feat(admin-performance): Vue 3 SPA dashboard with metrics history trending

Rebuild /admin/performance from Jinja2 to Vue 3 SPA with ECharts, adding
cache telemetry infrastructure, connection pool monitoring, and SQLite-backed
historical metrics collection with trend chart visualization.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-23 09:18:10 +08:00
parent 1c46f5eb69
commit 5d570ca7a2
32 changed files with 2903 additions and 261 deletions

View File

@@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/reject-history/index.html ../src/mes_dashboard/static/dist/reject-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html",
"build": "vite build && cp ../src/mes_dashboard/static/dist/src/portal-shell/index.html ../src/mes_dashboard/static/dist/portal-shell.html && cp ../src/mes_dashboard/static/dist/src/tables/index.html ../src/mes_dashboard/static/dist/tables.html && cp ../src/mes_dashboard/static/dist/src/qc-gate/index.html ../src/mes_dashboard/static/dist/qc-gate.html && cp ../src/mes_dashboard/static/dist/src/wip-overview/index.html ../src/mes_dashboard/static/dist/wip-overview.html && cp ../src/mes_dashboard/static/dist/src/wip-detail/index.html ../src/mes_dashboard/static/dist/wip-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-detail/index.html ../src/mes_dashboard/static/dist/hold-detail.html && cp ../src/mes_dashboard/static/dist/src/hold-overview/index.html ../src/mes_dashboard/static/dist/hold-overview.html && cp ../src/mes_dashboard/static/dist/src/hold-history/index.html ../src/mes_dashboard/static/dist/hold-history.html && cp ../src/mes_dashboard/static/dist/src/reject-history/index.html ../src/mes_dashboard/static/dist/reject-history.html && cp ../src/mes_dashboard/static/dist/src/resource-status/index.html ../src/mes_dashboard/static/dist/resource-status.html && cp ../src/mes_dashboard/static/dist/src/resource-history/index.html ../src/mes_dashboard/static/dist/resource-history.html && cp ../src/mes_dashboard/static/dist/src/mid-section-defect/index.html ../src/mes_dashboard/static/dist/mid-section-defect.html && cp ../src/mes_dashboard/static/dist/src/admin-performance/index.html ../src/mes_dashboard/static/dist/admin-performance.html",
"test": "node --test tests/*.test.js"
},
"devDependencies": {

View File

@@ -0,0 +1,613 @@
<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.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 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 } });
historyData.value = res?.data?.snapshots || [];
} 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' },
];
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' },
];
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>

View File

@@ -0,0 +1,49 @@
<template>
<div class="gauge-bar">
<div class="gauge-bar-header">
<span class="gauge-bar-label">{{ label }}</span>
<span class="gauge-bar-value">{{ displayValue }}</span>
</div>
<div class="gauge-bar-track">
<div class="gauge-bar-fill" :style="fillStyle"></div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
label: { type: String, default: '' },
value: { type: Number, default: 0 },
max: { type: Number, default: 100 },
unit: { type: String, default: '%' },
displayText: { type: String, default: '' },
warningThreshold: { type: Number, default: 0.7 },
dangerThreshold: { type: Number, default: 0.9 },
});
const ratio = computed(() => {
if (props.max <= 0) return 0;
return Math.min(Math.max(props.value / props.max, 0), 1);
});
const displayValue = computed(() => {
if (props.displayText) return props.displayText;
if (props.unit === '%') {
return `${(ratio.value * 100).toFixed(1)}%`;
}
return `${props.value}${props.unit ? ' ' + props.unit : ''}`;
});
const fillColor = computed(() => {
if (ratio.value >= props.dangerThreshold) return '#ef4444';
if (ratio.value >= props.warningThreshold) return '#f59e0b';
return '#22c55e';
});
const fillStyle = computed(() => ({
width: `${ratio.value * 100}%`,
backgroundColor: fillColor.value,
}));
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="stat-card">
<div class="stat-card-value">{{ formattedValue }}</div>
<div class="stat-card-label">{{ label }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
const props = defineProps({
value: { type: [Number, String], default: '-' },
label: { type: String, default: '' },
unit: { type: String, default: '' },
});
const formattedValue = computed(() => {
if (props.value === null || props.value === undefined) return '-';
const v = typeof props.value === 'number'
? (Number.isInteger(props.value) ? props.value : props.value.toFixed(2))
: props.value;
return props.unit ? `${v} ${props.unit}` : String(v);
});
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="status-dot-wrapper">
<span class="status-dot" :class="'status-dot--' + status"></span>
<span class="status-dot-label">{{ label }}</span>
</div>
</template>
<script setup>
defineProps({
status: {
type: String,
default: 'disabled',
validator: (v) => ['healthy', 'degraded', 'error', 'disabled'].includes(v),
},
label: { type: String, default: '' },
});
</script>

View File

@@ -0,0 +1,98 @@
<script setup>
import { computed } from 'vue';
import { LineChart } from 'echarts/charts';
import {
GridComponent,
LegendComponent,
TooltipComponent,
} from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const props = defineProps({
title: { type: String, default: '' },
snapshots: { type: Array, default: () => [] },
series: { type: Array, default: () => [] },
height: { type: String, default: '220px' },
yAxisLabel: { type: String, default: '' },
yMax: { type: Number, default: undefined },
});
const hasData = computed(() => props.snapshots.length > 1);
function extractValue(row, key) {
return row[key] ?? null;
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
}
const chartOption = computed(() => {
const data = props.snapshots || [];
const seriesDefs = props.series || [];
const xLabels = data.map((row) => formatTime(row.ts));
const echartsSeries = seriesDefs.map((s) => ({
name: s.name,
type: 'line',
smooth: true,
symbol: 'none',
areaStyle: { opacity: 0.12 },
lineStyle: { width: 2 },
itemStyle: { color: s.color },
yAxisIndex: s.yAxisIndex || 0,
data: data.map((row) => extractValue(row, s.key)),
}));
const yAxisConfig = { type: 'value', min: 0 };
if (props.yMax != null) yAxisConfig.max = props.yMax;
if (props.yAxisLabel) {
yAxisConfig.axisLabel = { formatter: `{value}${props.yAxisLabel}` };
}
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: seriesDefs.map((s) => s.name),
bottom: 0,
},
grid: {
left: 50,
right: 20,
top: 16,
bottom: 40,
},
xAxis: {
type: 'category',
data: xLabels,
axisLabel: { fontSize: 10 },
},
yAxis: yAxisConfig,
series: echartsSeries,
};
});
</script>
<template>
<div class="trend-chart-card">
<h4 v-if="title" class="trend-chart-title">{{ title }}</h4>
<div v-if="hasData" class="trend-chart-canvas" :style="{ height }">
<VChart :option="chartOption" autoresize />
</div>
<div v-else class="trend-chart-empty">趨勢資料不足需至少 2 筆快照</div>
</div>
</template>

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Performance Monitor</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="./main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { createApp } from 'vue';
import App from './App.vue';
import './style.css';
createApp(App).mount('#app');

View File

@@ -0,0 +1,544 @@
/* Admin Performance Dashboard */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f1f5f9;
color: #1e293b;
line-height: 1.5;
}
.perf-dashboard {
max-width: 1280px;
margin: 0 auto;
padding: 0 16px 32px;
}
/* Header */
.perf-header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
padding: 20px 24px;
border-radius: 0 0 12px 12px;
margin: 0 -16px 20px;
}
.perf-header-inner {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
}
.perf-title {
font-size: 1.4rem;
font-weight: 700;
}
.perf-header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auto-refresh-toggle {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
cursor: pointer;
user-select: none;
}
.auto-refresh-toggle input[type='checkbox'] {
accent-color: #fff;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
background: rgba(255, 255, 255, 0.2);
color: #fff;
transition: background 0.15s;
}
.btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.3);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 5px 10px;
font-size: 0.8rem;
background: #e2e8f0;
color: #334155;
}
.btn-sm:hover:not(:disabled) {
background: #cbd5e1;
}
.btn-danger {
background: #ef4444;
color: #fff;
}
.btn-danger:hover:not(:disabled) {
background: #dc2626;
}
/* Panel */
.panel {
background: #fff;
border-radius: 10px;
padding: 20px;
margin-bottom: 16px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
}
.panel-disabled {
opacity: 0.6;
}
.panel-title {
font-size: 1rem;
font-weight: 600;
margin-bottom: 14px;
color: #334155;
}
.sub-title {
font-size: 0.9rem;
font-weight: 600;
margin: 16px 0 10px;
color: #475569;
}
.muted {
color: #94a3b8;
font-size: 0.85rem;
}
/* Status Cards */
.status-cards-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.status-card {
background: #f8fafc;
border-radius: 8px;
padding: 14px;
text-align: center;
}
.status-card-title {
font-size: 0.75rem;
color: #64748b;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* StatusDot */
.status-dot-wrapper {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot--healthy {
background: #22c55e;
box-shadow: 0 0 6px rgba(34, 197, 94, 0.4);
}
.status-dot--degraded {
background: #f59e0b;
box-shadow: 0 0 6px rgba(245, 158, 11, 0.4);
}
.status-dot--error {
background: #ef4444;
box-shadow: 0 0 6px rgba(239, 68, 68, 0.4);
}
.status-dot--disabled {
background: #94a3b8;
}
.status-dot-label {
font-size: 0.85rem;
font-weight: 500;
}
/* GaugeBar */
.gauge-bar {
margin-bottom: 12px;
}
.gauge-bar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 4px;
}
.gauge-bar-label {
font-size: 0.8rem;
color: #64748b;
}
.gauge-bar-value {
font-size: 0.8rem;
font-weight: 600;
}
.gauge-bar-track {
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.gauge-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.4s ease, background-color 0.3s;
min-width: 2px;
}
/* StatCard */
.stat-card {
background: #f8fafc;
border-radius: 8px;
padding: 10px 12px;
text-align: center;
}
.stat-card-value {
font-size: 1.1rem;
font-weight: 700;
color: #1e293b;
line-height: 1.2;
}
.stat-card-label {
font-size: 0.7rem;
color: #64748b;
margin-top: 2px;
}
/* Query Performance */
.query-perf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.query-perf-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
align-content: start;
}
.query-perf-chart {
min-height: 200px;
}
/* Redis */
.redis-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.redis-mini-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
margin-top: 12px;
}
.redis-namespaces {
overflow-x: auto;
}
/* Mini Table */
.mini-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.mini-table th,
.mini-table td {
padding: 6px 10px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.mini-table th {
background: #f8fafc;
font-weight: 600;
color: #475569;
}
/* Memory Cache Cards */
.cache-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.cache-card {
background: #f8fafc;
border-radius: 8px;
padding: 14px;
}
.cache-card-name {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 2px;
}
.cache-card-desc {
font-size: 0.72rem;
color: #64748b;
margin-bottom: 8px;
}
.cache-card-ttl {
font-size: 0.72rem;
color: #94a3b8;
margin-top: 4px;
}
.route-cache-stats {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 8px;
}
/* Connection Pool */
.pool-stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-top: 14px;
}
/* Worker */
.worker-info {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
}
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-dialog {
background: #fff;
border-radius: 12px;
padding: 24px;
max-width: 400px;
width: 90%;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.modal-dialog h3 {
margin-bottom: 8px;
}
.modal-dialog p {
font-size: 0.9rem;
color: #475569;
margin-bottom: 16px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
}
.modal-actions .btn {
background: #e2e8f0;
color: #334155;
}
/* Log Controls */
.log-controls {
display: flex;
gap: 8px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.log-controls select,
.log-controls input[type='text'] {
padding: 6px 10px;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: 0.82rem;
outline: none;
}
.log-controls input[type='text'] {
flex: 1;
min-width: 160px;
}
/* Log Table */
.log-table-wrapper {
overflow-x: auto;
max-height: 400px;
overflow-y: auto;
}
.log-table {
width: 100%;
border-collapse: collapse;
font-size: 0.78rem;
font-family: 'SF Mono', 'Fira Code', monospace;
}
.log-table th,
.log-table td {
padding: 5px 8px;
text-align: left;
border-bottom: 1px solid #f1f5f9;
white-space: nowrap;
}
.log-table th {
background: #f8fafc;
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
.log-msg {
white-space: pre-wrap;
word-break: break-all;
max-width: 600px;
}
.log-time {
color: #64748b;
}
.log-level {
font-weight: 600;
}
.log-error .log-level {
color: #ef4444;
}
.log-warning .log-level {
color: #f59e0b;
}
.log-info .log-level {
color: #3b82f6;
}
.log-debug .log-level {
color: #94a3b8;
}
/* Log Pagination */
.log-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 10px;
font-size: 0.82rem;
}
/* Responsive */
@media (max-width: 768px) {
.status-cards-grid {
grid-template-columns: repeat(2, 1fr);
}
.query-perf-grid,
.redis-grid {
grid-template-columns: 1fr;
}
.pool-stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.route-cache-stats {
grid-template-columns: repeat(3, 1fr);
}
}
/* Trend Charts */
.trend-chart-card {
margin-top: 4px;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
.trend-chart-title {
font-size: 0.9rem;
font-weight: 600;
color: #475569;
margin-bottom: 8px;
}
.trend-chart-canvas {
width: 100%;
min-height: 200px;
}
.trend-chart-empty {
color: #94a3b8;
font-size: 0.85rem;
text-align: center;
padding: 32px 0;
}

View File

@@ -70,6 +70,10 @@ const NATIVE_MODULE_LOADERS = Object.freeze({
() => import('../tables/App.vue'),
[() => import('../tables/style.css')],
),
'/admin/performance': createNativeLoader(
() => import('../admin-performance/App.vue'),
[() => import('../admin-performance/style.css')],
),
});
export function getNativeModuleLoader(route) {

View File

@@ -190,13 +190,13 @@ const ROUTE_CONTRACTS = Object.freeze({
'/admin/performance': buildContract({
route: '/admin/performance',
routeId: 'admin-performance',
renderMode: 'external',
renderMode: 'native',
owner: 'frontend-platform-admin',
title: '效能監控',
rollbackStrategy: 'external_route_reversion',
rollbackStrategy: 'fallback_to_legacy_route',
visibilityPolicy: 'admin_only',
scope: 'in-scope',
compatibilityPolicy: 'external_target_redirect',
compatibilityPolicy: 'redirect_to_shell_when_spa_enabled',
}),
'/tables': buildContract({
route: '/tables',

View File

@@ -28,7 +28,8 @@ export default defineConfig(({ mode }) => ({
'query-tool': resolve(__dirname, 'src/query-tool/main.js'),
'tmtt-defect': resolve(__dirname, 'src/tmtt-defect/main.js'),
'qc-gate': resolve(__dirname, 'src/qc-gate/index.html'),
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html')
'mid-section-defect': resolve(__dirname, 'src/mid-section-defect/index.html'),
'admin-performance': resolve(__dirname, 'src/admin-performance/index.html')
},
output: {
entryFileNames: '[name].js',