Merge pull request #1174 from pikasTech/fix/v03-system-routes-1155
fix: 补齐 v0.3 Vue system routes parity
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import type { ApiResult } from "@/types";
|
import type { ApiResult } from "@/types";
|
||||||
|
import { recordApiTiming } from "@/utils/rum";
|
||||||
|
|
||||||
export interface ActivityRef {
|
export interface ActivityRef {
|
||||||
lastActivityAt: number;
|
lastActivityAt: number;
|
||||||
@@ -68,8 +69,10 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
|
|||||||
signal: controller.signal
|
signal: controller.signal
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => null) as T | null;
|
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}`) };
|
return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) };
|
||||||
} catch (error) {
|
} 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 (error instanceof DOMException && error.name === "AbortError") {
|
||||||
if (inactivityRef.current) {
|
if (inactivityRef.current) {
|
||||||
const detail = inactivityRef.current;
|
const detail = inactivityRef.current;
|
||||||
@@ -84,11 +87,14 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
|
export async function fetchText(path: string, options: ApiRequestOptions = {}): Promise<ApiResult<string>> {
|
||||||
|
const startedAt = Date.now();
|
||||||
try {
|
try {
|
||||||
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" });
|
const response = await fetch(path, { ...options, credentials: options.credentials ?? "same-origin" });
|
||||||
const text = await response.text();
|
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}` };
|
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
|
||||||
} catch (error) {
|
} 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) };
|
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export { accessAPI } from "./access";
|
|||||||
export { providerProfilesAPI } from "./providerProfiles";
|
export { providerProfilesAPI } from "./providerProfiles";
|
||||||
export { apiKeysAPI } from "./apiKeys";
|
export { apiKeysAPI } from "./apiKeys";
|
||||||
export { usageAPI } from "./usage";
|
export { usageAPI } from "./usage";
|
||||||
|
export { systemAPI, type SkillUploadFileInput } from "./system";
|
||||||
|
|
||||||
import { fetchJson } from "./client";
|
import { fetchJson } from "./client";
|
||||||
import { accessAPI } from "./access";
|
import { accessAPI } from "./access";
|
||||||
@@ -17,6 +18,7 @@ import { hwpodAPI } from "./hwpod";
|
|||||||
import { providerProfilesAPI } from "./providerProfiles";
|
import { providerProfilesAPI } from "./providerProfiles";
|
||||||
import { usageAPI } from "./usage";
|
import { usageAPI } from "./usage";
|
||||||
import { workbenchAPI } from "./workbench";
|
import { workbenchAPI } from "./workbench";
|
||||||
|
import { systemAPI } from "./system";
|
||||||
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
|
import type { ApiResult, LiveBuildsPayload, LiveProbePayload, SkillsResponse } from "@/types";
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
@@ -28,6 +30,7 @@ export const api = {
|
|||||||
providerProfiles: providerProfilesAPI,
|
providerProfiles: providerProfilesAPI,
|
||||||
apiKeys: apiKeysAPI,
|
apiKeys: apiKeysAPI,
|
||||||
usage: usageAPI,
|
usage: usageAPI,
|
||||||
|
system: systemAPI,
|
||||||
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
healthLive: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health/live", { timeoutMs: 12000, timeoutName: "health/live" }),
|
||||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||||
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
|
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
|
||||||
|
|||||||
@@ -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<ApiResult<GateDiagnosticsResponse>> => fetchJson("/v1/diagnostics/gate", { timeoutMs: 12000, timeoutName: "gate diagnostics" }),
|
||||||
|
performanceSummary: (): 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" }),
|
||||||
|
uploadSkill: (name: string, files: SkillUploadFileInput[]): Promise<ApiResult<SkillUploadResponse>> => fetchJson("/v1/skills/uploads", { method: "POST", body: JSON.stringify({ name: name.trim() || undefined, files }), timeoutMs: 30000, timeoutName: "skill upload" }),
|
||||||
|
skillTree: (skillId: string): Promise<ApiResult<SkillTreeResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/tree`, { timeoutMs: 12000, timeoutName: "skill tree" }),
|
||||||
|
skillFile: (skillId: string, path = "SKILL.md"): Promise<ApiResult<SkillFileResponse>> => fetchJson(`/v1/skills/${encodeURIComponent(skillId)}/files?path=${encodeURIComponent(path)}`, { timeoutMs: 12000, timeoutName: "skill file" })
|
||||||
|
};
|
||||||
@@ -13,7 +13,7 @@ const navSections = [
|
|||||||
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench" }, { name: "Dashboard", label: "概览", path: "/dashboard" }] },
|
{ 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: "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: "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");
|
const shellless = computed(() => route.name === "Login" || route.name === "NotFound");
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import App from "./App.vue";
|
|||||||
import router from "./router";
|
import router from "./router";
|
||||||
import i18n, { initI18n } from "./i18n";
|
import i18n, { initI18n } from "./i18n";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { installWebRum } from "@/utils/rum";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import "./styles/workbench.css";
|
import "./styles/workbench.css";
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ async function bootstrap(): Promise<void> {
|
|||||||
app.use(router);
|
app.use(router);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
|
installWebRum();
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1395,6 +1395,199 @@
|
|||||||
text-align: left;
|
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) {
|
@media (max-width: 1180px) {
|
||||||
.platform-shell {
|
.platform-shell {
|
||||||
grid-template-columns: 76px minmax(0, 1fr);
|
grid-template-columns: 76px minmax(0, 1fr);
|
||||||
@@ -1413,7 +1606,9 @@
|
|||||||
.workbench-grid,
|
.workbench-grid,
|
||||||
.overview-grid,
|
.overview-grid,
|
||||||
.settings-grid,
|
.settings-grid,
|
||||||
.usage-panels {
|
.usage-panels,
|
||||||
|
.system-summary-grid,
|
||||||
|
.system-split-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1444,4 +1639,10 @@
|
|||||||
.platform-topbar {
|
.platform-topbar {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-filter-bar,
|
||||||
|
.rum-bar-row,
|
||||||
|
.skill-tree-list li {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,9 +237,83 @@ export interface HwpodSpecsResponse { specs?: Array<Record<string, unknown>>; [k
|
|||||||
export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array<Record<string, unknown>>; [key: string]: unknown }
|
export interface HwpodNodeOpsResponse { ok?: boolean; status?: string; events?: TraceEvent[]; groups?: Array<Record<string, unknown>>; [key: string]: unknown }
|
||||||
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
|
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
|
||||||
export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown }
|
export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown }
|
||||||
export interface SkillsResponse { skills?: Array<Record<string, unknown>>; [key: string]: unknown }
|
export interface GateDiagnosticRow extends Record<string, unknown> {
|
||||||
export interface SkillTreeResponse { tree?: Array<Record<string, unknown>>; [key: string]: unknown }
|
category?: string;
|
||||||
export interface SkillFileResponse { content?: string; [key: string]: unknown }
|
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<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebPerformanceRow extends Record<string, unknown> {
|
||||||
|
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<string, unknown> {
|
||||||
|
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<string, unknown> { 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<string, string>; agentRunAssembly?: Record<string, unknown>; [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<string, unknown>; [key: string]: unknown }
|
export interface AdminAccessSummaryResponse { summary?: Record<string, unknown>; [key: string]: unknown }
|
||||||
export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown }
|
export interface AdminAccessUsersResponse { users?: AuthUser[]; [key: string]: unknown }
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -1,11 +1,135 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from "vue";
|
||||||
|
import { systemAPI } from "@/api";
|
||||||
|
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||||
|
import DataTable from "@/components/common/DataTable.vue";
|
||||||
|
import type { DataTableColumn } from "@/components/common/DataTable.vue";
|
||||||
import EmptyState from "@/components/common/EmptyState.vue";
|
import EmptyState from "@/components/common/EmptyState.vue";
|
||||||
|
import LoadingState from "@/components/common/LoadingState.vue";
|
||||||
import PageHeader from "@/components/common/PageHeader.vue";
|
import PageHeader from "@/components/common/PageHeader.vue";
|
||||||
|
import Pagination from "@/components/common/Pagination.vue";
|
||||||
|
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||||
|
import { useTableLoader } from "@/composables/useTableLoader";
|
||||||
|
import type { GateDiagnosticRow, GateDiagnosticsResponse } from "@/types";
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: "category", label: "类别" },
|
||||||
|
{ key: "check", label: "检查项" },
|
||||||
|
{ key: "status", label: "状态" },
|
||||||
|
{ key: "owner", label: "归因对象" },
|
||||||
|
{ key: "detail", label: "关键细节" },
|
||||||
|
{ key: "actions", label: "下钻", align: "right" }
|
||||||
|
];
|
||||||
|
|
||||||
|
const payload = ref<GateDiagnosticsResponse | null>(null);
|
||||||
|
const query = ref("");
|
||||||
|
const statusFilter = ref("all");
|
||||||
|
const selectedRow = ref<GateDiagnosticRow | null>(null);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 8;
|
||||||
|
|
||||||
|
const table = useTableLoader<GateDiagnosticRow>(async () => {
|
||||||
|
const response = await systemAPI.gateDiagnostics();
|
||||||
|
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
|
||||||
|
payload.value = response.data;
|
||||||
|
return response.data?.rows ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = computed(() => table.rows.value);
|
||||||
|
const statusCounts = computed(() => ({
|
||||||
|
failed: rows.value.filter((row) => row.statusKey === "failed").length,
|
||||||
|
blocked: rows.value.filter((row) => row.statusKey === "blocked").length,
|
||||||
|
pending: rows.value.filter((row) => row.statusKey === "pending").length,
|
||||||
|
pass: rows.value.filter((row) => row.statusKey === "pass").length
|
||||||
|
}));
|
||||||
|
const filteredRows = computed(() => {
|
||||||
|
const needle = query.value.trim().toLowerCase();
|
||||||
|
return rows.value.filter((row) => {
|
||||||
|
if (statusFilter.value !== "all" && row.statusKey !== statusFilter.value) return false;
|
||||||
|
if (!needle) return true;
|
||||||
|
return [row.category, row.check, row.status, row.owner, row.detail, row.next, evidenceList(row).join(" ")]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(needle);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const pageRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize, page.value * pageSize));
|
||||||
|
|
||||||
|
watch([query, statusFilter], () => { page.value = 1; });
|
||||||
|
onMounted(() => void table.reload());
|
||||||
|
|
||||||
|
function rowKey(row: GateDiagnosticRow): string {
|
||||||
|
return `${row.category ?? "category"}:${row.check ?? "check"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evidenceList(row: GateDiagnosticRow): string[] {
|
||||||
|
if (Array.isArray(row.evidence)) return row.evidence.map(String);
|
||||||
|
if (typeof row.evidence === "string" && row.evidence.trim()) return [row.evidence];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(row: GateDiagnosticRow): string {
|
||||||
|
return row.status ?? row.statusKey ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDate(value?: string): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="route-stack">
|
<section class="route-stack system-page gate-page">
|
||||||
<PageHeader eyebrow="System" title="内部复核" description="Gate 不是默认首屏;只作为内部复核 route 保留。" />
|
<PageHeader eyebrow="System" title="内部复核" description="Gate diagnostics 直接读取 /v1/diagnostics/gate 的 live 聚合结果,用表格、筛选和 drill-down 展示失败归因。" />
|
||||||
<EmptyState title="Gate route 已迁移到 Vue" description="后续接 /v1/diagnostics/gate。" />
|
<LoadingState v-if="table.loading.value && !rows.length" />
|
||||||
|
<section v-else-if="table.error.value" class="data-panel">
|
||||||
|
<EmptyState title="Gate diagnostics 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
|
||||||
|
</section>
|
||||||
|
<template v-else>
|
||||||
|
<section class="system-summary-grid" aria-label="Gate live aggregation">
|
||||||
|
<article class="metric-card"><span>Status</span><strong>{{ payload?.status || 'unknown' }}</strong><small>{{ payload?.sourceKind || '-' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Rows</span><strong>{{ rows.length }}</strong><small>{{ payload?.contractVersion || '-' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Blocked</span><strong>{{ statusCounts.blocked + statusCounts.failed }}</strong><small>failed {{ statusCounts.failed }} / blocked {{ statusCounts.blocked }}</small></article>
|
||||||
|
<article class="metric-card"><span>Observed</span><strong>{{ displayDate(payload?.observedAt) }}</strong><small>liveBackend={{ payload?.liveBackend ? 'true' : 'false' }}</small></article>
|
||||||
|
</section>
|
||||||
|
<TablePageLayout title="Diagnostics" subtitle="live aggregation / filter / failure drill-down">
|
||||||
|
<template #actions>
|
||||||
|
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
|
||||||
|
</template>
|
||||||
|
<template #filters>
|
||||||
|
<div class="system-filter-bar">
|
||||||
|
<label><span>状态</span><select v-model="statusFilter"><option value="all">全部</option><option value="failed">failed</option><option value="blocked">blocked</option><option value="pending">pending</option><option value="pass">pass</option></select></label>
|
||||||
|
<label><span>搜索</span><input v-model="query" type="search" placeholder="类别 / 检查项 / blocker / trace"></label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<DataTable :columns="columns" :rows="pageRows" :row-key="rowKey" empty-text="当前筛选没有 Gate 诊断行。">
|
||||||
|
<template #cell-category="{ row }"><strong>{{ (row as GateDiagnosticRow).category || '-' }}</strong></template>
|
||||||
|
<template #cell-check="{ row }"><span>{{ (row as GateDiagnosticRow).check || '-' }}</span><small>{{ displayDate((row as GateDiagnosticRow).updatedAt) }}</small></template>
|
||||||
|
<template #cell-status="{ row }"><span class="status-pill" :data-status="(row as GateDiagnosticRow).statusKey || 'pending'">{{ statusLabel(row as GateDiagnosticRow) }}</span></template>
|
||||||
|
<template #cell-owner="{ row }"><code>{{ (row as GateDiagnosticRow).owner || '-' }}</code></template>
|
||||||
|
<template #cell-detail="{ row }"><span class="system-row-detail">{{ (row as GateDiagnosticRow).detail || '-' }}</span></template>
|
||||||
|
<template #cell-actions="{ row }"><button class="table-action" type="button" @click="selectedRow = row as GateDiagnosticRow">Detail</button></template>
|
||||||
|
</DataTable>
|
||||||
|
<template #pagination><Pagination v-model:page="page" :page-size="pageSize" :total="filteredRows.length" /></template>
|
||||||
|
</TablePageLayout>
|
||||||
|
<BaseDialog :open="selectedRow !== null" title="Gate failure drill-down" :description="selectedRow?.check || ''" wide @close="selectedRow = null">
|
||||||
|
<dl v-if="selectedRow" class="field-grid">
|
||||||
|
<div><dt>category</dt><dd>{{ selectedRow.category || '-' }}</dd></div>
|
||||||
|
<div><dt>status</dt><dd>{{ statusLabel(selectedRow) }}</dd></div>
|
||||||
|
<div><dt>owner</dt><dd>{{ selectedRow.owner || '-' }}</dd></div>
|
||||||
|
<div><dt>updated</dt><dd>{{ displayDate(selectedRow.updatedAt) }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
<section v-if="selectedRow" class="system-drilldown">
|
||||||
|
<h3>关键细节</h3>
|
||||||
|
<p>{{ selectedRow.detail || '-' }}</p>
|
||||||
|
<h3>证据 / trace</h3>
|
||||||
|
<ul><li v-for="item in evidenceList(selectedRow)" :key="item"><code>{{ item }}</code></li></ul>
|
||||||
|
<h3>下一步</h3>
|
||||||
|
<p>{{ selectedRow.next || '-' }}</p>
|
||||||
|
</section>
|
||||||
|
</BaseDialog>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,117 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { usageAPI } from "@/api";
|
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 EmptyState from "@/components/common/EmptyState.vue";
|
||||||
import LoadingState from "@/components/common/LoadingState.vue";
|
import LoadingState from "@/components/common/LoadingState.vue";
|
||||||
import PageHeader from "@/components/common/PageHeader.vue";
|
import PageHeader from "@/components/common/PageHeader.vue";
|
||||||
import type { UsageSummary } from "@/types";
|
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||||
|
import { useTableLoader } from "@/composables/useTableLoader";
|
||||||
|
import type { WebPerformanceRow, WebPerformanceSummaryResponse } from "@/types";
|
||||||
|
|
||||||
const summary = ref<UsageSummary | null>(null);
|
const columns: DataTableColumn[] = [
|
||||||
const loading = ref(false);
|
{ key: "metric", label: "Metric" },
|
||||||
const error = ref<string | null>(null);
|
{ key: "route", label: "Route" },
|
||||||
onMounted(async () => {
|
{ key: "count", label: "Count", align: "right" },
|
||||||
loading.value = true;
|
{ key: "p50", label: "P50", align: "right" },
|
||||||
const response = await usageAPI.performance();
|
{ key: "p95", label: "P95", align: "right" },
|
||||||
summary.value = response.data;
|
{ key: "tone", label: "状态" }
|
||||||
error.value = response.ok ? null : response.error;
|
];
|
||||||
loading.value = false;
|
|
||||||
|
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 problems = computed(() => payload.value?.problems ?? []);
|
||||||
|
const webVitals = computed(() => payload.value?.webVitals ?? []);
|
||||||
|
const apiRoutes = computed(() => payload.value?.apiRoutes ?? []);
|
||||||
|
const longTasks = computed(() => payload.value?.longTasks ?? []);
|
||||||
|
const chartRows = computed(() => [
|
||||||
|
{ label: "Web Vitals", value: webVitals.value.length },
|
||||||
|
{ label: "API Timing", value: apiRoutes.value.length },
|
||||||
|
{ label: "Long Tasks", value: longTasks.value.length },
|
||||||
|
{ label: "Problems", value: problems.value.length }
|
||||||
|
]);
|
||||||
|
const maxChartValue = computed(() => Math.max(1, ...chartRows.value.map((row) => row.value)));
|
||||||
|
|
||||||
|
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'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(row: WebPerformanceRow, key: "average" | "p50" | "p95"): string {
|
||||||
|
const value = Number(row[key]);
|
||||||
|
if (!Number.isFinite(value)) return "-";
|
||||||
|
if (row.unit === "score") return value.toFixed(3);
|
||||||
|
return `${Math.round(value * 1000)}ms`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCount(value?: number): string {
|
||||||
|
return Number.isFinite(Number(value)) ? String(value) : "0";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="route-stack">
|
<section class="route-stack system-page performance-page">
|
||||||
<PageHeader eyebrow="System" title="性能监控" description="Cloud Web RUM/API latency 的页面骨架,保持轻量源码检查,不恢复旧 browser gate。" />
|
<PageHeader eyebrow="System" title="性能监控" description="RUM 已在 Vue 入口安装,页面汇总真实浏览器 navigation/Web Vital/API timing/long task 样本。" />
|
||||||
<LoadingState v-if="loading" />
|
<LoadingState v-if="table.loading.value && !table.rows.value.length" />
|
||||||
<EmptyState v-else-if="error" title="Performance 加载失败" :description="error" />
|
<section v-else-if="table.error.value" class="data-panel">
|
||||||
<EmptyState v-else-if="!summary?.rows?.length" title="Performance 空状态" description="等待 /v1/web-performance/summary 同源数据。" />
|
<EmptyState title="Performance 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
|
||||||
<pre v-else class="json-panel">{{ JSON.stringify(summary, null, 2) }}</pre>
|
</section>
|
||||||
|
<template v-else>
|
||||||
|
<section class="system-summary-grid" aria-label="Performance summary">
|
||||||
|
<article class="metric-card"><span>Status</span><strong>{{ summary.status || 'waiting' }}</strong><small>{{ payload?.source || '-' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Samples</span><strong>{{ formatCount(summary.sampleCount as number) }}</strong><small>{{ formatCount(summary.sampleSeries as number) }} series</small></article>
|
||||||
|
<article class="metric-card"><span>Routes</span><strong>{{ formatCount(summary.routeCount as number) }}</strong><small>{{ payload?.namespace || '-' }} / {{ payload?.gitopsTarget || '-' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Problems</span><strong>{{ formatCount(summary.problemCount as number) }}</strong><small>{{ payload?.observedAt || '-' }}</small></article>
|
||||||
|
</section>
|
||||||
|
<section class="data-panel rum-chart-panel" aria-label="Performance chart">
|
||||||
|
<div v-for="item in chartRows" :key="item.label" class="rum-bar-row">
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
<div><i :style="{ width: `${Math.max(6, (item.value / maxChartValue) * 100)}%` }" /></div>
|
||||||
|
<strong>{{ item.value }}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<TablePageLayout title="Problem rows" subtitle="按后端 summary 的 warn/blocked/outcome 聚合">
|
||||||
|
<template #actions><button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button></template>
|
||||||
|
<DataTable :columns="columns" :rows="problems.length ? problems : table.rows.value" :row-key="rowKey" empty-text="等待 RUM 样本。浏览几个页面后刷新即可看到数据。">
|
||||||
|
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
|
||||||
|
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }} / {{ (row as WebPerformanceRow).outcome || '-' }}</small></template>
|
||||||
|
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
|
||||||
|
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
|
||||||
|
<template #cell-p95="{ row }"><strong>{{ formatValue(row as WebPerformanceRow, 'p95') }}</strong><small>{{ (row as WebPerformanceRow).problem || '' }}</small></template>
|
||||||
|
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
|
||||||
|
</DataTable>
|
||||||
|
</TablePageLayout>
|
||||||
|
<section class="system-split-grid">
|
||||||
|
<TablePageLayout title="Web vitals" subtitle="navigation / LCP / CLS / FID">
|
||||||
|
<DataTable :columns="columns" :rows="webVitals" :row-key="rowKey" empty-text="暂无 Web Vital 样本。">
|
||||||
|
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
|
||||||
|
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code></template>
|
||||||
|
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
|
||||||
|
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
|
||||||
|
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
|
||||||
|
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
|
||||||
|
</DataTable>
|
||||||
|
</TablePageLayout>
|
||||||
|
<TablePageLayout title="API timing" subtitle="fetchJson/fetchText 同源 API 耗时">
|
||||||
|
<DataTable :columns="columns" :rows="apiRoutes" :row-key="rowKey" empty-text="暂无 API timing 样本。">
|
||||||
|
<template #cell-metric="{ row }"><strong>{{ (row as WebPerformanceRow).metric || '-' }}</strong><small>{{ (row as WebPerformanceRow).kind || '-' }}</small></template>
|
||||||
|
<template #cell-route="{ row }"><code>{{ (row as WebPerformanceRow).route || '-' }}</code><small>{{ (row as WebPerformanceRow).method || 'GET' }} / {{ (row as WebPerformanceRow).statusClass || '-' }}</small></template>
|
||||||
|
<template #cell-count="{ row }">{{ formatCount((row as WebPerformanceRow).count) }}</template>
|
||||||
|
<template #cell-p50="{ row }">{{ formatValue(row as WebPerformanceRow, 'p50') }}</template>
|
||||||
|
<template #cell-p95="{ row }">{{ formatValue(row as WebPerformanceRow, 'p95') }}</template>
|
||||||
|
<template #cell-tone="{ row }"><span class="status-pill" :data-status="(row as WebPerformanceRow).tone || 'pending'">{{ (row as WebPerformanceRow).tone || 'pending' }}</span></template>
|
||||||
|
</DataTable>
|
||||||
|
</TablePageLayout>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,29 +1,192 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue";
|
||||||
import { api } from "@/api";
|
import { systemAPI, type SkillUploadFileInput } from "@/api";
|
||||||
|
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||||
|
import DataTable from "@/components/common/DataTable.vue";
|
||||||
|
import type { DataTableColumn } from "@/components/common/DataTable.vue";
|
||||||
import EmptyState from "@/components/common/EmptyState.vue";
|
import EmptyState from "@/components/common/EmptyState.vue";
|
||||||
import LoadingState from "@/components/common/LoadingState.vue";
|
import LoadingState from "@/components/common/LoadingState.vue";
|
||||||
import PageHeader from "@/components/common/PageHeader.vue";
|
import PageHeader from "@/components/common/PageHeader.vue";
|
||||||
import type { SkillsResponse } from "@/types";
|
import Pagination from "@/components/common/Pagination.vue";
|
||||||
|
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
|
||||||
|
import { useForm } from "@/composables/useForm";
|
||||||
|
import { useTableLoader } from "@/composables/useTableLoader";
|
||||||
|
import type { SkillFilePayload, SkillRecord, SkillTreeEntry, SkillsResponse } from "@/types";
|
||||||
|
|
||||||
|
const columns: DataTableColumn[] = [
|
||||||
|
{ key: "name", label: "Skill" },
|
||||||
|
{ key: "source", label: "来源" },
|
||||||
|
{ key: "version", label: "版本" },
|
||||||
|
{ key: "files", label: "文件", align: "right" },
|
||||||
|
{ key: "assembly", label: "AgentRun assembly" },
|
||||||
|
{ key: "actions", label: "操作", align: "right" }
|
||||||
|
];
|
||||||
|
|
||||||
const payload = ref<SkillsResponse | null>(null);
|
const payload = ref<SkillsResponse | null>(null);
|
||||||
const loading = ref(false);
|
const selectedSkill = ref<SkillRecord | null>(null);
|
||||||
const error = ref<string | null>(null);
|
const tree = ref<SkillTreeEntry[]>([]);
|
||||||
onMounted(async () => {
|
const treeError = ref<string | null>(null);
|
||||||
loading.value = true;
|
const filePreview = ref<SkillFilePayload | null>(null);
|
||||||
const response = await api.skills();
|
const fileError = ref<string | null>(null);
|
||||||
|
const uploadOpen = ref(false);
|
||||||
|
const uploadName = ref("");
|
||||||
|
const uploadFiles = ref<SkillUploadFileInput[]>([]);
|
||||||
|
const uploadForm = useForm();
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
|
||||||
|
const table = useTableLoader<SkillRecord>(async () => {
|
||||||
|
const response = await systemAPI.skills();
|
||||||
|
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
|
||||||
payload.value = response.data;
|
payload.value = response.data;
|
||||||
error.value = response.ok ? null : response.error;
|
return response.data?.skills ?? [];
|
||||||
loading.value = false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rows = computed(() => table.rows.value);
|
||||||
|
const pageRows = computed(() => rows.value.slice((page.value - 1) * pageSize, page.value * pageSize));
|
||||||
|
const uploadedCount = computed(() => rows.value.filter((row) => row.source === "uploaded").length);
|
||||||
|
const preinstalledCount = computed(() => rows.value.filter((row) => row.source === "preinstalled").length);
|
||||||
|
const assembly = computed(() => payload.value?.agentRunAssembly ?? {});
|
||||||
|
const assemblyResourceBundle = computed(() => (assembly.value.resourceBundle && typeof assembly.value.resourceBundle === "object" ? assembly.value.resourceBundle as Record<string, unknown> : {}));
|
||||||
|
const assemblyPrompt = computed(() => (assembly.value.promptAssembly && typeof assembly.value.promptAssembly === "object" ? assembly.value.promptAssembly as Record<string, unknown> : {}));
|
||||||
|
|
||||||
|
onMounted(() => void table.reload());
|
||||||
|
|
||||||
|
function rowKey(row: SkillRecord): string {
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectSkill(row: SkillRecord): Promise<void> {
|
||||||
|
selectedSkill.value = row;
|
||||||
|
tree.value = [];
|
||||||
|
filePreview.value = null;
|
||||||
|
treeError.value = null;
|
||||||
|
fileError.value = null;
|
||||||
|
const treeResponse = await systemAPI.skillTree(row.id);
|
||||||
|
if (!treeResponse.ok) {
|
||||||
|
treeError.value = treeResponse.error ?? `HTTP ${treeResponse.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tree.value = treeResponse.data?.tree ?? [];
|
||||||
|
await previewFile(row, "SKILL.md");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function previewFile(row: SkillRecord, path = "SKILL.md"): Promise<void> {
|
||||||
|
fileError.value = null;
|
||||||
|
const response = await systemAPI.skillFile(row.id, path);
|
||||||
|
if (!response.ok) {
|
||||||
|
filePreview.value = null;
|
||||||
|
fileError.value = response.error ?? `HTTP ${response.status}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filePreview.value = response.data?.file ?? { path, content: response.data?.content ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadInput(event: Event): Promise<void> {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
uploadFiles.value = await Promise.all(files.slice(0, 80).map(async (file) => ({
|
||||||
|
relativePath: file.webkitRelativePath || file.name,
|
||||||
|
contentText: await file.text()
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadSkill(): Promise<void> {
|
||||||
|
const ok = await uploadForm.submit(async () => {
|
||||||
|
const response = await systemAPI.uploadSkill(uploadName.value, uploadFiles.value);
|
||||||
|
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
|
||||||
|
uploadOpen.value = false;
|
||||||
|
uploadName.value = "";
|
||||||
|
uploadFiles.value = [];
|
||||||
|
await table.reload();
|
||||||
|
const uploaded = response.data?.skill;
|
||||||
|
if (uploaded) await inspectSkill(uploaded);
|
||||||
|
}, "Skill uploaded");
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayDate(value?: string | null): string {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(value?: number): string {
|
||||||
|
const bytes = Number(value);
|
||||||
|
if (!Number.isFinite(bytes) || bytes <= 0) return "0 B";
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="route-stack">
|
<section class="route-stack system-page skills-page">
|
||||||
<PageHeader eyebrow="System" title="Skills" description="技能包上传、浏览和审阅的 Vue route 骨架。" />
|
<PageHeader eyebrow="System" title="Skills" description="技能包管理直接使用 /v1/skills、upload、tree 和 file preview 接口,展示 AgentRun assembly 状态。" />
|
||||||
<LoadingState v-if="loading" />
|
<LoadingState v-if="table.loading.value && !rows.length" />
|
||||||
<EmptyState v-else-if="error" title="Skills 加载失败" :description="error" />
|
<section v-else-if="table.error.value" class="data-panel">
|
||||||
<EmptyState v-else-if="!payload?.skills?.length" title="Skills 空状态" description="等待 /v1/skills 返回真实技能包。" />
|
<EmptyState title="Skills 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
|
||||||
<pre v-else class="json-panel">{{ JSON.stringify(payload, null, 2) }}</pre>
|
</section>
|
||||||
|
<template v-else>
|
||||||
|
<section class="system-summary-grid" aria-label="Skills summary">
|
||||||
|
<article class="metric-card"><span>Total</span><strong>{{ rows.length }}</strong><small>{{ preinstalledCount }} preinstalled / {{ uploadedCount }} uploaded</small></article>
|
||||||
|
<article class="metric-card"><span>ResourceBundle</span><strong>{{ assemblyResourceBundle.kind || '-' }}</strong><small>{{ assemblyResourceBundle.skillsDir || '-' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Prompt</span><strong>{{ assemblyPrompt.status || '-' }}</strong><small>valuesPrinted={{ assemblyPrompt.valuesPrinted === false ? 'false' : 'unknown' }}</small></article>
|
||||||
|
<article class="metric-card"><span>Roots</span><strong>{{ payload?.roots?.uploaded ? 'ready' : 'unknown' }}</strong><small>{{ payload?.roots?.codexAggregation || '-' }}</small></article>
|
||||||
|
</section>
|
||||||
|
<TablePageLayout title="Skill registry" subtitle="preinstalled + uploaded roots / tree / SKILL.md preview">
|
||||||
|
<template #actions>
|
||||||
|
<button class="table-action" type="button" :disabled="table.loading.value" @click="table.reload">刷新</button>
|
||||||
|
<button class="table-action" type="button" @click="uploadOpen = true">Upload</button>
|
||||||
|
</template>
|
||||||
|
<DataTable :columns="columns" :rows="pageRows" :row-key="rowKey" empty-text="暂无可用 skill。">
|
||||||
|
<template #cell-name="{ row }">
|
||||||
|
<strong>{{ (row as SkillRecord).name || (row as SkillRecord).id }}</strong>
|
||||||
|
<small>{{ (row as SkillRecord).description || '-' }}</small>
|
||||||
|
</template>
|
||||||
|
<template #cell-source="{ row }"><span class="status-pill" :data-status="(row as SkillRecord).source || 'pending'">{{ (row as SkillRecord).source || '-' }}</span></template>
|
||||||
|
<template #cell-version="{ row }"><code>{{ (row as SkillRecord).version || (row as SkillRecord).commitId || '-' }}</code><small>{{ displayDate((row as SkillRecord).updatedAt || (row as SkillRecord).createdAt) }}</small></template>
|
||||||
|
<template #cell-files="{ row }"><strong>{{ (row as SkillRecord).fileCount ?? 0 }}</strong><small>{{ formatBytes((row as SkillRecord).sizeBytes) }}</small></template>
|
||||||
|
<template #cell-assembly="{ row }"><code>{{ (row as SkillRecord).manifestPath || '-' }}</code></template>
|
||||||
|
<template #cell-actions="{ row }"><button class="table-action" type="button" @click="inspectSkill(row as SkillRecord)">Tree</button></template>
|
||||||
|
</DataTable>
|
||||||
|
<template #pagination><Pagination v-model:page="page" :page-size="pageSize" :total="rows.length" /></template>
|
||||||
|
</TablePageLayout>
|
||||||
|
|
||||||
|
<section class="system-split-grid skills-detail-grid">
|
||||||
|
<section class="data-panel skills-tree-panel">
|
||||||
|
<header class="panel-header"><h2>目录树</h2><span class="status-pill" :data-status="treeError ? 'error' : selectedSkill ? 'ready' : 'pending'">{{ selectedSkill?.name || '未选择' }}</span></header>
|
||||||
|
<p v-if="treeError" class="form-error">{{ treeError }}</p>
|
||||||
|
<p v-else-if="!selectedSkill" class="muted-box">选择一行 skill 查看 tree 和 SKILL.md。</p>
|
||||||
|
<ul v-else class="skill-tree-list">
|
||||||
|
<li v-for="entry in tree" :key="entry.path" :data-type="entry.type">
|
||||||
|
<button type="button" :disabled="entry.type !== 'file'" @click="selectedSkill && previewFile(selectedSkill, entry.path)">{{ entry.type === 'directory' ? 'dir' : 'file' }}</button>
|
||||||
|
<span>{{ entry.path }}</span>
|
||||||
|
<small>{{ entry.type === 'file' ? formatBytes(entry.sizeBytes ?? 0) : '' }}</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
<section class="data-panel skills-preview-panel">
|
||||||
|
<header class="panel-header"><h2>文件预览</h2><code>{{ filePreview?.path || 'SKILL.md' }}</code></header>
|
||||||
|
<p v-if="fileError" class="form-error">{{ fileError }}</p>
|
||||||
|
<pre v-else class="mini-log">{{ filePreview?.content || '等待选择 skill 或文件。' }}</pre>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<BaseDialog :open="uploadOpen" title="Upload skill" description="上传文本型 skill 目录;至少包含 SKILL.md。" wide @close="uploadOpen = false">
|
||||||
|
<form class="admin-form" @submit.prevent="uploadSkill">
|
||||||
|
<label><span>名称</span><input v-model="uploadName" type="text" placeholder="可选;默认读取 SKILL.md frontmatter"></label>
|
||||||
|
<label><span>目录或文件</span><input type="file" multiple webkitdirectory @change="onUploadInput"></label>
|
||||||
|
<p class="muted-box">已选择 {{ uploadFiles.length }} 个文本文件;后端会剥离单层顶级目录并写入 uploaded skill root。</p>
|
||||||
|
<p v-if="uploadForm.error.value" class="form-error">{{ uploadForm.error.value }}</p>
|
||||||
|
<p v-if="uploadForm.notice.value" class="form-notice">{{ uploadForm.notice.value }}</p>
|
||||||
|
</form>
|
||||||
|
<template #footer>
|
||||||
|
<button class="table-action" type="button" :disabled="uploadForm.loading.value" @click="uploadOpen = false">取消</button>
|
||||||
|
<button class="table-action" type="button" :disabled="uploadForm.loading.value || uploadFiles.length === 0" @click="uploadSkill">{{ uploadForm.loading.value ? '上传中' : '上传' }}</button>
|
||||||
|
</template>
|
||||||
|
</BaseDialog>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user