Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/views/PerformanceView.vue
T
2026-06-19 16:49:13 +08:00

395 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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>