diff --git a/web/hwlab-cloud-web/src/api/client.ts b/web/hwlab-cloud-web/src/api/client.ts index bd5a5936..674cf4e7 100644 --- a/web/hwlab-cloud-web/src/api/client.ts +++ b/web/hwlab-cloud-web/src/api/client.ts @@ -1,4 +1,5 @@ import type { ApiResult } from "@/types"; +import { recordApiTiming } from "@/utils/rum"; export interface ActivityRef { lastActivityAt: number; @@ -68,8 +69,10 @@ export async function fetchJson(path: string, options: ApiRequestOptions = {} signal: controller.signal }); const payload = await response.json().catch(() => null) as T | null; + recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" }); return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) }; } catch (error) { + recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error" }); if (error instanceof DOMException && error.name === "AbortError") { if (inactivityRef.current) { const detail = inactivityRef.current; @@ -84,11 +87,14 @@ export async function fetchJson(path: string, options: ApiRequestOptions = {} } export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise> { + const startedAt = Date.now(); try { const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" }); const text = await response.text(); + recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" }); return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` }; } catch (error) { + recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: "network_error" }); return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) }; } } diff --git a/web/hwlab-cloud-web/src/api/index.ts b/web/hwlab-cloud-web/src/api/index.ts index cdb58c3a..1a9b334d 100644 --- a/web/hwlab-cloud-web/src/api/index.ts +++ b/web/hwlab-cloud-web/src/api/index.ts @@ -7,6 +7,7 @@ export { accessAPI } from "./access"; export { providerProfilesAPI } from "./providerProfiles"; export { apiKeysAPI } from "./apiKeys"; export { usageAPI } from "./usage"; +export { systemAPI, type SkillUploadFileInput } from "./system"; import { fetchJson } from "./client"; import { accessAPI } from "./access"; @@ -17,6 +18,7 @@ import { hwpodAPI } from "./hwpod"; import { providerProfilesAPI } from "./providerProfiles"; import { usageAPI } from "./usage"; import { workbenchAPI } from "./workbench"; +import { systemAPI } from "./system"; import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types"; export const api = { @@ -28,6 +30,7 @@ export const api = { providerProfiles: providerProfilesAPI, apiKeys: apiKeysAPI, usage: usageAPI, + system: systemAPI, healthLive: (): Promise> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }), health: (): Promise> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }), restIndex: (): Promise> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }), diff --git a/web/hwlab-cloud-web/src/api/system.ts b/web/hwlab-cloud-web/src/api/system.ts new file mode 100644 index 00000000..fa0e3bb1 --- /dev/null +++ b/web/hwlab-cloud-web/src/api/system.ts @@ -0,0 +1,24 @@ +import { fetchJson } from "./client"; +import type { + ApiResult, + GateDiagnosticsResponse, + SkillFileResponse, + SkillTreeResponse, + SkillUploadResponse, + SkillsResponse, + WebPerformanceSummaryResponse +} from "@/types"; + +export interface SkillUploadFileInput { + relativePath: string; + contentText: string; +} + +export const systemAPI = { + gateDiagnostics: (): Promise> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }), + performanceSummary: (): Promise> => fetchJson("/v1/web-performance/summary", { timeoutMs: 12000, timeoutName: "web performance summary" }), + skills: (): Promise> => fetchJson("/v1/skills", { timeoutMs: 12000, timeoutName: "skills" }), + uploadSkill: (name: string, files: SkillUploadFileInput[]): Promise> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ name: name.trim() || undefined, files }), timeoutMs: 30000, timeoutName: "skill upload" }), + skillTree: (skillId: string): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }), + skillFile: (skillId: string, path = "SKILL.md"): Promise> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(path)}`, { timeoutMs: 12000, timeoutName: "skill file" }) +}; diff --git a/web/hwlab-cloud-web/src/components/layout/AppShell.vue b/web/hwlab-cloud-web/src/components/layout/AppShell.vue index e265706c..64706c30 100644 --- a/web/hwlab-cloud-web/src/components/layout/AppShell.vue +++ b/web/hwlab-cloud-web/src/components/layout/AppShell.vue @@ -13,7 +13,7 @@ const navSections = [ { title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] }, { title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys" }, { name: "Usage", label: "用量", path: "/usage" }] }, { title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access" }, { name: "Users", label: "用户", path: "/admin/users" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles" }] }, - { title: "系统", items: [{ name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] } + { title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate" }, { name: "Performance", label: "性能", path: "/performance" }, { name: "Skills", label: "Skills", path: "/skills" }, { name: "Settings", label: "设置", path: "/settings" }, { name: "Help", label: "帮助", path: "/help" }] } ]; const shellless = computed(() => route.name === "Login" || route.name === "NotFound"); diff --git a/web/hwlab-cloud-web/src/main.ts b/web/hwlab-cloud-web/src/main.ts index 9ebd266a..07f814cd 100644 --- a/web/hwlab-cloud-web/src/main.ts +++ b/web/hwlab-cloud-web/src/main.ts @@ -4,6 +4,7 @@ import App from "./App.vue"; import router from "./router"; import i18n, { initI18n } from "./i18n"; import { useAppStore } from "@/stores/app"; +import { installWebRum } from "@/utils/rum"; import "./style.css"; import "./styles/workbench.css"; @@ -23,6 +24,7 @@ async function bootstrap(): Promise { app.use(router); app.use(i18n); await router.isReady(); + installWebRum(); app.mount("#app"); } diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 42787a48..52215bf5 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -1395,6 +1395,199 @@ text-align: left; } +.system-page .data-table td small, +.provider-admin-page .data-table td small { + display: block; + margin-top: 3px; + color: #64748b; + font-size: 12px; +} + +.system-summary-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; +} + +.system-summary-grid .metric-card { + display: grid; + gap: 5px; + padding: 14px; +} + +.system-summary-grid .metric-card span, +.rum-bar-row span, +.skill-tree-list small { + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.system-summary-grid .metric-card strong { + min-width: 0; + overflow-wrap: anywhere; + color: #0f172a; + font-size: 20px; + line-height: 1.15; +} + +.system-summary-grid .metric-card small { + min-width: 0; + overflow-wrap: anywhere; + color: #64748b; + font-size: 12px; +} + +.system-filter-bar { + display: grid; + grid-template-columns: minmax(140px, 220px) minmax(240px, 1fr); + gap: 10px; +} + +.system-filter-bar label { + display: grid; + gap: 5px; + color: #475569; + font-size: 12px; + font-weight: 700; +} + +.system-filter-bar select, +.system-filter-bar input { + min-height: 36px; + min-width: 0; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: white; + padding: 7px 10px; +} + +.system-row-detail { + display: -webkit-box; + max-width: 520px; + overflow: hidden; + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} + +.system-drilldown { + display: grid; + gap: 8px; +} + +.system-drilldown h3, +.system-drilldown p { + margin: 0; +} + +.system-drilldown h3 { + font-size: 13px; +} + +.system-drilldown ul, +.skill-tree-list { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} + +.system-drilldown li, +.skill-tree-list li { + min-width: 0; + border: 1px solid #e2e8f0; + border-radius: 8px; + background: #f8fafc; + padding: 8px; +} + +.rum-chart-panel { + display: grid; + gap: 10px; + padding: 14px; +} + +.rum-bar-row { + display: grid; + grid-template-columns: minmax(100px, 160px) minmax(0, 1fr) 48px; + align-items: center; + gap: 10px; +} + +.rum-bar-row div { + height: 10px; + overflow: hidden; + border-radius: 999px; + background: #e2e8f0; +} + +.rum-bar-row i { + display: block; + height: 100%; + border-radius: inherit; + background: #0e7490; +} + +.rum-bar-row strong { + text-align: right; +} + +.system-split-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.skills-tree-panel, +.skills-preview-panel { + display: grid; + align-content: start; + gap: 12px; + padding: 14px; +} + +.skill-tree-list { + max-height: 420px; + overflow: auto; +} + +.skill-tree-list li { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: 8px; +} + +.skill-tree-list button { + min-height: 26px; + border: 1px solid #cbd5e1; + border-radius: 8px; + background: white; + color: #334155; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.skill-tree-list button:disabled { + color: #94a3b8; +} + +.skill-tree-list span { + min-width: 0; + overflow-wrap: anywhere; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.skills-preview-panel .mini-log { + max-height: 460px; + min-height: 240px; + overflow: auto; + white-space: pre-wrap; +} + @media (max-width: 1180px) { .platform-shell { grid-template-columns: 76px minmax(0, 1fr); @@ -1413,7 +1606,9 @@ .workbench-grid, .overview-grid, .settings-grid, - .usage-panels { + .usage-panels, + .system-summary-grid, + .system-split-grid { grid-template-columns: 1fr; } @@ -1444,4 +1639,10 @@ .platform-topbar { padding: 0 12px; } + + .system-filter-bar, + .rum-bar-row, + .skill-tree-list li { + grid-template-columns: 1fr; + } } diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 1d42ff7f..536ee212 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -237,9 +237,83 @@ export interface HwpodSpecsResponse { specs?: Array>; [k export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array>; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown } -export interface SkillsResponse { skills?: Array>; [key: string]: unknown } -export interface SkillTreeResponse { tree?: Array>; [key: string]: unknown } -export interface SkillFileResponse { content?: string; [key: string]: unknown } +export interface GateDiagnosticRow extends Record { + category?: string; + check?: string; + status?: string; + statusKey?: string; + owner?: string; + detail?: string; + evidence?: string | string[]; + updatedAt?: string; + next?: string; +} + +export interface GateDiagnosticsResponse { + status?: string; + route?: string; + contractVersion?: string; + sourceKind?: string; + liveBackend?: boolean; + observedAt?: string; + rowCount?: number; + rows?: GateDiagnosticRow[]; + safety?: Record; + [key: string]: unknown; +} + +export interface WebPerformanceRow extends Record { + kind?: string; + metric?: string; + route?: string; + method?: string; + statusClass?: string; + outcome?: string; + count?: number; + average?: number; + p50?: number; + p95?: number; + unit?: "seconds" | "score" | string; + tone?: "ok" | "warn" | "blocked" | "source" | string; + problem?: string; +} + +export interface WebPerformanceSummaryResponse { + status?: string; + source?: string; + observedAt?: string; + namespace?: string; + gitopsTarget?: string; + summary?: { sampleCount?: number; sampleSeries?: number; durationSeries?: number; clsSeries?: number; routeCount?: number; problemCount?: number; status?: string; [key: string]: unknown }; + apiRoutes?: WebPerformanceRow[]; + webVitals?: WebPerformanceRow[]; + longTasks?: WebPerformanceRow[]; + problems?: WebPerformanceRow[]; + rows?: WebPerformanceRow[]; + [key: string]: unknown; +} + +export interface SkillRecord extends Record { + id: string; + source?: "preinstalled" | "uploaded" | string; + name?: string; + description?: string; + version?: string | null; + commitId?: string | null; + rootDir?: string; + manifestPath?: string | null; + createdAt?: string | null; + updatedAt?: string | null; + fileCount?: number; + sizeBytes?: number; +} + +export interface SkillTreeEntry extends Record { path: string; type?: "directory" | "file" | string; sizeBytes?: number | null } +export interface SkillFilePayload { path?: string; sizeBytes?: number; contentType?: string; content?: string } +export interface SkillsResponse { count?: number; skills?: SkillRecord[]; roots?: Record; agentRunAssembly?: Record; [key: string]: unknown } +export interface SkillUploadResponse { accepted?: boolean; skill?: SkillRecord; [key: string]: unknown } +export interface SkillTreeResponse { skill?: SkillRecord; tree?: SkillTreeEntry[]; count?: number; [key: string]: unknown } +export interface SkillFileResponse { skill?: SkillRecord; file?: SkillFilePayload; content?: string; [key: string]: unknown } export interface AdminAccessSummaryResponse { summary?: Record; [key: string]: unknown } export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown } diff --git a/web/hwlab-cloud-web/src/utils/rum.ts b/web/hwlab-cloud-web/src/utils/rum.ts new file mode 100644 index 00000000..bbdbc3e6 --- /dev/null +++ b/web/hwlab-cloud-web/src/utils/rum.ts @@ -0,0 +1,124 @@ +interface RumEvent { + kind: "navigation" | "web_vital" | "api" | "long_task"; + metric: string; + route?: string; + valueMs?: number; + value?: number; + method?: string; + status?: number; + statusClass?: string; + outcome?: string; +} + +interface ApiTimingInput { + route: string; + method?: string; + status?: number; + startedAt: number; + outcome: string; +} + +const SCHEMA_VERSION = "hwlab-web-performance-v1"; +const FLUSH_INTERVAL_MS = 4000; +const MAX_QUEUE = 80; +const queue: RumEvent[] = []; +let installed = false; +let flushTimer: number | null = null; + +export function installWebRum(): void { + if (installed || typeof window === "undefined") return; + installed = true; + window.addEventListener("load", () => window.setTimeout(recordNavigationTiming, 0), { once: true }); + window.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flushRum(true); + }); + installPerformanceObservers(); +} + +export function recordApiTiming(input: ApiTimingInput): void { + if (typeof window === "undefined") return; + const valueMs = Math.max(0, Date.now() - input.startedAt); + enqueue({ + kind: "api", + metric: "api_request", + route: input.route, + method: (input.method || "GET").toUpperCase(), + status: input.status ?? 0, + statusClass: statusClass(input.status), + outcome: input.outcome, + valueMs + }); +} + +function installPerformanceObservers(): void { + const Observer = window.PerformanceObserver; + if (!Observer) return; + observe("largest-contentful-paint", (entry) => enqueue({ kind: "web_vital", metric: "lcp", route: pageRoute(), valueMs: entry.startTime })); + observe("first-input", (entry) => { + const firstInput = entry as PerformanceEventTiming; + enqueue({ kind: "web_vital", metric: "fid", route: pageRoute(), valueMs: Math.max(0, firstInput.processingStart - firstInput.startTime) }); + }); + observe("layout-shift", (entry) => { + const shift = entry as PerformanceEntry & { value?: number; hadRecentInput?: boolean }; + if (shift.hadRecentInput) return; + enqueue({ kind: "web_vital", metric: "cls", route: pageRoute(), value: Number(shift.value) || 0 }); + }); + observe("longtask", (entry) => enqueue({ kind: "long_task", metric: "long_task", route: pageRoute(), valueMs: entry.duration })); +} + +function observe(type: string, onEntry: (entry: PerformanceEntry) => void): void { + try { + const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) onEntry(entry); + }); + observer.observe({ type, buffered: true }); + } catch { + // Older Chromium builds may not support every Web Vital entry type. + } +} + +function recordNavigationTiming(): void { + const navigation = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined; + if (!navigation) return; + const route = pageRoute(); + const start = navigation.startTime || 0; + enqueue({ kind: "navigation", metric: "navigation_ttfb", route, valueMs: Math.max(0, navigation.responseStart - start) }); + enqueue({ kind: "navigation", metric: "navigation_dom_content_loaded", route, valueMs: Math.max(0, navigation.domContentLoadedEventEnd - start) }); + enqueue({ kind: "navigation", metric: "navigation_load", route, valueMs: Math.max(0, navigation.loadEventEnd - start) }); +} + +function enqueue(event: RumEvent): void { + if (!Number.isFinite(event.valueMs ?? event.value ?? NaN)) return; + queue.push(event); + if (queue.length > MAX_QUEUE) queue.splice(0, queue.length - MAX_QUEUE); + scheduleFlush(); +} + +function scheduleFlush(): void { + if (flushTimer !== null) return; + flushTimer = window.setTimeout(() => flushRum(false), FLUSH_INTERVAL_MS); +} + +function flushRum(useBeacon: boolean): void { + if (flushTimer !== null) { + window.clearTimeout(flushTimer); + flushTimer = null; + } + if (!queue.length) return; + const events = queue.splice(0, queue.length); + const body = JSON.stringify({ schemaVersion: SCHEMA_VERSION, serviceId: "hwlab-cloud-web", page: pageRoute(), events }); + if (useBeacon && navigator.sendBeacon) { + navigator.sendBeacon("/v1/web-performance", new Blob([body], { type: "application/json" })); + return; + } + void fetch("/v1/web-performance", { method: "POST", headers: { "content-type": "application/json" }, body, credentials: "same-origin", keepalive: true }).catch(() => undefined); +} + +function pageRoute(): string { + return `${window.location.pathname}${window.location.hash || ""}` || "/"; +} + +function statusClass(status?: number): string { + if (!status || !Number.isFinite(status)) return "network"; + return `${Math.floor(status / 100)}xx`; +} diff --git a/web/hwlab-cloud-web/src/views/GateView.vue b/web/hwlab-cloud-web/src/views/GateView.vue index 8b02367f..63cccc37 100644 --- a/web/hwlab-cloud-web/src/views/GateView.vue +++ b/web/hwlab-cloud-web/src/views/GateView.vue @@ -1,11 +1,135 @@ diff --git a/web/hwlab-cloud-web/src/views/PerformanceView.vue b/web/hwlab-cloud-web/src/views/PerformanceView.vue index b09e1a9e..c7ad6ff3 100644 --- a/web/hwlab-cloud-web/src/views/PerformanceView.vue +++ b/web/hwlab-cloud-web/src/views/PerformanceView.vue @@ -1,29 +1,117 @@ diff --git a/web/hwlab-cloud-web/src/views/SkillsView.vue b/web/hwlab-cloud-web/src/views/SkillsView.vue index d2e2e6b5..f3f60426 100644 --- a/web/hwlab-cloud-web/src/views/SkillsView.vue +++ b/web/hwlab-cloud-web/src/views/SkillsView.vue @@ -1,29 +1,192 @@