395 lines
19 KiB
Vue
395 lines
19 KiB
Vue
<!-- SPEC: PJ2026-01060505 Workbench Performance draft-2026-06-17-p0. -->
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from "vue";
|
||
import { systemAPI } from "@/api";
|
||
import DataTable from "@/components/common/DataTable.vue";
|
||
import type { DataTableColumn } from "@/components/common/DataTable.vue";
|
||
import EmptyState from "@/components/common/EmptyState.vue";
|
||
import LoadingState from "@/components/common/LoadingState.vue";
|
||
import PageHeader from "@/components/common/PageHeader.vue";
|
||
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||
import { useTableLoader } from "@/composables/useTableLoader";
|
||
import type {
|
||
WebPerformanceDashboardDistribution,
|
||
WebPerformanceDashboardPoint,
|
||
WebPerformanceDashboardTopN,
|
||
WebPerformanceDashboardTrend,
|
||
WebPerformanceRow,
|
||
WebPerformanceSummaryResponse
|
||
} from "@/types";
|
||
|
||
const detailColumns: DataTableColumn[] = [
|
||
{ key: "metric", label: "指标" },
|
||
{ key: "route", label: "路由与维度" },
|
||
{ key: "count", label: "样本", align: "right" },
|
||
{ key: "p50", label: "P50", align: "right" },
|
||
{ key: "p75", label: "P75", align: "right" },
|
||
{ key: "p95", label: "P95", align: "right" },
|
||
{ key: "tone", label: "健康状态" }
|
||
];
|
||
|
||
const payload = ref<WebPerformanceSummaryResponse | null>(null);
|
||
const table = useTableLoader<WebPerformanceRow>(async () => {
|
||
const response = await systemAPI.performanceSummary();
|
||
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
|
||
payload.value = response.data;
|
||
return response.data?.rows ?? [];
|
||
});
|
||
|
||
const summary = computed(() => payload.value?.summary ?? {});
|
||
const dashboard = computed(() => payload.value?.dashboard ?? null);
|
||
const cards = computed(() => dashboard.value?.cards ?? []);
|
||
const trends = computed(() => dashboard.value?.trends ?? []);
|
||
const distributions = computed(() => dashboard.value?.distributions ?? []);
|
||
const topN = computed(() => dashboard.value?.topN ?? []);
|
||
const problems = computed(() => payload.value?.problems ?? []);
|
||
const detailRows = computed(() => {
|
||
if (problems.value.length) return problems.value;
|
||
return [...(payload.value?.workbenchRows ?? []), ...(payload.value?.rows ?? [])];
|
||
});
|
||
const hasDashboard = computed(() => dashboard.value?.schemaVersion === "hwlab-web-performance-dashboard-v1");
|
||
const freshnessText = computed(() => {
|
||
const freshness = dashboard.value?.freshness;
|
||
const observedAt = formatTime(freshness?.observedAt ?? payload.value?.observedAt);
|
||
const source = freshness?.source ?? payload.value?.source ?? "-";
|
||
const namespace = freshness?.namespace ?? payload.value?.namespace ?? "-";
|
||
const target = freshness?.gitopsTarget ?? payload.value?.gitopsTarget ?? "-";
|
||
return `${observedAt} · ${source} · ${namespace} / ${target}`;
|
||
});
|
||
|
||
onMounted(() => void table.reload());
|
||
|
||
function rowKey(row: WebPerformanceRow): string {
|
||
return `${row.kind ?? "kind"}:${row.metric ?? "metric"}:${row.route ?? "route"}:${row.method ?? "method"}:${row.statusClass ?? "status"}:${row.backend ?? "backend"}:${row.transport ?? "transport"}:${row.eventType ?? "event"}:${row.phase ?? "phase"}`;
|
||
}
|
||
|
||
function displayText(value?: string | null): string {
|
||
return String(value ?? "")
|
||
.replace(/:sessionId/gu, ":会话")
|
||
.replace(/:traceId/gu, ":轨迹")
|
||
.replace(/:runId/gu, ":运行")
|
||
.replace(/:threadId/gu, ":线程")
|
||
.replace(/:turnId/gu, ":轮次")
|
||
.replace(/\bsessionId\b/gu, "会话")
|
||
.replace(/\btraceId\b/gu, "轨迹")
|
||
.replace(/\brunId\b/gu, "运行")
|
||
.replace(/\bthreadId\b/gu, "线程")
|
||
.replace(/\bturnId\b/gu, "轮次")
|
||
.replace(/\bstdout\b/gu, "标准输出")
|
||
.replace(/\bstderr\b/gu, "错误输出")
|
||
.replace(/\bSecret\b/gu, "密钥");
|
||
}
|
||
|
||
function displayRoute(value?: string | null): string {
|
||
const text = displayText(value).trim();
|
||
return text || "当前窗口";
|
||
}
|
||
|
||
function metricLabel(row: WebPerformanceRow): string {
|
||
const metric = String(row.phase || row.metric || row.kind || "unknown").replace(/-/gu, "_");
|
||
const labels: Record<string, string> = {
|
||
submit_to_first_visible: "提交到首个可见结果",
|
||
submit_to_failure: "提交失败可见",
|
||
backend_event_to_visible: "后端事件到可见",
|
||
session_switch_first_visible: "切换会话首个可见",
|
||
session_switch_full_load: "切换会话完整加载",
|
||
workbench_open_first_visible: "打开工作台首个可见",
|
||
workbench_open_full_load: "打开工作台完整加载",
|
||
created_to_append: "创建到写入 Trace",
|
||
append_to_sse: "Trace 写入到实时流",
|
||
sse_to_receive: "实时流到浏览器",
|
||
receive_to_project: "接收到状态投影",
|
||
project_to_paint: "投影到画面可见",
|
||
user_submit_to_api_accepted: "提交到 API 接收",
|
||
api_accepted_to_backend_event: "API 接收到后端事件",
|
||
api_request: "同源 API 请求",
|
||
long_task: "主线程长任务",
|
||
lcp: "最大内容绘制",
|
||
inp: "交互响应",
|
||
fid: "首次输入延迟",
|
||
cls: "布局偏移",
|
||
navigation_ttfb: "首字节时间",
|
||
navigation_dom_content_loaded: "DOM 内容加载",
|
||
navigation_load: "页面加载完成",
|
||
trace_event_projected: "轨迹事件投影",
|
||
backend_event_visible: "后端事件到可见",
|
||
longtask: "主线程长任务"
|
||
};
|
||
return labels[metric] ?? metric.replace(/[_-]/gu, " ");
|
||
}
|
||
|
||
function kindLabel(kind?: string): string {
|
||
const labels: Record<string, string> = {
|
||
workbench_journey: "工作台体感",
|
||
workbench_event_phase: "事件阶段",
|
||
workbench_backend_event_visible: "后端可见",
|
||
web_vital: "浏览器体感",
|
||
navigation: "页面加载",
|
||
api: "同源 API",
|
||
long_task: "长任务"
|
||
};
|
||
return labels[String(kind ?? "")] ?? "性能样本";
|
||
}
|
||
|
||
function formatValue(row: WebPerformanceRow | WebPerformanceDashboardPoint, key: "value" | "p50" | "p75" | "p95" = "value", unit?: string): string {
|
||
const value = Number(row[key]);
|
||
if (!Number.isFinite(value)) return "-";
|
||
const resolvedUnit = unit ?? ("unit" in row && typeof row.unit === "string" ? row.unit : undefined);
|
||
if (resolvedUnit === "score") return value.toFixed(3);
|
||
if (value >= 1) return `${value.toFixed(value >= 10 ? 0 : 1)} 秒`;
|
||
return `${Math.round(value * 1000)} 毫秒`;
|
||
}
|
||
|
||
function formatCount(value?: number | string): string {
|
||
const number = Number(value);
|
||
return Number.isFinite(number) ? number.toLocaleString("zh-CN") : "0";
|
||
}
|
||
|
||
function formatCardValue(value?: string | number, unit?: string): string {
|
||
if (typeof value === "number") return `${value.toLocaleString("zh-CN")}${unit && unit !== "状态" ? ` ${unit}` : ""}`;
|
||
return String(value ?? "-");
|
||
}
|
||
|
||
function formatTime(value?: string): string {
|
||
if (!value) return "未采集";
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return value;
|
||
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(date);
|
||
}
|
||
|
||
function formatDimensions(row: WebPerformanceRow): string {
|
||
const parts = [
|
||
row.backend ? `后端:${row.backend}` : "",
|
||
row.transport ? `传输:${transportLabel(row.transport)}` : "",
|
||
row.eventType ? `事件:${eventTypeLabel(row.eventType)}` : "",
|
||
row.targetState ? `目标:${targetStateLabel(row.targetState)}` : "",
|
||
row.cache ? `缓存:${cacheLabel(row.cache)}` : "",
|
||
row.source ? `来源:${sourceLabel(row.source)}` : "",
|
||
row.entry ? `入口:${entryLabel(row.entry)}` : "",
|
||
row.outcome ? `结果:${outcomeLabel(row.outcome)}` : ""
|
||
].filter(Boolean);
|
||
return parts.length ? parts.join(" / ") : "当前窗口";
|
||
}
|
||
|
||
function problemLabel(problem?: string): string {
|
||
const text = String(problem ?? "");
|
||
const labels: Record<string, string> = {
|
||
ok: "正常",
|
||
"low-sample": "低样本",
|
||
timeout: "超时",
|
||
network: "网络失败",
|
||
"server-error": "服务端错误",
|
||
"client-error": "客户端错误",
|
||
"layout-shift": "布局偏移",
|
||
"main-thread busy": "主线程繁忙"
|
||
};
|
||
if (labels[text]) return labels[text];
|
||
if (text.startsWith("slow-")) return `慢路径:${metricLabel({ metric: text.slice(5) } as WebPerformanceRow)}`;
|
||
return text.replace(/[_-]/gu, " ");
|
||
}
|
||
|
||
function statusText(row: WebPerformanceRow): string {
|
||
return `${toneLabel(row.tone)} · ${sampleStateLabel(row.sampleState)}`;
|
||
}
|
||
|
||
function toneLabel(tone?: string): string {
|
||
if (tone === "ok") return "正常";
|
||
if (tone === "warn") return "预警";
|
||
if (tone === "blocked") return "阻塞";
|
||
return "等待数据";
|
||
}
|
||
|
||
function sampleStateLabel(state?: string): string {
|
||
if (state === "ok") return "正常样本";
|
||
if (state === "low-sample") return "低样本";
|
||
return "暂无数据";
|
||
}
|
||
|
||
function outcomeLabel(value?: string): string {
|
||
const labels: Record<string, string> = { ok: "正常", timeout: "超时", error: "错误", dropped: "丢弃", stale: "滞后", partial: "部分", empty: "空结果", network: "网络", denied: "拒绝", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function transportLabel(value?: string): string {
|
||
const labels: Record<string, string> = { sse: "实时流", rest_gap: "REST 补洞", poll: "轮询", none: "无", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function eventTypeLabel(value?: string): string {
|
||
if (String(value ?? "").startsWith("agentrun.")) return "后端输出";
|
||
const labels: Record<string, string> = { assistant: "助手消息", tool_call: "工具调用", backend: "后端事件", terminal: "终态结果", error: "错误", status: "状态", request: "请求", result: "结果", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function targetStateLabel(value?: string): string {
|
||
const labels: Record<string, string> = { running: "运行中", terminal: "已终态", completed: "已完成", empty: "空会话", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function cacheLabel(value?: string): string {
|
||
const labels: Record<string, string> = { warm: "热缓存", cold: "冷加载", hit: "命中", miss: "未命中", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function sourceLabel(value?: string): string {
|
||
const labels: Record<string, string> = { rail: "会话栏", deeplink: "深链", history: "历史", direct: "直达", hydrate: "恢复", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function entryLabel(value?: string): string {
|
||
const labels: Record<string, string> = { new: "新会话", existing: "已有会话", steer: "续写", retry: "重试", unknown: "未知" };
|
||
return labels[String(value ?? "unknown")] ?? String(value);
|
||
}
|
||
|
||
function trendPoints(trend: WebPerformanceDashboardTrend): WebPerformanceDashboardPoint[] {
|
||
return Array.isArray(trend.points) ? trend.points : [];
|
||
}
|
||
|
||
function linePoints(trend: WebPerformanceDashboardTrend): string {
|
||
const points = trendPoints(trend);
|
||
if (!points.length) return "";
|
||
return points.map((point, index) => `${chartX(index, points.length)},${chartY(Number(point.value), points)}`).join(" ");
|
||
}
|
||
|
||
function chartX(index: number, count: number): number {
|
||
if (count <= 1) return 150;
|
||
return Math.round(22 + (index / (count - 1)) * 256);
|
||
}
|
||
|
||
function chartY(value: number, points: WebPerformanceDashboardPoint[]): number {
|
||
const values = points.map((point) => Number(point.value)).filter(Number.isFinite);
|
||
const max = Math.max(...values, 0.001);
|
||
const normalized = Number.isFinite(value) ? value / max : 0;
|
||
return Math.round(86 - normalized * 58);
|
||
}
|
||
|
||
function barPercent(value?: number, max = 1): number {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number) || number <= 0) return 4;
|
||
return Math.max(6, Math.min(100, (number / Math.max(1, max)) * 100));
|
||
}
|
||
|
||
function distributionMax(distribution: WebPerformanceDashboardDistribution): number {
|
||
return Math.max(1, ...(distribution.buckets ?? []).map((bucket) => Number(bucket.count) || 0));
|
||
}
|
||
|
||
function topMax(group: WebPerformanceDashboardTopN): number {
|
||
return Math.max(0.001, ...(group.rows ?? []).map((row) => Number(row.value) || 0));
|
||
}
|
||
|
||
function toneClass(tone?: string): string {
|
||
if (tone === "blocked") return "is-blocked";
|
||
if (tone === "warn") return "is-warn";
|
||
if (tone === "ok") return "is-ok";
|
||
return "is-source";
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<section class="route-stack system-page performance-page">
|
||
<PageHeader eyebrow="运维监控" title="性能监控" description="以同一性能摘要展示工作台体感、后端事件可见延迟、API 耗时、浏览器指标和采集健康。" />
|
||
<LoadingState v-if="table.loading.value && !table.rows.value.length" />
|
||
<section v-else-if="table.error.value" class="data-panel">
|
||
<EmptyState title="性能监控加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
|
||
</section>
|
||
<template v-else>
|
||
<section class="performance-health-strip" aria-label="性能概览">
|
||
<article v-for="card in cards" :key="card.id" class="metric-card performance-health-card" :class="toneClass(String(card.tone ?? 'source'))">
|
||
<span>{{ card.label }}</span>
|
||
<strong>{{ formatCardValue(card.value, card.unit) }}</strong>
|
||
<small>{{ card.detail || freshnessText }}</small>
|
||
</article>
|
||
</section>
|
||
|
||
<section v-if="!hasDashboard" class="data-panel performance-diagnostic-panel">
|
||
<EmptyState title="等待图表数据契约" description="summary 已返回,但缺少 dashboard 字段。请先发布包含 chart-ready summary 的 Cloud API,再观察图表。" action-label="刷新" @action="table.reload" />
|
||
</section>
|
||
|
||
<template v-else>
|
||
<section class="performance-freshness-panel" aria-label="采集新鲜度">
|
||
<div>
|
||
<strong>{{ dashboard?.freshness?.statusLabel || '等待数据' }}</strong>
|
||
<span>{{ freshnessText }}</span>
|
||
</div>
|
||
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
|
||
</section>
|
||
|
||
<section class="performance-chart-grid" aria-label="性能趋势图">
|
||
<article v-for="trend in trends" :key="trend.id" class="performance-chart-card">
|
||
<header>
|
||
<div>
|
||
<strong>{{ trend.title }}</strong>
|
||
<span>{{ trend.subtitle }}</span>
|
||
</div>
|
||
<em>{{ trendPoints(trend).length }} 条序列</em>
|
||
</header>
|
||
<svg class="performance-line-chart" viewBox="0 0 300 104" role="img" :aria-label="`${trend.title || '性能'}趋势图`">
|
||
<path d="M22 28 H278 M22 57 H278 M22 86 H278" class="chart-grid-line" />
|
||
<polyline v-if="linePoints(trend)" :points="linePoints(trend)" class="chart-line" />
|
||
<circle v-for="(point, index) in trendPoints(trend)" :key="`${trend.id}-${point.label}-${index}`" :cx="chartX(index, trendPoints(trend).length)" :cy="chartY(Number(point.value), trendPoints(trend))" r="4" :class="['chart-point', toneClass(point.tone)]">
|
||
<title>{{ point.label }}:{{ formatValue(point, 'value', trend.unit) }},{{ formatCount(point.count) }} 个样本</title>
|
||
</circle>
|
||
</svg>
|
||
<ol class="performance-chart-legend">
|
||
<li v-for="point in trendPoints(trend).slice(0, 4)" :key="`${trend.id}-${point.label}`">
|
||
<span>{{ point.label }}</span>
|
||
<strong>{{ formatValue(point, 'value', trend.unit) }}</strong>
|
||
</li>
|
||
</ol>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="performance-analytics-grid" aria-label="性能分布与排行">
|
||
<article v-for="distribution in distributions" :key="distribution.id" class="performance-chart-card performance-bar-card">
|
||
<header>
|
||
<div>
|
||
<strong>{{ distribution.title }}</strong>
|
||
<span>{{ distribution.subtitle }}</span>
|
||
</div>
|
||
</header>
|
||
<div class="performance-bar-list">
|
||
<div v-for="bucket in distribution.buckets" :key="`${distribution.id}-${bucket.label}`" class="performance-bar-row">
|
||
<span>{{ bucket.label }}</span>
|
||
<div><i :class="toneClass(bucket.tone)" :style="{ width: `${barPercent(Number(bucket.count), distributionMax(distribution))}%` }" /></div>
|
||
<strong>{{ formatCount(bucket.count) }}</strong>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article v-for="group in topN" :key="group.id" class="performance-chart-card performance-top-card">
|
||
<header>
|
||
<div>
|
||
<strong>{{ group.title }}</strong>
|
||
<span>{{ group.subtitle }}</span>
|
||
</div>
|
||
</header>
|
||
<div class="performance-top-list">
|
||
<div v-for="row in group.rows" :key="`${group.id}-${row.label}-${row.detail}`" class="performance-top-row">
|
||
<div>
|
||
<strong>{{ row.label }}</strong>
|
||
<span>{{ displayText(row.detail) }}</span>
|
||
</div>
|
||
<div><i :class="toneClass(row.tone)" :style="{ width: `${barPercent(Number(row.value), topMax(group))}%` }" /></div>
|
||
<em>{{ formatValue(row, 'value', row.unit) }}</em>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
</template>
|
||
|
||
<TablePageLayout title="性能明细" subtitle="来自同一 summary 的问题行和可访问表格 drill-down">
|
||
<template #actions><button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button></template>
|
||
<DataTable :columns="detailColumns" :rows="detailRows" :row-key="rowKey" empty-text="暂无性能样本。请先打开工作台或执行一次真实页面操作后刷新。">
|
||
<template #cell-metric="{ row }"><strong>{{ metricLabel(row as WebPerformanceRow) }}</strong><small>{{ kindLabel((row as WebPerformanceRow).kind) }}</small></template>
|
||
<template #cell-route="{ row }"><code>{{ displayRoute((row as WebPerformanceRow).route) }}</code><small>{{ formatDimensions(row as WebPerformanceRow) }}</small></template>
|
||
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
|
||
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
|
||
<template #cell-p75="{ row }">{{ formatValue(row as WebPerformanceRow, 'p75') }}</template>
|
||
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem ? `诊断:${problemLabel((row as WebPerformanceRow).problem)}` : '' }}</small></template>
|
||
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ statusText(row as WebPerformanceRow) }}</span></template>
|
||
</DataTable>
|
||
</TablePageLayout>
|
||
</template>
|
||
</section>
|
||
</template>
|