Remove Jinja2 template fallback (1249 lines) — /admin/performance now serves Vue SPA exclusively via send_from_directory. Backend: - Add _SLOW_QUERY_WAITING counter with get_slow_query_waiting_count() - Record slow-path latency in read_sql_df_slow/iter via record_query_latency() - Extend metrics_history schema with slow_query_active, slow_query_waiting, worker_rss_bytes columns + ALTER TABLE migration for existing DBs - Add cleanup_archive_logs() with configurable ARCHIVE_LOG_DIR/KEEP_COUNT - Integrate archive cleanup into MetricsHistoryCollector 50-min cycle Frontend: - Add slow_query_active and slow_query_waiting StatCards to connection pool - Add slow_query_active trend line to pool trend chart - Add Worker memory (RSS MB) trend chart with preprocessing - Update modernization gate check path to frontend style.css Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
635 lines
21 KiB
Vue
635 lines
21 KiB
Vue
<template>
|
||
<div class="perf-dashboard">
|
||
<!-- Header -->
|
||
<header class="perf-header">
|
||
<div class="perf-header-inner">
|
||
<h1 class="perf-title">效能監控儀表板</h1>
|
||
<div class="perf-header-actions">
|
||
<label class="auto-refresh-toggle">
|
||
<input type="checkbox" v-model="autoRefreshEnabled" @change="toggleAutoRefresh" />
|
||
自動更新 (30s)
|
||
</label>
|
||
<button class="btn btn-sm" @click="refreshAll" :disabled="loading">
|
||
<template v-if="loading">更新中...</template>
|
||
<template v-else>重新整理</template>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- Status Cards -->
|
||
<section class="panel">
|
||
<div class="status-cards-grid">
|
||
<div class="status-card">
|
||
<div class="status-card-title">Database</div>
|
||
<StatusDot :status="dbStatus" :label="dbStatusLabel" />
|
||
</div>
|
||
<div class="status-card">
|
||
<div class="status-card-title">Redis</div>
|
||
<StatusDot :status="redisStatus" :label="redisStatusLabel" />
|
||
</div>
|
||
<div class="status-card">
|
||
<div class="status-card-title">Circuit Breaker</div>
|
||
<StatusDot :status="cbStatus" :label="cbStatusLabel" />
|
||
</div>
|
||
<div class="status-card">
|
||
<div class="status-card-title">Worker PID</div>
|
||
<StatusDot status="healthy" :label="String(systemData?.worker_pid || '-')" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Query Performance -->
|
||
<section class="panel">
|
||
<h2 class="panel-title">查詢效能</h2>
|
||
<div class="query-perf-grid">
|
||
<div class="query-perf-stats">
|
||
<StatCard :value="metricsData?.p50_ms" label="P50 (ms)" />
|
||
<StatCard :value="metricsData?.p95_ms" label="P95 (ms)" />
|
||
<StatCard :value="metricsData?.p99_ms" label="P99 (ms)" />
|
||
<StatCard :value="metricsData?.count" label="查詢數" />
|
||
<StatCard :value="metricsData?.slow_count" label="慢查詢" />
|
||
<StatCard :value="slowRateDisplay" label="慢查詢率" />
|
||
</div>
|
||
<div class="query-perf-chart" ref="latencyChartRef"></div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Query Latency Trend -->
|
||
<TrendChart
|
||
v-if="historyData.length > 1"
|
||
title="查詢延遲趨勢"
|
||
:snapshots="historyData"
|
||
:series="latencyTrendSeries"
|
||
yAxisLabel="ms"
|
||
/>
|
||
|
||
<!-- Redis Cache Detail -->
|
||
<section class="panel" v-if="perfDetail?.redis">
|
||
<h2 class="panel-title">Redis 快取</h2>
|
||
<div class="redis-grid">
|
||
<div class="redis-stats">
|
||
<GaugeBar
|
||
label="記憶體使用"
|
||
:value="redisMemoryRatio"
|
||
:max="1"
|
||
:displayText="redisMemoryLabel"
|
||
/>
|
||
<div class="redis-mini-stats">
|
||
<StatCard :value="perfDetail.redis.used_memory_human" label="已使用" />
|
||
<StatCard :value="perfDetail.redis.peak_memory_human" label="峰值" />
|
||
<StatCard :value="perfDetail.redis.connected_clients" label="連線數" />
|
||
<StatCard :value="hitRateDisplay" label="命中率" />
|
||
</div>
|
||
</div>
|
||
<div class="redis-namespaces">
|
||
<table class="mini-table">
|
||
<thead><tr><th>Namespace</th><th>Key 數量</th></tr></thead>
|
||
<tbody>
|
||
<tr v-for="ns in perfDetail.redis.namespaces" :key="ns.name">
|
||
<td>{{ ns.name }}</td>
|
||
<td>{{ ns.key_count }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<section class="panel panel-disabled" v-else-if="perfDetail && !perfDetail.redis">
|
||
<h2 class="panel-title">Redis 快取</h2>
|
||
<p class="muted">Redis 未啟用</p>
|
||
</section>
|
||
|
||
<!-- Redis Memory Trend -->
|
||
<TrendChart
|
||
v-if="historyData.length > 1"
|
||
title="Redis 記憶體趨勢"
|
||
:snapshots="historyData"
|
||
:series="redisTrendSeries"
|
||
/>
|
||
|
||
<!-- Memory Caches -->
|
||
<section class="panel" v-if="perfDetail">
|
||
<h2 class="panel-title">記憶體快取</h2>
|
||
<div class="cache-cards-grid">
|
||
<div class="cache-card" v-for="(info, name) in perfDetail.process_caches" :key="name">
|
||
<div class="cache-card-name">{{ name }}</div>
|
||
<div class="cache-card-desc">{{ info.description }}</div>
|
||
<GaugeBar
|
||
label="使用率"
|
||
:value="info.entries"
|
||
:max="info.max_size"
|
||
/>
|
||
<div class="cache-card-ttl">TTL: {{ info.ttl_seconds }}s</div>
|
||
</div>
|
||
</div>
|
||
<div class="route-cache-section" v-if="perfDetail.route_cache">
|
||
<h3 class="sub-title">Route Cache</h3>
|
||
<div class="route-cache-stats">
|
||
<StatCard :value="perfDetail.route_cache.mode" label="模式" />
|
||
<StatCard :value="perfDetail.route_cache.l1_size" label="L1 大小" />
|
||
<StatCard :value="routeCacheL1HitRate" label="L1 命中率" />
|
||
<StatCard :value="routeCacheL2HitRate" label="L2 命中率" />
|
||
<StatCard :value="routeCacheMissRate" label="未命中率" />
|
||
<StatCard :value="perfDetail.route_cache.reads_total" label="總讀取" />
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Cache Hit Rate Trend -->
|
||
<TrendChart
|
||
v-if="historyData.length > 1"
|
||
title="快取命中率趨勢"
|
||
:snapshots="historyData"
|
||
:series="hitRateTrendSeries"
|
||
yAxisLabel=""
|
||
:yMax="1"
|
||
/>
|
||
|
||
<!-- Connection Pool -->
|
||
<section class="panel" v-if="perfDetail?.db_pool?.status">
|
||
<h2 class="panel-title">連線池</h2>
|
||
<GaugeBar
|
||
label="飽和度"
|
||
:value="perfDetail.db_pool.status.saturation"
|
||
:max="1"
|
||
/>
|
||
<div class="pool-stats-grid">
|
||
<StatCard :value="perfDetail.db_pool.status.checked_out" label="使用中" />
|
||
<StatCard :value="perfDetail.db_pool.status.checked_in" label="閒置" />
|
||
<StatCard :value="poolTotalConnections" label="總連線數" />
|
||
<StatCard :value="perfDetail.db_pool.status.max_capacity" label="最大容量" />
|
||
<StatCard :value="poolOverflowDisplay" label="溢出連線" />
|
||
<StatCard :value="perfDetail.db_pool.status.slow_query_active" label="慢查詢執行中" />
|
||
<StatCard :value="perfDetail.db_pool.status.slow_query_waiting" label="慢查詢排隊中" />
|
||
<StatCard :value="perfDetail.db_pool.config?.pool_size" label="池大小" />
|
||
<StatCard :value="perfDetail.db_pool.config?.pool_recycle" label="回收週期 (s)" />
|
||
<StatCard :value="perfDetail.db_pool.config?.pool_timeout" label="逾時 (s)" />
|
||
<StatCard :value="perfDetail.direct_connections?.total_since_start" label="直連次數" />
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Connection Pool Trend -->
|
||
<TrendChart
|
||
v-if="historyData.length > 1"
|
||
title="連線池趨勢"
|
||
:snapshots="historyData"
|
||
:series="poolTrendSeries"
|
||
/>
|
||
|
||
<!-- Worker Memory Trend -->
|
||
<TrendChart
|
||
v-if="historyData.length > 1"
|
||
title="Worker 記憶體趨勢"
|
||
:snapshots="historyData"
|
||
:series="memoryTrendSeries"
|
||
yAxisLabel="MB"
|
||
/>
|
||
|
||
<!-- Worker Control -->
|
||
<section class="panel">
|
||
<h2 class="panel-title">Worker 控制</h2>
|
||
<div class="worker-info">
|
||
<StatCard :value="workerData?.worker_pid" label="PID" />
|
||
<StatCard :value="workerStartTimeDisplay" label="啟動時間" />
|
||
<StatCard :value="cooldownDisplay" label="冷卻狀態" />
|
||
</div>
|
||
<button
|
||
class="btn btn-danger"
|
||
:disabled="workerCooldownActive"
|
||
@click="showRestartModal = true"
|
||
>
|
||
重啟 Worker
|
||
</button>
|
||
|
||
<!-- Restart Modal -->
|
||
<div class="modal-backdrop" v-if="showRestartModal" @click.self="showRestartModal = false">
|
||
<div class="modal-dialog">
|
||
<h3>確認重啟 Worker</h3>
|
||
<p>重啟將導致目前的請求暫時中斷,確定要繼續嗎?</p>
|
||
<div class="modal-actions">
|
||
<button class="btn" @click="showRestartModal = false">取消</button>
|
||
<button class="btn btn-danger" @click="doRestart" :disabled="restartLoading">
|
||
{{ restartLoading ? '重啟中...' : '確認重啟' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- System Logs -->
|
||
<section class="panel">
|
||
<h2 class="panel-title">系統日誌</h2>
|
||
<div class="log-controls">
|
||
<select v-model="logLevel" @change="loadLogs">
|
||
<option value="">全部等級</option>
|
||
<option value="ERROR">ERROR</option>
|
||
<option value="WARNING">WARNING</option>
|
||
<option value="INFO">INFO</option>
|
||
<option value="DEBUG">DEBUG</option>
|
||
</select>
|
||
<input
|
||
type="text"
|
||
v-model="logSearch"
|
||
placeholder="搜尋日誌..."
|
||
@input="debouncedLoadLogs"
|
||
/>
|
||
<button class="btn btn-sm" @click="cleanupLogs" :disabled="cleanupLoading">
|
||
{{ cleanupLoading ? '清理中...' : '清理日誌' }}
|
||
</button>
|
||
</div>
|
||
<div class="log-table-wrapper">
|
||
<table class="log-table" v-if="logsData?.logs?.length">
|
||
<thead>
|
||
<tr>
|
||
<th>時間</th>
|
||
<th>等級</th>
|
||
<th>訊息</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr v-for="(log, i) in logsData.logs" :key="i" :class="'log-' + (log.level || '').toLowerCase()">
|
||
<td class="log-time">{{ log.timestamp }}</td>
|
||
<td class="log-level">{{ log.level }}</td>
|
||
<td class="log-msg">{{ log.message }}</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<p v-else class="muted">無日誌</p>
|
||
</div>
|
||
<div class="log-pagination" v-if="logsData?.total > logLimit">
|
||
<button class="btn btn-sm" :disabled="logOffset === 0" @click="logOffset -= logLimit; loadLogs()">上一頁</button>
|
||
<span>{{ logOffset / logLimit + 1 }} / {{ Math.ceil(logsData.total / logLimit) }}</span>
|
||
<button class="btn btn-sm" :disabled="logOffset + logLimit >= logsData.total" @click="logOffset += logLimit; loadLogs()">下一頁</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
|
||
import * as echarts from 'echarts/core';
|
||
import { BarChart } from 'echarts/charts';
|
||
import { GridComponent, TooltipComponent } from 'echarts/components';
|
||
import { CanvasRenderer } from 'echarts/renderers';
|
||
|
||
import { apiGet, apiPost } from '../core/api.js';
|
||
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
|
||
|
||
import GaugeBar from './components/GaugeBar.vue';
|
||
import StatCard from './components/StatCard.vue';
|
||
import StatusDot from './components/StatusDot.vue';
|
||
import TrendChart from './components/TrendChart.vue';
|
||
|
||
echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer]);
|
||
|
||
// --- State ---
|
||
const loading = ref(false);
|
||
const autoRefreshEnabled = ref(true);
|
||
const systemData = ref(null);
|
||
const metricsData = ref(null);
|
||
const perfDetail = ref(null);
|
||
const historyData = ref([]);
|
||
const logsData = ref(null);
|
||
const workerData = ref(null);
|
||
|
||
const logLevel = ref('');
|
||
const logSearch = ref('');
|
||
const logOffset = ref(0);
|
||
const logLimit = 50;
|
||
|
||
const showRestartModal = ref(false);
|
||
const restartLoading = ref(false);
|
||
const cleanupLoading = ref(false);
|
||
|
||
const latencyChartRef = ref(null);
|
||
let chartInstance = null;
|
||
|
||
// --- Computed ---
|
||
const dbStatus = computed(() => {
|
||
const s = systemData.value?.database?.status;
|
||
if (s === 'healthy' || s === 'ok') return 'healthy';
|
||
if (s === 'error') return 'error';
|
||
return 'disabled';
|
||
});
|
||
const dbStatusLabel = computed(() => systemData.value?.database?.status || '-');
|
||
|
||
const redisStatus = computed(() => {
|
||
const r = systemData.value?.redis;
|
||
if (!r?.enabled) return 'disabled';
|
||
if (r.status === 'healthy' || r.status === 'ok') return 'healthy';
|
||
if (r.status === 'error') return 'error';
|
||
return 'degraded';
|
||
});
|
||
const redisStatusLabel = computed(() => {
|
||
const r = systemData.value?.redis;
|
||
if (!r?.enabled) return '未啟用';
|
||
return r.status || '-';
|
||
});
|
||
|
||
const cbStatus = computed(() => {
|
||
const s = systemData.value?.circuit_breaker?.state;
|
||
if (s === 'CLOSED') return 'healthy';
|
||
if (s === 'OPEN') return 'error';
|
||
if (s === 'HALF_OPEN') return 'degraded';
|
||
return 'disabled';
|
||
});
|
||
const cbStatusLabel = computed(() => systemData.value?.circuit_breaker?.state || '-');
|
||
|
||
const slowRateDisplay = computed(() => {
|
||
const r = metricsData.value?.slow_rate;
|
||
return r != null ? `${(r * 100).toFixed(1)}%` : '-';
|
||
});
|
||
|
||
const redisMemoryRatio = computed(() => {
|
||
const r = perfDetail.value?.redis;
|
||
if (!r) return 0;
|
||
const used = r.used_memory || 0;
|
||
const max = r.maxmemory || 0;
|
||
if (max > 0) return used / max;
|
||
const peak = r.peak_memory || used;
|
||
return peak > 0 ? used / peak : 0;
|
||
});
|
||
const redisMemoryLabel = computed(() => {
|
||
const r = perfDetail.value?.redis;
|
||
if (!r) return '';
|
||
const used = r.used_memory_human || 'N/A';
|
||
const max = r.maxmemory && r.maxmemory > 0
|
||
? r.maxmemory_human
|
||
: r.peak_memory_human;
|
||
return `${used} / ${max || 'N/A'}`;
|
||
});
|
||
|
||
const hitRateDisplay = computed(() => {
|
||
const r = perfDetail.value?.redis?.hit_rate;
|
||
return r != null ? `${(r * 100).toFixed(1)}%` : '-';
|
||
});
|
||
|
||
const routeCacheL1HitRate = computed(() => {
|
||
const r = perfDetail.value?.route_cache?.l1_hit_rate;
|
||
return r != null ? `${(r * 100).toFixed(1)}%` : '-';
|
||
});
|
||
const routeCacheL2HitRate = computed(() => {
|
||
const r = perfDetail.value?.route_cache?.l2_hit_rate;
|
||
return r != null ? `${(r * 100).toFixed(1)}%` : '-';
|
||
});
|
||
const routeCacheMissRate = computed(() => {
|
||
const r = perfDetail.value?.route_cache?.miss_rate;
|
||
return r != null ? `${(r * 100).toFixed(1)}%` : '-';
|
||
});
|
||
|
||
const poolOverflowDisplay = computed(() => {
|
||
const overflow = perfDetail.value?.db_pool?.status?.overflow;
|
||
if (overflow == null) return '-';
|
||
return Math.max(0, overflow);
|
||
});
|
||
const poolTotalConnections = computed(() => {
|
||
const s = perfDetail.value?.db_pool?.status;
|
||
if (!s) return '-';
|
||
return (s.checked_out || 0) + (s.checked_in || 0);
|
||
});
|
||
|
||
const workerStartTimeDisplay = computed(() => {
|
||
const t = workerData.value?.worker_start_time;
|
||
if (!t) return '-';
|
||
try {
|
||
return new Date(t).toLocaleString('zh-TW');
|
||
} catch {
|
||
return t;
|
||
}
|
||
});
|
||
|
||
const workerCooldownActive = computed(() => workerData.value?.cooldown?.active || false);
|
||
const cooldownDisplay = computed(() => {
|
||
if (workerCooldownActive.value) {
|
||
const secs = workerData.value?.cooldown?.remaining_seconds || 0;
|
||
return `冷卻中 (${secs}s)`;
|
||
}
|
||
return '就緒';
|
||
});
|
||
|
||
// --- Data Fetching ---
|
||
async function loadSystemStatus() {
|
||
try {
|
||
const res = await apiGet('/admin/api/system-status');
|
||
systemData.value = res?.data || null;
|
||
} catch (e) {
|
||
console.error('Failed to load system status:', e);
|
||
}
|
||
}
|
||
|
||
async function loadMetrics() {
|
||
try {
|
||
const res = await apiGet('/admin/api/metrics');
|
||
metricsData.value = res?.data || null;
|
||
updateLatencyChart();
|
||
} catch (e) {
|
||
console.error('Failed to load metrics:', e);
|
||
}
|
||
}
|
||
|
||
async function loadPerformanceDetail() {
|
||
try {
|
||
const res = await apiGet('/admin/api/performance-detail');
|
||
perfDetail.value = res?.data || null;
|
||
} catch (e) {
|
||
console.error('Failed to load performance detail:', e);
|
||
}
|
||
}
|
||
|
||
async function loadLogs() {
|
||
try {
|
||
const params = { limit: logLimit, offset: logOffset.value };
|
||
if (logLevel.value) params.level = logLevel.value;
|
||
if (logSearch.value) params.q = logSearch.value;
|
||
const res = await apiGet('/admin/api/logs', { params });
|
||
logsData.value = res?.data || null;
|
||
} catch (e) {
|
||
console.error('Failed to load logs:', e);
|
||
}
|
||
}
|
||
|
||
async function loadWorkerStatus() {
|
||
try {
|
||
const res = await apiGet('/admin/api/worker/status');
|
||
workerData.value = res?.data || null;
|
||
} catch (e) {
|
||
console.error('Failed to load worker status:', e);
|
||
}
|
||
}
|
||
|
||
async function loadPerformanceHistory() {
|
||
try {
|
||
const res = await apiGet('/admin/api/performance-history', { params: { minutes: 30 } });
|
||
const snapshots = res?.data?.snapshots || [];
|
||
// Pre-process: convert worker_rss_bytes to worker_rss_mb for trend chart
|
||
historyData.value = snapshots.map((s) => ({
|
||
...s,
|
||
worker_rss_mb: s.worker_rss_bytes ? Math.round(s.worker_rss_bytes / 1048576 * 10) / 10 : 0,
|
||
}));
|
||
} catch (e) {
|
||
console.error('Failed to load performance history:', e);
|
||
}
|
||
}
|
||
|
||
// --- Trend Chart Series Configs ---
|
||
const poolTrendSeries = [
|
||
{ name: '飽和度', key: 'pool_saturation', color: '#6366f1' },
|
||
{ name: '使用中', key: 'pool_checked_out', color: '#f59e0b' },
|
||
{ name: '慢查詢執行中', key: 'slow_query_active', color: '#ef4444' },
|
||
];
|
||
|
||
const latencyTrendSeries = [
|
||
{ name: 'P50', key: 'latency_p50_ms', color: '#22c55e' },
|
||
{ name: 'P95', key: 'latency_p95_ms', color: '#f59e0b' },
|
||
{ name: 'P99', key: 'latency_p99_ms', color: '#ef4444' },
|
||
];
|
||
|
||
const redisTrendSeries = [
|
||
{ name: '記憶體 (bytes)', key: 'redis_used_memory', color: '#06b6d4' },
|
||
];
|
||
|
||
const hitRateTrendSeries = [
|
||
{ name: 'Redis 命中率', key: 'redis_hit_rate', color: '#22c55e' },
|
||
{ name: 'L1 命中率', key: 'rc_l1_hit_rate', color: '#2563eb' },
|
||
{ name: 'L2 命中率', key: 'rc_l2_hit_rate', color: '#f59e0b' },
|
||
];
|
||
|
||
const memoryTrendSeries = [
|
||
{ name: 'RSS (MB)', key: 'worker_rss_mb', color: '#8b5cf6' },
|
||
];
|
||
|
||
async function refreshAll() {
|
||
loading.value = true;
|
||
try {
|
||
await Promise.all([
|
||
loadSystemStatus(),
|
||
loadMetrics(),
|
||
loadPerformanceDetail(),
|
||
loadPerformanceHistory(),
|
||
loadLogs(),
|
||
loadWorkerStatus(),
|
||
]);
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
// --- Auto Refresh ---
|
||
const { startAutoRefresh, stopAutoRefresh } = useAutoRefresh({
|
||
onRefresh: refreshAll,
|
||
intervalMs: 30_000,
|
||
autoStart: false,
|
||
});
|
||
|
||
function toggleAutoRefresh() {
|
||
if (autoRefreshEnabled.value) {
|
||
startAutoRefresh();
|
||
} else {
|
||
stopAutoRefresh();
|
||
}
|
||
}
|
||
|
||
// --- Worker Restart ---
|
||
async function doRestart() {
|
||
restartLoading.value = true;
|
||
try {
|
||
await apiPost('/admin/api/worker/restart', {});
|
||
showRestartModal.value = false;
|
||
await loadWorkerStatus();
|
||
} catch (e) {
|
||
alert(e.message || '重啟失敗');
|
||
} finally {
|
||
restartLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// --- Log Cleanup ---
|
||
async function cleanupLogs() {
|
||
cleanupLoading.value = true;
|
||
try {
|
||
await apiPost('/admin/api/logs/cleanup', {});
|
||
await loadLogs();
|
||
} catch (e) {
|
||
console.error('Failed to cleanup logs:', e);
|
||
} finally {
|
||
cleanupLoading.value = false;
|
||
}
|
||
}
|
||
|
||
// --- Debounce ---
|
||
let debounceTimer = null;
|
||
function debouncedLoadLogs() {
|
||
clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(() => {
|
||
logOffset.value = 0;
|
||
loadLogs();
|
||
}, 300);
|
||
}
|
||
|
||
// --- ECharts ---
|
||
function updateLatencyChart() {
|
||
if (!latencyChartRef.value) return;
|
||
|
||
if (!chartInstance) {
|
||
chartInstance = echarts.init(latencyChartRef.value);
|
||
}
|
||
|
||
const latencies = metricsData.value?.latencies || [];
|
||
if (!latencies.length) {
|
||
chartInstance.clear();
|
||
return;
|
||
}
|
||
|
||
// Build histogram buckets
|
||
const buckets = [
|
||
{ label: '<100ms', max: 100 },
|
||
{ label: '100-500ms', max: 500 },
|
||
{ label: '500ms-1s', max: 1000 },
|
||
{ label: '1-5s', max: 5000 },
|
||
{ label: '>5s', max: Infinity },
|
||
];
|
||
const counts = buckets.map(() => 0);
|
||
for (const ms of latencies.map((v) => v * 1000)) {
|
||
for (let i = 0; i < buckets.length; i++) {
|
||
if (ms < buckets[i].max || i === buckets.length - 1) {
|
||
counts[i]++;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
chartInstance.setOption({
|
||
tooltip: { trigger: 'axis' },
|
||
grid: { left: 40, right: 20, top: 20, bottom: 30 },
|
||
xAxis: { type: 'category', data: buckets.map((b) => b.label) },
|
||
yAxis: { type: 'value' },
|
||
series: [
|
||
{
|
||
type: 'bar',
|
||
data: counts,
|
||
itemStyle: { color: '#6366f1' },
|
||
barMaxWidth: 40,
|
||
},
|
||
],
|
||
});
|
||
}
|
||
|
||
// --- Lifecycle ---
|
||
onMounted(async () => {
|
||
await refreshAll();
|
||
if (autoRefreshEnabled.value) {
|
||
startAutoRefresh();
|
||
}
|
||
});
|
||
|
||
onBeforeUnmount(() => {
|
||
stopAutoRefresh();
|
||
if (chartInstance) {
|
||
chartInstance.dispose();
|
||
chartInstance = null;
|
||
}
|
||
clearTimeout(debounceTimer);
|
||
});
|
||
</script>
|