RSS Git Download  Clone
Raw Blame History 23kB 606 lines
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import P3xrAccordion from '../../components/P3xrAccordion.vue'
import P3xrButton from '../../components/P3xrButton.vue'
import { useI18nStore } from '../../stores/i18n'
import { useRedisStateStore } from '../../stores/redis-state'
import { useCommonStore } from '../../stores/common'
import { request, onSocketEvent } from '../../stores/socket.service'

const i18n = useI18nStore()
const state = useRedisStateStore()
const common = useCommonStore()

const strings = computed(() => i18n.strings)
const s = computed(() => strings.value?.page?.analysis || {})

const data = ref<any>(null)
const loading = ref(false)
const topN = ref(20)
const maxScanKeys = ref(5000)
const typeEntries = ref<Array<{ type: string; count: number; bytes: number }>>([])

const doctorText = ref<string | null>(null)
const doctorLoading = ref(false)
const autoRefreshDoctor = ref(localStorage.getItem('p3xr-monitor-auto-doctor') === 'true')
let doctorInterval: any = null

const typeChartEl = ref<HTMLDivElement>()
const prefixChartEl = ref<HTMLDivElement>()
const rootEl = ref<HTMLElement>()

const connName = computed(() => state.connection?.name)

let themeObserver: MutationObserver | null = null
let chartResizeObserver: ResizeObserver | null = null
let resizeTimer: any
const unsubFns: Array<() => void> = []

function formatBytes(bytes: number): string {
    if (bytes == null || isNaN(bytes)) return '-'
    if (bytes < 1024) return bytes + ' B'
    if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
    if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
    return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB'
}

function formatTTL(seconds: number): string {
    if (!seconds || seconds <= 0) return '-'
    if (seconds < 60) return seconds + 's'
    if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'
    if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ' + Math.floor((seconds % 3600) / 60) + 'm'
    return Math.floor(seconds / 86400) + 'd ' + Math.floor((seconds % 86400) / 3600) + 'h'
}

function downloadText(content: string, filename: string) {
    const blob = new Blob([content], { type: 'text/plain' })
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    a.click()
    URL.revokeObjectURL(url)
}

// --- Chart helpers ---

function getChartColors() {
    const isDark = document.body.classList.contains('p3xr-theme-dark')
    const style = getComputedStyle(document.body)
    const primary = style.getPropertyValue('--p3xr-btn-primary-bg').trim()
    const accent = style.getPropertyValue('--p3xr-btn-accent-bg').trim()
    const warn = style.getPropertyValue('--p3xr-btn-warn-bg').trim()
    return {
        primary: primary || (isDark ? '#90caf9' : '#1976d2'),
        accent: accent || (isDark ? '#ce93d8' : '#9c27b0'),
        warn: warn || (isDark ? '#ef9a9a' : '#f44336'),
        text: isDark ? 'rgba(255,255,255,0.87)' : 'rgba(0,0,0,0.87)',
        grid: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)',
        isDark,
    }
}

function getBarColors(colors: ReturnType<typeof getChartColors>): string[] {
    const isDark = colors.isDark
    return [
        colors.primary, colors.accent, colors.warn,
        isDark ? '#ffb74d' : '#ff9800',
        isDark ? '#81c784' : '#4caf50',
        isDark ? '#4dd0e1' : '#00bcd4',
        isDark ? '#a1887f' : '#795548',
        isDark ? '#90a4ae' : '#607d8b',
    ]
}

function drawBarChart(container: HTMLDivElement | undefined, items: Array<{ label: string; value: number }>) {
    if (!container || items.length === 0 || container.offsetWidth <= 0) return
    container.innerHTML = ''

    const colors = getChartColors()
    const barColors = getBarColors(colors)

    const canvas = document.createElement('canvas')
    const dpr = window.devicePixelRatio || 1
    const width = container.offsetWidth || 500
    const barHeight = 24
    const labelWidth = 120
    const valueWidth = 80
    const chartLeft = labelWidth + 8
    const chartRight = width - valueWidth - 8
    const chartWidth = chartRight - chartLeft
    const topPad = 8
    const height = topPad + items.length * (barHeight + 4) + 8

    canvas.width = width * dpr
    canvas.height = height * dpr
    canvas.style.width = width + 'px'
    canvas.style.height = height + 'px'

    const ctx = canvas.getContext('2d')!
    ctx.scale(dpr, dpr)

    const maxVal = Math.max(...items.map(i => i.value), 1)

    items.forEach((item, i) => {
        const y = topPad + i * (barHeight + 4)

        ctx.fillStyle = colors.text
        ctx.font = '12px Roboto, sans-serif'
        ctx.textAlign = 'right'
        ctx.textBaseline = 'middle'
        ctx.fillText(item.label.length > 15 ? item.label.substring(0, 14) + '\u2026' : item.label, labelWidth, y + barHeight / 2)

        ctx.fillStyle = colors.grid
        ctx.fillRect(chartLeft, y, chartWidth, barHeight)

        const barWidth = (item.value / maxVal) * chartWidth
        ctx.fillStyle = barColors[i % barColors.length]
        ctx.fillRect(chartLeft, y, barWidth, barHeight)

        ctx.fillStyle = colors.text
        ctx.font = '11px Roboto Mono, monospace'
        ctx.textAlign = 'left'
        ctx.fillText(formatBytes(item.value), chartRight + 8, y + barHeight / 2)
    })

    container.appendChild(canvas)
}

function drawCharts() {
    drawBarChart(typeChartEl.value, typeEntries.value.map(t => ({
        label: t.type,
        value: t.bytes,
    })))
    drawBarChart(prefixChartEl.value, (data.value?.prefixMemory || []).slice(0, 20).map((p: any) => ({
        label: p.prefix,
        value: p.totalBytes,
    })))
}

// --- Data fetching ---

async function runAnalysis() {
    if (loading.value) return
    loading.value = true
    try {
        const response = await request({
            action: 'memory/analysis',
            payload: { topN: topN.value, maxScanKeys: maxScanKeys.value },
        })
        data.value = response.data
        typeEntries.value = Object.keys(data.value.typeDistribution).map(type => ({
            type,
            count: data.value.typeDistribution[type],
            bytes: data.value.typeMemory[type] || 0,
        })).sort((a, b) => b.bytes - a.bytes)
        loading.value = false
        nextTick(() => setTimeout(() => drawCharts(), 100))
    } catch (e) {
        loading.value = false
        common.generalHandleError(e)
    }
}

// --- Memory Doctor ---

async function runDoctor() {
    doctorLoading.value = true
    try {
        const resp = await request({ action: 'memory/doctor' })
        doctorText.value = resp.data.text
    } catch (e) { common.generalHandleError(e) }
    finally { doctorLoading.value = false }
}

function toggleAutoDoctor() {
    autoRefreshDoctor.value = !autoRefreshDoctor.value
    localStorage.setItem('p3xr-monitor-auto-doctor', String(autoRefreshDoctor.value))
    if (autoRefreshDoctor.value) {
        runDoctor()
        startDoctorInterval()
    } else {
        stopDoctorInterval()
    }
}

function startDoctorInterval() {
    stopDoctorInterval()
    doctorInterval = setInterval(() => runDoctor(), 2000)
}

function stopDoctorInterval() {
    if (doctorInterval) { clearInterval(doctorInterval); doctorInterval = null }
}

function exportDoctor() {
    if (!doctorText.value) return
    downloadText(doctorText.value, `${connName.value}-memory-doctor.txt`)
}

// --- Exports ---

function exportOverview() {
    if (!data.value) return
    const t = s.value
    downloadText([
        `${t.keysScanned}: ${data.value.totalScanned} / ${data.value.dbSize}`,
        `${t.topN}: ${topN.value}`,
        `${t.maxScanKeys}: ${maxScanKeys.value}`,
    ].join('\n'), `${connName.value}-analysis-overview.txt`)
}

function exportMemoryBreakdown() {
    if (!data.value) return
    const t = s.value
    const m = data.value.memoryInfo
    downloadText([
        `${t.totalMemory}: ${m.usedHuman}`,
        `${t.rssMemory}: ${m.rssHuman}`,
        `${t.peakMemory}: ${m.peakHuman}`,
        `${t.overheadMemory}: ${formatBytes(m.overhead)}`,
        `${t.datasetMemory}: ${formatBytes(m.dataset)}`,
        `${t.luaMemory}: ${formatBytes(m.lua)}`,
        `${t.fragmentation}: ${m.fragRatio}x`,
        `${t.allocator}: ${m.allocator}`,
    ].join('\n'), `${connName.value}-memory-breakdown.txt`)
}

function exportExpiration() {
    if (!data.value) return
    const t = s.value
    const e = data.value.expirationOverview
    downloadText([
        `${t.withTTL}: ${e.withTTL}`,
        `${t.persistent}: ${e.persistent}`,
        `${t.avgTTL}: ${formatTTL(e.avgTTL)}`,
    ].join('\n'), `${connName.value}-expiration.txt`)
}

function exportChart(chartEl: HTMLDivElement | undefined, name: string) {
    const canvas = chartEl?.querySelector('canvas') as HTMLCanvasElement
    if (!canvas) return
    const exportCanvas = document.createElement('canvas')
    exportCanvas.width = canvas.width
    exportCanvas.height = canvas.height
    const ctx = exportCanvas.getContext('2d')!
    const isDarkBg = document.body.classList.contains('p3xr-theme-dark')
    ctx.fillStyle = getComputedStyle(document.body).getPropertyValue('--p3xr-body-bg').trim() || (isDarkBg ? '#1e1e1e' : '#ffffff')
    ctx.fillRect(0, 0, exportCanvas.width, exportCanvas.height)
    ctx.drawImage(canvas, 0, 0)
    const url = exportCanvas.toDataURL('image/png')
    const a = document.createElement('a')
    a.href = url
    a.download = `${connName.value}-${name}.png`
    a.click()
}

// --- Lifecycle ---

// Watch rootEl + chart containers — set up ResizeObserver when they appear
// Fires on window resize, accordion open/close, any layout change
watch([rootEl, typeChartEl, prefixChartEl], () => {
    chartResizeObserver?.disconnect()
    chartResizeObserver = null
    const els = [rootEl.value, typeChartEl.value, prefixChartEl.value].filter(Boolean) as HTMLElement[]
    if (els.length > 0) {
        chartResizeObserver = new ResizeObserver(() => {
            clearTimeout(resizeTimer)
            resizeTimer = setTimeout(() => { if (data.value) drawCharts() }, 150)
        })
        els.forEach(el => chartResizeObserver!.observe(el))
    }
})

onMounted(() => {
    if (autoRefreshDoctor.value) startDoctorInterval()
    if (state.connection) runAnalysis()

    unsubFns.push(onSocketEvent('connections', () => {
        data.value = null
        if (state.connection) runAnalysis()
    }))

    themeObserver = new MutationObserver(() => {
        if (data.value) setTimeout(() => drawCharts(), 100)
    })
    themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] })
})

onUnmounted(() => {
    stopDoctorInterval()
    themeObserver?.disconnect()
    chartResizeObserver?.disconnect()
    unsubFns.forEach(fn => fn())
})
</script>

<template>
    <!-- Memory Doctor (always visible) -->
    <P3xrAccordion :title="s.memoryDoctor" accordion-key="analysis-doctor">
        <template #actions>
            <P3xrButton
                @click="toggleAutoDoctor(); $event.stopPropagation()"
                :label="strings?.label?.autoRefresh"
                :icon="autoRefreshDoctor ? 'mdi-checkbox-marked' : 'mdi-checkbox-blank-outline'"
            />
            <P3xrButton
                v-if="!autoRefreshDoctor"
                @click="runDoctor(); $event.stopPropagation()"
                :label="doctorLoading ? strings?.label?.loading : strings?.intention?.refresh"
                :icon="doctorLoading ? 'mdi-timer-sand' : 'mdi-refresh'"
                :disabled="doctorLoading"
            />
            <P3xrButton
                @click="exportDoctor(); $event.stopPropagation()"
                :label="strings?.intention?.export"
                icon="mdi-download"
            />
        </template>
        <div v-if="!doctorText" style="padding: 12px 16px; opacity: 0.6;">
            {{ s.doctorNoData }}
        </div>
        <pre v-else style="white-space: pre-wrap; font-family: 'Roboto Mono', monospace; font-size: 13px; padding: 12px 16px; margin: 0;">{{ doctorText }}</pre>
    </P3xrAccordion>

    <br />

    <div v-if="loading && !data" class="p3xr-analysis-loading">
        <v-icon>mdi-timer-sand</v-icon>
        <span>{{ s.running }}</span>
    </div>

    <div v-if="!loading && !data" class="p3xr-analysis-loading">
        <v-icon>mdi-chart-bar</v-icon>
        <span>{{ s.noData }}</span>
    </div>

    <div v-if="data" ref="rootEl">
        <!-- Controls + Overview -->
        <P3xrAccordion :title="s.title" accordion-key="analysis-controls">
            <template #actions>
                <P3xrButton
                    @click="runAnalysis(); $event.stopPropagation()"
                    :label="loading ? s.running : s.runAnalysis"
                    :icon="loading ? 'mdi-timer-sand' : 'mdi-play'"
                    :disabled="loading"
                />
                <P3xrButton
                    @click="exportOverview(); $event.stopPropagation()"
                    :label="strings?.intention?.export"
                    icon="mdi-download"
                />
            </template>
            <v-list density="compact" class="pa-0">
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.keysScanned }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.totalScanned?.toLocaleString() }} / {{ data.dbSize?.toLocaleString() }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.topN }}</div>
                        <div class="p3xr-pair-value">
                            <v-text-field
                                v-model.number="topN"
                                type="number"
                                min="5"
                                max="100"
                                hide-details
                                style="max-width: 100px;"
                            />
                        </div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.maxScanKeys }}</div>
                        <div class="p3xr-pair-value">
                            <v-text-field
                                v-model.number="maxScanKeys"
                                type="number"
                                min="100"
                                max="100000"
                                step="1000"
                                hide-details
                                style="max-width: 120px;"
                            />
                        </div>
                    </div>
                </v-list-item>
            </v-list>
        </P3xrAccordion>

        <br />

        <!-- Memory Breakdown -->
        <P3xrAccordion :title="s.memoryBreakdown" accordion-key="analysis-memory-info">
            <template #actions>
                <P3xrButton @click="exportMemoryBreakdown(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
            </template>
            <v-list density="compact" class="pa-0">
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.totalMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.memoryInfo.usedHuman }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.rssMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.memoryInfo.rssHuman }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.peakMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.memoryInfo.peakHuman }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.overheadMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ formatBytes(data.memoryInfo.overhead) }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.datasetMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ formatBytes(data.memoryInfo.dataset) }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.luaMemory }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ formatBytes(data.memoryInfo.lua) }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.fragmentation }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.memoryInfo.fragRatio }}x</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.allocator }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.memoryInfo.allocator }}</div>
                    </div>
                </v-list-item>
            </v-list>
        </P3xrAccordion>

        <br />

        <!-- Type Distribution -->
        <P3xrAccordion :title="s.typeDistribution" accordion-key="analysis-type-dist">
            <template #actions>
                <P3xrButton @click="exportChart(typeChartEl, 'type-distribution'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
            </template>
            <div ref="typeChartEl" class="p3xr-analysis-chart"></div>
            <v-list density="compact" class="pa-0">
                <template v-for="item in typeEntries" :key="item.type">
                    <v-list-item>
                        <div class="p3xr-pair-row">
                            <div class="p3xr-pair-label">
                                <span style="font-weight: 500;">{{ item.type }}</span>
                                <span class="p3xr-analysis-sub">{{ item.count }} keys</span>
                            </div>
                            <div class="p3xr-pair-value p3xr-mono">{{ formatBytes(item.bytes) }}</div>
                        </div>
                    </v-list-item>
                    <v-divider />
                </template>
            </v-list>
        </P3xrAccordion>

        <br />

        <!-- Memory by Prefix -->
        <P3xrAccordion :title="s.prefixMemory" accordion-key="analysis-prefix-mem">
            <template #actions>
                <P3xrButton @click="exportChart(prefixChartEl, 'memory-by-prefix'); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
            </template>
            <div ref="prefixChartEl" class="p3xr-analysis-chart"></div>
            <v-list density="compact" class="pa-0">
                <template v-for="(item, i) in data.prefixMemory" :key="item.prefix">
                    <v-list-item>
                        <div class="p3xr-pair-row">
                            <div class="p3xr-pair-label">
                                <span style="opacity: 0.4; margin-right: 8px;">#{{ i + 1 }}</span>
                                <span class="p3xr-mono" style="font-size: 13px;">{{ item.prefix }}</span>
                                <span class="p3xr-analysis-sub">{{ item.keyCount }} keys</span>
                            </div>
                            <div class="p3xr-pair-value p3xr-mono">{{ formatBytes(item.totalBytes) }}</div>
                        </div>
                    </v-list-item>
                    <v-divider />
                </template>
            </v-list>
        </P3xrAccordion>

        <br />

        <!-- Key Expiration Overview -->
        <P3xrAccordion :title="s.expirationOverview" accordion-key="analysis-expiration">
            <template #actions>
                <P3xrButton @click="exportExpiration(); $event.stopPropagation()" :label="strings?.intention?.export" icon="mdi-download" />
            </template>
            <v-list density="compact" class="pa-0">
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.withTTL }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.expirationOverview.withTTL?.toLocaleString() }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.persistent }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ data.expirationOverview.persistent?.toLocaleString() }}</div>
                    </div>
                </v-list-item>
                <v-divider />
                <v-list-item>
                    <div class="p3xr-pair-row">
                        <div class="p3xr-pair-label">{{ s.avgTTL }}</div>
                        <div class="p3xr-pair-value p3xr-mono">{{ formatTTL(data.expirationOverview.avgTTL) }}</div>
                    </div>
                </v-list-item>
            </v-list>
        </P3xrAccordion>
    </div>
</template>

<style scoped>
.p3xr-analysis-loading {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 32px 16px;
    opacity: 0.5;
}

.p3xr-pair-row {
    display: flex;
    width: 100%;
    gap: 16px;
    align-items: center;
}
.p3xr-pair-label {
    flex: 1 1 auto;
    min-width: 0;
    font-weight: 500;
}
.p3xr-pair-value {
    flex: 0 1 auto;
    min-width: 0;
    text-align: right;
    white-space: nowrap;
}
.p3xr-mono {
    font-family: 'Roboto Mono', monospace;
    font-size: 13px;
}

.p3xr-analysis-chart {
    padding: 8px 16px;
    min-height: 50px;
}

.p3xr-analysis-sub {
    opacity: 0.5;
    font-size: 12px;
    margin-left: 8px;
}
</style>