feat: add v02 web performance dashboard
This commit is contained in:
@@ -103,6 +103,7 @@ Code Agent session 是显式资源,不再由普通 `client agent send`、Workb
|
||||
| `hwlab-cli client workbench restore/status/watch/reset` | `GET/PATCH /v1/workbench/workspace*` | 恢复、观察或重置账号级共享 workspace,支持 Web/CLI 和多 profile 共享同一账号状态。 |
|
||||
| `hwlab-cli client rpc METHOD [--full]` | `POST /json-rpc` | 像 Web `callRpc` 一样自动生成 `id`、`traceId` 和 `meta`,覆盖 `system.health`、`cloud.adapter.describe` 等 JSON-RPC 非视觉能力。 |
|
||||
| `hwlab-cli client request METHOD /path [--full]` | Cloud Web 同源相对路径 | 覆盖 `/v1/access/status`、`/v1/setup/status`、`/v1/diagnostics/gate`、`/v1/m3/status`、`/v1/m3/io` 等低频或新增 WEB API;`/json-rpc` 优先使用 `client rpc`。 |
|
||||
| `hwlab-cli client request GET /v1/web-performance/summary [--full]` | `GET /v1/web-performance/summary` | 性能监控页同路径非视觉验收入口;输出 WebUI RUM 样本、慢 API route p95、Web Vitals、long task 和问题队列摘要,不访问 Prometheus 公网地址,不读取 `/metrics` 原始文本。 |
|
||||
|
||||
`client` 之外的历史命令不作为 v0.2 验收入口。保留旧命令时只能返回废弃说明或转向 `client`,不得把 fixture 或 dry-run 结果当作 WEB 功能通过证据。
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@
|
||||
| `POST /v1/agent/chat`、`POST /v1/agent/chat/steer`、`POST /v1/agent/chat/cancel` | 同源代理到 cloud-api 的 Code Agent 入口;steer 必须走同一个 `19666` Web path,由 cloud-api/AgentRun 判断目标 turn 是否可接收。 |
|
||||
| `POST /v1/device-pods/...` | 受控同源代理到 cloud-api 的 Device Pod job/操作入口;只要 Cloud API 已提供对应能力,Cloud Web 不能只代理 list/status 而让 job POST 在 `19666` 返回 404。 |
|
||||
| `POST /v1/web-performance` | 浏览器 RUM 上报入口;只允许低基数性能事件和数值,Cloud API 聚合后进入 Prometheus,详见 [spec-v02-observability-monitoring.md](spec-v02-observability-monitoring.md)。 |
|
||||
| `GET /v1/web-performance/summary` | 性能监控顶级页读取的同源摘要接口;返回低基数 WebUI 体感性能 JSON,包含样本数、route p95、Web Vitals、long task 和错误/超时问题队列,不返回 Prometheus 原始文本或高基数 trace/session/conversation/thread/user 标识。 |
|
||||
| `POST /v1/m3/io`、`POST /json-rpc` | 同源代理到受控 API;不能绕过 cloud-api 直连硬件服务。 |
|
||||
|
||||
## 测试规格
|
||||
|
||||
@@ -32,6 +32,8 @@ sidecar 脚本通过 ConfigMap 挂载时,Deployment template 必须包含脚
|
||||
|
||||
Cloud Web 用户感知性能必须进入同一套 Prometheus 查询面。浏览器侧 RUM 只上报低基数 route template、metric、method、status class、outcome 和数值,不上报 trace/session/conversation/thread/run/job/user ID 或正文。`hwlab-cloud-api` 负责接收 `/v1/web-performance`、做进程内聚合,并仅允许 `hwlab-cloud-api` pod 内 loopback 访问 `/v1/web-performance/metrics`;`hwlab-cloud-api` 的 metrics sidecar 再通过额外 loopback target 把 WebUI Prometheus 文本附加到 9100 `/metrics`。公网 `19666/19667` 仍不得暴露 Prometheus 原始文本。
|
||||
|
||||
Cloud Web 顶级性能页通过同源 `GET /v1/web-performance/summary` 读取低基数 JSON 摘要,用于展示用户可感知的 WebUI 样本数、慢 API route p95、Web Vitals、long task 和错误/超时问题队列。该接口不得返回 Prometheus 原始文本、Secret、prompt/assistant 正文或 trace/session/conversation/thread/user 等高基数标识;CLI 同路径验收使用 `hwlab-cli client request GET /v1/web-performance/summary`,不直连 Prometheus 公网地址。
|
||||
|
||||
## API 接口说明
|
||||
|
||||
### `/metrics`
|
||||
|
||||
@@ -48,7 +48,8 @@ import { buildLiveBuildsPayload } from "./server-rest-payloads.ts";
|
||||
import {
|
||||
createWebPerformanceStore,
|
||||
handleWebPerformanceIngestHttp,
|
||||
handleWebPerformanceMetricsHttp
|
||||
handleWebPerformanceMetricsHttp,
|
||||
handleWebPerformanceSummaryHttp
|
||||
} from "./web-performance.ts";
|
||||
import {
|
||||
createCodeAgentChatResultStore,
|
||||
@@ -304,6 +305,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/v1/web-performance/summary" && request.method === "GET") {
|
||||
handleWebPerformanceSummaryHttp(request, response, { store: options.webPerformanceStore });
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1") {
|
||||
const dbProbe = await buildDbRuntimeReadiness(options.env ?? process.env, options.dbProbe);
|
||||
const codeAgent = await describeCodeAgentAvailability(options.env ?? process.env, options);
|
||||
|
||||
@@ -24,6 +24,29 @@ test("web performance store aggregates browser-perceived durations as Prometheus
|
||||
assert.doesNotMatch(text, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu);
|
||||
});
|
||||
|
||||
test("web performance summary exposes user-perceived route latency without high-cardinality data", () => {
|
||||
const store = createWebPerformanceStore({ env: { HWLAB_METRICS_NAMESPACE: "hwlab-v02", HWLAB_GITOPS_TARGET: "v02" } });
|
||||
store.record({
|
||||
schemaVersion: "hwlab-web-performance-v1",
|
||||
page: "/workspace",
|
||||
events: [
|
||||
{ kind: "api", metric: "api_request", route: "/v1/agent/chat/result/trc_secret", method: "GET", status: 200, statusClass: "2xx", outcome: "ok", valueMs: 420 },
|
||||
{ kind: "api", metric: "api_request", route: "/v1/agent/chat/result/trc_secret", method: "GET", status: 0, statusClass: "network", outcome: "timeout", valueMs: 2600 },
|
||||
{ kind: "long_task", metric: "long_task", route: "/workspace", valueMs: 260 },
|
||||
{ kind: "web_vital", metric: "lcp", route: "/workspace", valueMs: 3100 }
|
||||
]
|
||||
});
|
||||
|
||||
const summary = store.summary();
|
||||
const json = JSON.stringify(summary);
|
||||
assert.equal(summary.summary.sampleCount, 4);
|
||||
assert.equal(summary.namespace, "hwlab-v02");
|
||||
assert.ok(summary.apiRoutes.some((row) => row.route === "/v1/agent/chat/result/:id" && row.metric === "api_request" && row.p95 >= 2.5));
|
||||
assert.ok(summary.problems.some((row) => row.problem === "timeout" && row.tone === "blocked"));
|
||||
assert.ok(summary.longTasks.some((row) => row.metric === "long_task" && row.problem === "slow-long_task" && row.tone !== "ok"));
|
||||
assert.doesNotMatch(json, /trc_secret|sessionId|conversationId|threadId|prompt|api key/iu);
|
||||
});
|
||||
|
||||
test("web performance route templates keep metrics low-cardinality", () => {
|
||||
assert.equal(webPerformanceRouteTemplate("/v1/workbench/workspace/cnv_abcdef/select-conversation?traceId=trc_secret"), "/v1/workbench/workspace/:id/select-conversation");
|
||||
assert.equal(webPerformanceRouteTemplate("#/skills"), "/skills");
|
||||
@@ -53,6 +76,12 @@ test("cloud api ingests WebUI performance samples and exposes metrics only on lo
|
||||
|
||||
const publicHost = await fetch(`${baseUrl}/v1/web-performance/metrics`, { headers: { host: "74.48.78.17:19667" } });
|
||||
assert.equal(publicHost.status, 404);
|
||||
|
||||
const summary = await fetch(`${baseUrl}/v1/web-performance/summary`);
|
||||
const body = await summary.json();
|
||||
assert.equal(summary.status, 200);
|
||||
assert.equal(body.route, "/v1/web-performance/summary");
|
||||
assert.equal(body.summary.sampleCount, 1);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
}
|
||||
|
||||
@@ -49,6 +49,22 @@ interface CounterSeries {
|
||||
value: number;
|
||||
}
|
||||
|
||||
interface PerformanceSummaryRow {
|
||||
kind: string;
|
||||
metric: string;
|
||||
route: string;
|
||||
method: string;
|
||||
statusClass: string;
|
||||
outcome: string;
|
||||
count: number;
|
||||
average: number;
|
||||
p50: number;
|
||||
p95: number;
|
||||
unit: "seconds" | "score";
|
||||
tone: "ok" | "warn" | "blocked" | "source";
|
||||
problem: string;
|
||||
}
|
||||
|
||||
export function createWebPerformanceStore(options: WebPerformanceStoreOptions = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const maxSeries = parsePositiveInteger(env.HWLAB_WEB_PERFORMANCE_MAX_SERIES, options.maxSeries ?? 240);
|
||||
@@ -101,7 +117,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
metric,
|
||||
route,
|
||||
method: normalizeMethod(input.method),
|
||||
status_class: normalizeStatusClass(input.statusClass ?? statusClass(input.status)),
|
||||
status_class: normalizeStatusClass(input.statusClass ?? (input.status === undefined || input.status === null ? "unknown" : statusClass(input.status))),
|
||||
outcome: sanitizeLabelValue(input.outcome ?? "ok", "ok")
|
||||
};
|
||||
return { metric, value, labels };
|
||||
@@ -123,6 +139,51 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function summary() {
|
||||
const durationRows = histogramSummaryRows(durationSeries, DURATION_BUCKETS_SECONDS, "seconds");
|
||||
const clsRows = histogramSummaryRows(clsSeries, CLS_BUCKETS, "score");
|
||||
const rows = [...durationRows, ...clsRows];
|
||||
const apiRoutes = durationRows
|
||||
.filter((row) => row.metric === "api_request")
|
||||
.sort(sortByProblemThenP95)
|
||||
.slice(0, 12);
|
||||
const webVitals = rows
|
||||
.filter((row) => row.kind === "web_vital" || row.metric.startsWith("navigation_"))
|
||||
.sort(sortByProblemThenP95)
|
||||
.slice(0, 12);
|
||||
const longTasks = durationRows
|
||||
.filter((row) => row.metric === "long_task")
|
||||
.sort(sortByProblemThenP95)
|
||||
.slice(0, 12);
|
||||
const problems = rows
|
||||
.filter((row) => row.tone === "blocked" || row.tone === "warn" || row.outcome !== "ok")
|
||||
.sort(sortByProblemThenP95)
|
||||
.slice(0, 16);
|
||||
const sampleCount = [...sampleSeries.values()].reduce((sum, entry) => sum + entry.value, 0);
|
||||
return {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
route: "/v1/web-performance/summary",
|
||||
source: "cloud-api-rum-store",
|
||||
observedAt: new Date().toISOString(),
|
||||
namespace: baseLabels.namespace,
|
||||
gitopsTarget: baseLabels.gitops_target,
|
||||
summary: {
|
||||
sampleCount,
|
||||
sampleSeries: sampleSeries.size,
|
||||
durationSeries: durationSeries.size,
|
||||
clsSeries: clsSeries.size,
|
||||
routeCount: new Set(rows.map((row) => row.route)).size,
|
||||
problemCount: problems.length,
|
||||
status: sampleCount === 0 ? "waiting" : sampleCount < 10 ? "warming" : problems.length > 0 ? "watch" : "ok"
|
||||
},
|
||||
apiRoutes,
|
||||
webVitals,
|
||||
longTasks,
|
||||
problems,
|
||||
rows: rows.sort(sortByProblemThenP95).slice(0, 40)
|
||||
};
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
sampleSeries: sampleSeries.size,
|
||||
@@ -132,7 +193,7 @@ export function createWebPerformanceStore(options: WebPerformanceStoreOptions =
|
||||
};
|
||||
}
|
||||
|
||||
return { record, metricsText, snapshot };
|
||||
return { record, metricsText, snapshot, summary };
|
||||
}
|
||||
|
||||
export async function handleWebPerformanceIngestHttp(request, response, options = {}) {
|
||||
@@ -165,6 +226,10 @@ export function handleWebPerformanceMetricsHttp(request, response, options = {})
|
||||
response.end(payload);
|
||||
}
|
||||
|
||||
export function handleWebPerformanceSummaryHttp(_request, response, options = {}) {
|
||||
sendJson(response, 200, options.store.summary());
|
||||
}
|
||||
|
||||
export function webPerformanceRouteTemplate(input: unknown): string {
|
||||
const raw = String(input ?? "").trim() || "/";
|
||||
const normalizedRaw = raw.startsWith("#") ? raw.slice(1) || "/" : raw;
|
||||
@@ -237,6 +302,93 @@ function renderHistogram(metricName: string, series: Map<string, HistogramSeries
|
||||
return lines;
|
||||
}
|
||||
|
||||
function histogramSummaryRows(series: Map<string, HistogramSeries>, buckets: number[], unit: "seconds" | "score"): PerformanceSummaryRow[] {
|
||||
return [...series.values()].map((entry) => {
|
||||
const labels = entry.labels;
|
||||
const count = entry.count;
|
||||
const average = count > 0 ? entry.sum / count : 0;
|
||||
const p50 = histogramQuantile(entry, buckets, 0.5);
|
||||
const p95 = histogramQuantile(entry, buckets, 0.95);
|
||||
const tone = performanceTone(labels, p95, count, unit);
|
||||
return {
|
||||
kind: labels.kind,
|
||||
metric: labels.metric,
|
||||
route: labels.route,
|
||||
method: labels.method,
|
||||
statusClass: labels.status_class,
|
||||
outcome: labels.outcome,
|
||||
count,
|
||||
average: roundMetric(average),
|
||||
p50: roundMetric(p50),
|
||||
p95: roundMetric(p95),
|
||||
unit,
|
||||
tone,
|
||||
problem: performanceProblem(labels, p95, count, unit, tone)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function histogramQuantile(entry: HistogramSeries, buckets: number[], quantile: number) {
|
||||
if (entry.count <= 0) return 0;
|
||||
const rank = Math.max(1, Math.ceil(entry.count * quantile));
|
||||
for (let index = 0; index < entry.buckets.length; index += 1) {
|
||||
if (entry.buckets[index] >= rank) return buckets[index];
|
||||
}
|
||||
return buckets[buckets.length - 1] ?? 0;
|
||||
}
|
||||
|
||||
function performanceTone(labels: Record<string, string>, p95: number, count: number, unit: "seconds" | "score") {
|
||||
if (labels.outcome !== "ok" || labels.status_class === "network" || labels.status_class === "5xx") return "blocked";
|
||||
if (labels.status_class === "4xx") return "warn";
|
||||
if (count <= 0) return "source";
|
||||
const metric = labels.metric;
|
||||
if (unit === "score") {
|
||||
if (p95 >= 0.25) return "blocked";
|
||||
if (p95 >= 0.1) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
if (metric === "lcp") return thresholdTone(p95, 2.5, 4);
|
||||
if (metric === "inp" || metric === "fid") return thresholdTone(p95, 0.2, 0.5);
|
||||
if (metric === "api_request") return thresholdTone(p95, 1, 2.5);
|
||||
if (metric === "long_task") return thresholdTone(p95, 0.2, 0.5);
|
||||
if (metric === "navigation_dom_content_loaded") return thresholdTone(p95, 2, 4);
|
||||
if (metric === "navigation_load") return thresholdTone(p95, 3, 6);
|
||||
if (metric === "navigation_ttfb") return thresholdTone(p95, 0.8, 1.8);
|
||||
return p95 > 1 ? "warn" : "ok";
|
||||
}
|
||||
|
||||
function thresholdTone(value: number, warn: number, blocked: number) {
|
||||
if (value >= blocked) return "blocked";
|
||||
if (value >= warn) return "warn";
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function performanceProblem(labels: Record<string, string>, p95: number, count: number, unit: "seconds" | "score", tone: string) {
|
||||
if (count <= 0) return "no-sample";
|
||||
if (labels.outcome !== "ok") return labels.outcome;
|
||||
if (labels.status_class === "network") return "network";
|
||||
if (labels.status_class === "5xx") return "server-error";
|
||||
if (labels.status_class === "4xx") return "client-error";
|
||||
if (tone === "blocked" || tone === "warn") return unit === "score" ? "layout-shift" : `slow-${labels.metric}`;
|
||||
return "ok";
|
||||
}
|
||||
|
||||
function sortByProblemThenP95(left: PerformanceSummaryRow, right: PerformanceSummaryRow) {
|
||||
return toneWeight(right.tone) - toneWeight(left.tone) || right.p95 - left.p95 || right.count - left.count;
|
||||
}
|
||||
|
||||
function toneWeight(tone: string) {
|
||||
if (tone === "blocked") return 4;
|
||||
if (tone === "warn") return 3;
|
||||
if (tone === "ok") return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
function roundMetric(value: number) {
|
||||
if (!Number.isFinite(value)) return 0;
|
||||
return Math.round(value * 10000) / 10000;
|
||||
}
|
||||
|
||||
function renderLabels(labels: Record<string, string>) {
|
||||
return Object.entries(labels)
|
||||
.map(([key, value]) => `${key}="${escapeLabelValue(value)}"`)
|
||||
|
||||
@@ -46,6 +46,12 @@ test("cloud web proxies public WebUI performance telemetry to cloud-api", () =>
|
||||
publicRoute: true,
|
||||
routeKey: "POST /v1/web-performance"
|
||||
});
|
||||
assert.deepEqual(cloudWebProxyRoutePolicy("GET", "/v1/web-performance/summary"), {
|
||||
proxy: true,
|
||||
authRequired: true,
|
||||
publicRoute: false,
|
||||
routeKey: "GET /v1/web-performance/summary"
|
||||
});
|
||||
});
|
||||
|
||||
test("cloud web proxies authenticated API key management write routes", () => {
|
||||
|
||||
@@ -141,6 +141,7 @@ function help() {
|
||||
"hwlab-cli client workbench watch --profile NAME --after-revision REVISION",
|
||||
"hwlab-cli client workbench reset --profile NAME --confirm",
|
||||
"hwlab-cli client request GET /v1/access/status [--full]",
|
||||
"hwlab-cli client request GET /v1/web-performance/summary [--full]",
|
||||
"hwlab-cli client rpc system.health [--full]",
|
||||
"hwlab-cli client agent session create --provider-profile minimax-m3",
|
||||
"hwlab-cli client agent session select SESSION_ID",
|
||||
@@ -1932,6 +1933,7 @@ function requestHelp() {
|
||||
imagePublished: false,
|
||||
commands: [
|
||||
"GET /path [--full]",
|
||||
"GET /v1/web-performance/summary [--full]",
|
||||
"POST /path --body-json '{...}' [--full]",
|
||||
"METHOD /path --body-file FILE"
|
||||
]
|
||||
|
||||
@@ -18,6 +18,7 @@ const requiredFiles = Object.freeze([
|
||||
"src/components/conversation/ConversationPanel.tsx",
|
||||
"src/components/command-bar/CommandBar.tsx",
|
||||
"src/components/device-pod/DevicePodSidebar.tsx",
|
||||
"src/components/performance/PerformanceView.tsx",
|
||||
"src/components/settings/SettingsView.tsx",
|
||||
"src/components/skills/SkillsView.tsx",
|
||||
"src/hooks/useAuth.ts",
|
||||
@@ -53,6 +54,7 @@ assertIncludes(appSource, "useWorkbenchStore", "state hook must own workbench st
|
||||
assertIncludes(appSource, "fetchJson", "API client must own fetch calls");
|
||||
assertIncludes(appSource, "DevicePodSidebar", "Device Pod sidebar component must exist");
|
||||
assertIncludes(appSource, "CommandBar", "command bar component must exist");
|
||||
assertIncludes(appSource, "PerformanceView", "performance view component must exist");
|
||||
assertIncludes(appSource, "SkillsView", "skills component must exist");
|
||||
assertIncludes(appSource, "SettingsView", "settings component must exist");
|
||||
assertIncludes(appTsx, "useWorkbenchStore(auth.authState === \"authenticated\")", "Workbench data requests must wait until auth is authenticated");
|
||||
@@ -66,10 +68,12 @@ assertIncludes(appSource, "id=\"conversation-list\"", "React runtime must render
|
||||
assertIncludes(appSource, "id=\"device-pod-sidebar\"", "React runtime must render Device Pod sidebar selector");
|
||||
assertIncludes(appSource, "id=\"device-event-scroll\"", "React runtime must render Device Pod event stream selector");
|
||||
assertIncludes(appSource, "id=\"logout-button\"", "React runtime must render logout selector");
|
||||
assertIncludes(appSource, "route === \"performance\"", "React runtime must route top-level performance panel");
|
||||
assertIncludes(readRepo("internal/cloud/server.ts"), "/v1/web-performance/summary", "cloud-api must expose WebUI performance summary API");
|
||||
assert.doesNotMatch(appSource, /document\.getElementById\((?!["']root["'])|querySelector\(["']#(?:command-input|conversation-list|device-pod-sidebar)/u, "major UI must not be driven by global DOM element lookups");
|
||||
assert.doesNotMatch(appSource, /\bconst\s+el\s*=/u, "global el map must not return");
|
||||
|
||||
for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) {
|
||||
for (const directory of ["components/layout", "components/auth", "components/sessions", "components/conversation", "components/command-bar", "components/device-pod", "components/performance", "components/settings", "components/skills", "hooks", "services/api", "state", "types", "styles"]) {
|
||||
assert.ok(fs.existsSync(path.join(rootDir, "src", directory)), `missing React module boundary src/${directory}`);
|
||||
}
|
||||
assert.match(css, /@media \(max-width: 720px\)/u, "mobile layout contract must be explicit");
|
||||
|
||||
@@ -17,6 +17,7 @@ import { SessionSidebar } from "./components/sessions/SessionSidebar";
|
||||
import { SettingsView } from "./components/settings/SettingsView";
|
||||
import { SkillsView } from "./components/skills/SkillsView";
|
||||
import { GateView } from "./components/gate/GateView";
|
||||
import { PerformanceView } from "./components/performance/PerformanceView";
|
||||
import { WorkbenchBuildSummary } from "./components/workbench/WorkbenchBuildSummary";
|
||||
import { WorkbenchProbe } from "./components/workbench/WorkbenchProbe";
|
||||
import { fetchText } from "./services/api/client";
|
||||
@@ -34,6 +35,7 @@ const RIGHT_SIDEBAR_MAX_WIDTH = 680;
|
||||
|
||||
const routeLabels: Record<RouteId, string> = {
|
||||
workspace: "工作台",
|
||||
performance: "性能监控",
|
||||
skills: "Skills",
|
||||
access: "授权管理",
|
||||
gate: "内部复核",
|
||||
@@ -174,6 +176,7 @@ export function App(): ReactElement {
|
||||
) : null}
|
||||
<section className="center-workspace">
|
||||
{route === "workspace" ? <ConversationPanel messages={store.state.messages} availability={store.state.codeAgentAvailability} live={store.state.live} chatPending={store.state.chatPending} codeAgentTimeoutMs={store.state.codeAgentTimeoutMs} onCopySession={() => copyTextToClipboard(firstNonEmptyString(store.composer.sessionId, store.state.workspace?.selectedAgentSessionId, store.state.workspace?.workspace?.selectedAgentSessionId, store.activeConversationId) ?? "")} onCancelMessage={(id) => void store.cancelAgentMessage(id)} onRetryMessage={(id) => void store.retryAgentMessage(id)} onReplayTrace={(id) => void store.replayAgentTrace(id)} /> : null}
|
||||
{route === "performance" ? <PerformanceView /> : null}
|
||||
{route === "skills" ? <SkillsView /> : null}
|
||||
{route === "access" ? <AccessView /> : null}
|
||||
{route === "gate" ? <GateView /> : null}
|
||||
@@ -274,9 +277,10 @@ function HelpView({ help }: { help: string }): ReactElement {
|
||||
|
||||
function routeFromLocation(): RouteId {
|
||||
const hash = window.location.hash.replace(/^#\/?/u, "");
|
||||
if (hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash;
|
||||
if (hash === "performance" || hash === "skills" || hash === "access" || hash === "gate" || hash === "help" || hash === "settings" || hash === "workspace") return hash;
|
||||
const path = window.location.pathname.replace(/\/+$/u, "") || "/";
|
||||
if (path === "/help") return "help";
|
||||
if (path === "/performance" || path === "/ops/performance") return "performance";
|
||||
if (path === "/skills") return "skills";
|
||||
if (path === "/access" || path === "/admin/access") return "access";
|
||||
if (path === "/gate" || path === "/diagnostics/gate") return "gate";
|
||||
|
||||
@@ -8,18 +8,19 @@ interface ActivityRailProps {
|
||||
|
||||
const routes: Array<{ id: RouteId; label: string; title: string }> = [
|
||||
{ id: "workspace", label: "工作台", title: "工作台" },
|
||||
{ id: "performance", label: "性能", title: "性能监控" },
|
||||
{ id: "skills", label: "Skills", title: "Skills" },
|
||||
{ id: "access", label: "授权", title: "授权管理" },
|
||||
{ id: "gate", label: "内部复核", title: "内部复核" },
|
||||
{ id: "help", label: "使用说明", title: "使用说明" },
|
||||
{ id: "settings", label: "设置", title: "设置" }
|
||||
];
|
||||
const settingsRoute = routes[5];
|
||||
const settingsRoute = routes.find((item) => item.id === "settings");
|
||||
|
||||
export function ActivityRail({ route, onRoute }: ActivityRailProps): ReactElement {
|
||||
return (
|
||||
<aside className="activity-rail" id="activity-rail" aria-label="工作台活动栏">
|
||||
{routes.slice(0, 5).map((item) => <RailButton key={item.id} item={item} active={route === item.id} onRoute={onRoute} />)}
|
||||
{routes.filter((item) => item.id !== "settings").map((item) => <RailButton key={item.id} item={item} active={route === item.id} onRoute={onRoute} />)}
|
||||
<span className="rail-spacer" aria-hidden="true" />
|
||||
{settingsRoute ? <RailButton item={settingsRoute} active={route === "settings"} onRoute={onRoute} /> : null}
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { WebPerformanceSummaryResponse, WebPerformanceSummaryRow } from "../../types/domain";
|
||||
import { api } from "../../services/api/client";
|
||||
import { asArray, formatBeijingTime, toneClass } from "../../utils";
|
||||
import { StateTag } from "../shared/StateTag";
|
||||
|
||||
type PerformanceState = {
|
||||
loading: boolean;
|
||||
payload: WebPerformanceSummaryResponse | null;
|
||||
error: string | null;
|
||||
observedAt: string | null;
|
||||
};
|
||||
|
||||
const SLOW_ROW_LIMIT = 8;
|
||||
|
||||
export function PerformanceView(): ReactElement {
|
||||
const [state, setState] = useState<PerformanceState>({ loading: true, payload: null, error: null, observedAt: null });
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
setState((current) => ({ ...current, loading: true, error: null }));
|
||||
const response = await api.webPerformanceSummary();
|
||||
setState({
|
||||
loading: false,
|
||||
payload: response.ok ? response.data : null,
|
||||
error: response.ok ? null : response.error ?? "性能摘要不可用",
|
||||
observedAt: response.ok ? response.data?.observedAt ?? new Date().toISOString() : null
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
const timer = window.setInterval(() => void refresh(), 15000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const payload = state.payload;
|
||||
const summary = payload?.summary ?? {};
|
||||
const sampleCount = numeric(summary.sampleCount);
|
||||
const problemCount = numeric(summary.problemCount);
|
||||
const status = state.error ? "blocked" : sampleCount === 0 ? "pending" : problemCount > 0 ? "warn" : "ok";
|
||||
const apiRows = useMemo(() => sortRows(payload?.apiRoutes).slice(0, SLOW_ROW_LIMIT), [payload]);
|
||||
const vitalRows = useMemo(() => sortRows(payload?.webVitals).slice(0, SLOW_ROW_LIMIT), [payload]);
|
||||
const longTaskRows = useMemo(() => sortRows(payload?.longTasks).slice(0, SLOW_ROW_LIMIT), [payload]);
|
||||
const problemRows = useMemo(() => sortRows(payload?.problems).slice(0, SLOW_ROW_LIMIT), [payload]);
|
||||
|
||||
return (
|
||||
<section className="view performance-view" id="performance" data-view="performance" aria-labelledby="performance-title">
|
||||
<header className="performance-header">
|
||||
<div>
|
||||
<p className="eyebrow">User-perceived WebUI</p>
|
||||
<h2 id="performance-title">性能监控</h2>
|
||||
</div>
|
||||
<div className="performance-actions">
|
||||
<StateTag tone={toneClass(status)} id="performance-status" title={state.error ?? `observedAt=${state.observedAt ?? "unknown"}`}>{performanceStatusLabel(state, sampleCount, problemCount)}</StateTag>
|
||||
<button className="command-button secondary" id="performance-refresh" type="button" onClick={() => void refresh()} disabled={state.loading}>{state.loading ? "刷新中" : "刷新"}</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{state.error ? <p className="performance-error" id="performance-error">{state.error}</p> : null}
|
||||
|
||||
<div className="performance-kpi-grid" aria-label="性能摘要">
|
||||
<MetricTile label="RUM 样本" value={String(sampleCount)} detail={sampleCount < 10 ? "低样本,仅作链路基线" : `${numeric(summary.routeCount)} routes`} tone={sampleCount === 0 ? "pending" : sampleCount < 10 ? "warn" : "ok"} />
|
||||
<MetricTile label="问题队列" value={String(problemCount)} detail={problemCount > 0 ? "需要关注的慢路径/错误" : "暂无明显问题"} tone={problemCount > 0 ? "warn" : "ok"} />
|
||||
<MetricTile label="API 最慢 p95" value={formatMetric(maxP95(apiRows), "seconds")} detail={apiRows[0]?.route ?? "等待 API 样本"} tone={apiRows[0]?.tone ?? "source"} />
|
||||
<MetricTile label="Long task p95" value={formatMetric(maxP95(longTaskRows), "seconds")} detail={longTaskRows[0]?.route ?? "等待长任务样本"} tone={longTaskRows[0]?.tone ?? "source"} />
|
||||
</div>
|
||||
|
||||
<div className="performance-source-line" aria-label="性能数据来源">
|
||||
<span>source <strong>{payload?.source ?? "cloud-api-rum-store"}</strong></span>
|
||||
<span>namespace <strong>{payload?.namespace ?? "hwlab-v02"}</strong></span>
|
||||
<span>target <strong>{payload?.gitopsTarget ?? "v02"}</strong></span>
|
||||
<span>observed <strong>{formatBeijingTime(state.observedAt, { withSeconds: true })}</strong></span>
|
||||
</div>
|
||||
|
||||
<div className="performance-grid">
|
||||
<PerformanceTable title="慢 API 路径" eyebrow="Route p95" empty="等待浏览器同源 API 请求样本。" rows={apiRows} />
|
||||
<PerformanceTable title="Web Vitals / 首屏" eyebrow="Vitals" empty="等待 LCP / INP / navigation 样本。" rows={vitalRows} />
|
||||
<PerformanceTable title="主线程长任务" eyebrow="Long task" empty="当前没有 long task 样本。" rows={longTaskRows} />
|
||||
<PerformanceTable title="需要关注" eyebrow="Problems" empty="暂无错误、超时或慢路径。" rows={problemRows} showProblem />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricTile({ label, value, detail, tone }: { label: string; value: string; detail: string; tone?: string }): ReactElement {
|
||||
return (
|
||||
<article className={`performance-tile tone-border-${toneClass(tone)}`}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
<p>{detail}</p>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceTable({ title, eyebrow, empty, rows, showProblem = false }: { title: string; eyebrow: string; empty: string; rows: WebPerformanceSummaryRow[]; showProblem?: boolean }): ReactElement {
|
||||
return (
|
||||
<section className="workspace-panel performance-panel" aria-label={title}>
|
||||
<div className="performance-panel-head">
|
||||
<div><p className="eyebrow">{eyebrow}</p><h3>{title}</h3></div>
|
||||
<StateTag tone={rows.length > 0 ? toneClass(rows[0]?.tone) : "source"}>{rows.length > 0 ? `${rows.length} rows` : "waiting"}</StateTag>
|
||||
</div>
|
||||
{rows.length === 0 ? <p className="performance-empty">{empty}</p> : (
|
||||
<div className="performance-table-wrap">
|
||||
<table className="performance-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>对象</th>
|
||||
<th>指标</th>
|
||||
<th>p95</th>
|
||||
<th>样本</th>
|
||||
<th>结果</th>
|
||||
{showProblem ? <th>问题</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row, index) => <PerformanceRow key={`${row.metric}-${row.route}-${row.method}-${index}`} row={row} showProblem={showProblem} />)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PerformanceRow({ row, showProblem }: { row: WebPerformanceSummaryRow; showProblem: boolean }): ReactElement {
|
||||
return (
|
||||
<tr data-performance-tone={toneClass(row.tone)}>
|
||||
<td className="mono wrap">{row.route ?? "--"}</td>
|
||||
<td><span className="performance-metric-name">{row.metric ?? row.kind ?? "metric"}</span>{row.method && row.method !== "NONE" ? <span className="performance-method">{row.method}</span> : null}</td>
|
||||
<td className="mono">{formatMetric(row.p95, row.unit)}</td>
|
||||
<td>{numeric(row.count)}</td>
|
||||
<td><StateTag tone={toneClass(row.tone)}>{row.outcome ?? row.statusClass ?? "ok"}</StateTag></td>
|
||||
{showProblem ? <td className="wrap">{row.problem ?? "ok"}</td> : null}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function sortRows(rows: unknown): WebPerformanceSummaryRow[] {
|
||||
return asArray<WebPerformanceSummaryRow>(rows).sort((left, right) => toneWeight(right.tone) - toneWeight(left.tone) || numeric(right.p95) - numeric(left.p95) || numeric(right.count) - numeric(left.count));
|
||||
}
|
||||
|
||||
function performanceStatusLabel(state: PerformanceState, sampleCount: number, problemCount: number): string {
|
||||
if (state.loading && !state.payload) return "读取中";
|
||||
if (state.error) return "性能摘要阻塞";
|
||||
if (sampleCount === 0) return "等待样本";
|
||||
if (sampleCount < 10) return `低样本 ${sampleCount}`;
|
||||
if (problemCount > 0) return `${problemCount} 个问题`;
|
||||
return "观测正常";
|
||||
}
|
||||
|
||||
function maxP95(rows: WebPerformanceSummaryRow[]): number {
|
||||
return rows.reduce((max, row) => Math.max(max, numeric(row.p95)), 0);
|
||||
}
|
||||
|
||||
function formatMetric(value: unknown, unit: unknown): string {
|
||||
const number = numeric(value);
|
||||
if (number <= 0) return "--";
|
||||
if (unit === "score") return number.toFixed(number < 0.1 ? 3 : 2);
|
||||
if (number < 1) return `${Math.round(number * 1000)}ms`;
|
||||
return `${number.toFixed(number < 10 ? 2 : 1)}s`;
|
||||
}
|
||||
|
||||
function numeric(value: unknown): number {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? number : 0;
|
||||
}
|
||||
|
||||
function toneWeight(value: unknown): number {
|
||||
const tone = toneClass(value);
|
||||
if (tone === "blocked" || tone === "error") return 4;
|
||||
if (tone === "warn" || tone === "pending") return 3;
|
||||
if (tone === "ok") return 2;
|
||||
return 1;
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DevicePodStatusResponse,
|
||||
LiveBuildsPayload,
|
||||
LiveProbePayload,
|
||||
WebPerformanceSummaryResponse,
|
||||
SkillsResponse,
|
||||
SkillFileResponse,
|
||||
SkillTreeResponse,
|
||||
@@ -235,6 +236,7 @@ export const api = {
|
||||
getAgentChatResult: (resultUrl: string, timeoutMs = 8000, activityRef?: ApiRequestOptions["activityRef"]): Promise<ApiResult<AgentChatResultResponse>> => fetchJson(resultUrl, { timeoutMs, timeoutName: "Code Agent result", activityRef }),
|
||||
cancelAgentMessage: (payload: Record<string, unknown>): Promise<ApiResult<{ ok?: boolean; canceled?: boolean; error?: { code?: string; message?: string; userMessage?: string } }>> => fetchJson("/v1/agent/chat/cancel", { method: "POST", body: JSON.stringify(payload), timeoutMs: 30000, timeoutName: "Code Agent cancel" }),
|
||||
gateDiagnostics: (): Promise<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
|
||||
webPerformanceSummary: (): Promise<ApiResult<WebPerformanceSummaryResponse>> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }),
|
||||
skills: (): Promise<ApiResult<SkillsResponse>> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }),
|
||||
uploadSkills: (files: Array<{ relativePath: string; sizeBytes: number; contentBase64: string }>): Promise<ApiResult<{ skill?: { id?: string } }>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ files }), timeoutMs: 60000, timeoutName: "skill upload" }),
|
||||
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
|
||||
|
||||
@@ -360,6 +360,31 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.access-switch input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.access-switch input:disabled + span { opacity: 0.55; }
|
||||
.access-switch.busy span { background: #fff3d8; border-color: #e4bd68; }
|
||||
.performance-view { display: grid; gap: 12px; align-content: start; background: #eef3ef; }
|
||||
.performance-header { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.performance-header h2, .performance-panel h3 { margin: 0; }
|
||||
.performance-actions { display: flex; align-items: center; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
||||
.performance-error { margin: 0; border: 1px solid #e7a1a1; border-radius: 6px; background: #fff3f3; color: var(--bad); padding: 8px 10px; font-size: 13px; font-weight: 700; }
|
||||
.performance-kpi-grid { display: grid; grid-template-columns: repeat(4, minmax(150px, 1fr)); gap: 10px; }
|
||||
.performance-tile { min-width: 0; min-height: 104px; display: grid; align-content: start; gap: 6px; border: 1px solid var(--line); border-radius: 8px; background: #fff; padding: 12px; box-shadow: 0 1px 0 rgba(0,0,0,0.03); }
|
||||
.performance-tile span { color: var(--muted); font-size: 12px; font-weight: 800; text-transform: uppercase; }
|
||||
.performance-tile strong { font-size: 26px; line-height: 1; }
|
||||
.performance-tile p { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
||||
.performance-source-line { min-width: 0; display: flex; align-items: center; gap: 8px 14px; flex-wrap: wrap; color: var(--muted); font-size: 12px; }
|
||||
.performance-source-line strong { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
||||
.performance-grid { min-width: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: start; }
|
||||
.performance-panel { min-width: 0; display: grid; gap: 10px; padding: 12px; }
|
||||
.performance-panel-head { min-width: 0; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.performance-empty { margin: 0; border: 1px dashed var(--line); border-radius: 6px; background: #fbfcf8; color: var(--muted); padding: 14px; font-size: 13px; text-align: center; }
|
||||
.performance-table-wrap { min-width: 0; overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
||||
.performance-table { width: 100%; min-width: 680px; border-collapse: collapse; }
|
||||
.performance-table th, .performance-table td { border-bottom: 1px solid var(--line); padding: 8px; text-align: left; vertical-align: top; font-size: 13px; }
|
||||
.performance-table th { color: var(--muted); font-size: 12px; font-weight: 900; text-transform: uppercase; }
|
||||
.performance-table tbody tr[data-performance-tone="blocked"] td:first-child { border-left: 3px solid var(--bad); padding-left: 5px; }
|
||||
.performance-table tbody tr[data-performance-tone="warn"] td:first-child { border-left: 3px solid var(--warn); padding-left: 5px; }
|
||||
.performance-table tbody tr[data-performance-tone="ok"] td:first-child { border-left: 3px solid var(--ok); padding-left: 5px; }
|
||||
.performance-metric-name { display: inline-block; font-weight: 800; }
|
||||
.performance-method { display: inline-flex; margin-left: 6px; border: 1px solid var(--line); border-radius: 999px; padding: 1px 6px; color: var(--source); font-size: 11px; font-weight: 900; }
|
||||
.gate-view { display: grid; gap: 12px; }
|
||||
.gate-controls { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.gate-table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: #fff; }
|
||||
@@ -409,6 +434,9 @@ button:disabled { cursor: not-allowed; opacity: 0.55; }
|
||||
.device-pod-meta { display: none; }
|
||||
.settings-group { grid-template-columns: minmax(0, 1fr); }
|
||||
.skills-layout { grid-template-columns: minmax(0, 1fr); }
|
||||
.performance-header { align-items: flex-start; flex-direction: column; }
|
||||
.performance-kpi-grid, .performance-grid { grid-template-columns: minmax(0, 1fr); }
|
||||
.performance-table { min-width: 560px; }
|
||||
.access-panel { min-height: auto; overflow: visible; }
|
||||
.access-layout { grid-template-columns: minmax(0, 1fr); overflow: visible; }
|
||||
.access-user-list { max-height: none; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LiveBuildsPayload } from "./live-builds";
|
||||
export type { LiveBuildBuild, LiveBuildCommit, LiveBuildImage, LiveBuildRecord, LiveBuildsPayload } from "./live-builds";
|
||||
|
||||
export type Tone = "ok" | "source" | "pending" | "blocked" | "error" | "warn" | "dev-live" | "dry-run";
|
||||
export type RouteId = "workspace" | "skills" | "access" | "gate" | "help" | "settings";
|
||||
export type RouteId = "workspace" | "performance" | "skills" | "access" | "gate" | "help" | "settings";
|
||||
export type AuthState = "checking" | "login" | "authenticated";
|
||||
export type ProviderProfile = "deepseek" | "codex-api" | "minimax-m3";
|
||||
|
||||
@@ -53,6 +53,45 @@ export interface LiveSurface {
|
||||
loadedAt: string;
|
||||
}
|
||||
|
||||
export interface WebPerformanceSummaryRow {
|
||||
kind?: string;
|
||||
metric?: string;
|
||||
route?: string;
|
||||
method?: string;
|
||||
statusClass?: string;
|
||||
outcome?: string;
|
||||
count?: number;
|
||||
average?: number;
|
||||
p50?: number;
|
||||
p95?: number;
|
||||
unit?: "seconds" | "score";
|
||||
tone?: Tone | string;
|
||||
problem?: string;
|
||||
}
|
||||
|
||||
export interface WebPerformanceSummaryResponse {
|
||||
serviceId?: string;
|
||||
route?: string;
|
||||
source?: string;
|
||||
observedAt?: string;
|
||||
namespace?: string;
|
||||
gitopsTarget?: string;
|
||||
summary?: {
|
||||
sampleCount?: number;
|
||||
sampleSeries?: number;
|
||||
durationSeries?: number;
|
||||
clsSeries?: number;
|
||||
routeCount?: number;
|
||||
problemCount?: number;
|
||||
status?: string;
|
||||
};
|
||||
apiRoutes?: WebPerformanceSummaryRow[];
|
||||
webVitals?: WebPerformanceSummaryRow[];
|
||||
longTasks?: WebPerformanceSummaryRow[];
|
||||
problems?: WebPerformanceSummaryRow[];
|
||||
rows?: WebPerformanceSummaryRow[];
|
||||
}
|
||||
|
||||
export interface CodeAgentAvailability {
|
||||
status?: string;
|
||||
ready?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user