Merge pull request #1732 from pikasTech/feat/1708-p3-api-error

feat(web): surface API error diagnostics
This commit is contained in:
Lyon
2026-06-20 15:39:57 +08:00
committed by GitHub
13 changed files with 555 additions and 39 deletions
@@ -226,7 +226,7 @@ async function handleRequest(request: IncomingMessage, response: ServerResponse)
if (path === "/v1/hwpod/specs") return json(response, 200, hwpodSpecsPayload());
if (path === "/v1/hwpod-node-ops") return json(response, 200, hwpodNodeOpsPayload());
if (path === "/v1/web-performance/summary") {
if (state.scenarioId === "performance-summary-error") return json(response, 502, { ok: false, status: 502, error: { code: "upstream_unavailable", message: "暂时无法连接上游。" } });
if (state.scenarioId === "performance-summary-error") return errorDiagnosticResponse(response, 502, "/v1/web-performance/summary", "upstream_unavailable", "暂时无法连接上游。");
if (state.scenarioId === "performance-empty") return json(response, 200, webPerformanceEmptyPayload(url));
if (state.scenarioId === "performance-stale-window") return json(response, 200, webPerformanceStaleWindowPayload(url));
return json(response, 200, webPerformanceSummaryPayload(url));
@@ -1884,8 +1884,29 @@ async function readJson(request: IncomingMessage): Promise<JsonRecord> {
}
}
function json(response: ServerResponse, status: number, body: unknown): void {
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
function errorDiagnosticResponse(response: ServerResponse, status: number, route: string, code: string, message: string): void {
const diagnostic = {
contractVersion: "hwlab-error-diagnostic-v1",
traceId: "11111111111111111111111111111111",
requestId: "req_e2e_api_error_diagnostic",
route,
layer: "api",
category: "server",
code,
httpStatus: status,
source: "server",
observedAt: new Date().toISOString(),
valuesPrinted: false
};
json(response, status, { ok: false, status, error: { code, message, diagnostic } }, {
traceparent: `00-${diagnostic.traceId}-2222222222222222-01`,
"x-hwlab-otel-trace-id": diagnostic.traceId,
"x-request-id": diagnostic.requestId
});
}
function json(response: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
response.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers });
response.end(JSON.stringify(body));
}
+238 -8
View File
@@ -1,4 +1,4 @@
import type { ApiResult } from "@/types";
import type { ApiError, ApiResult, ErrorDiagnostic } from "@/types";
import { recordApiTiming } from "@/utils/rum";
import { recordWorkbenchApiRequest } from "@/utils/workbench-performance";
@@ -17,6 +17,13 @@ export interface ApiRequestOptions extends RequestInit {
activityRef?: ActivityRef | ActivityRefSource;
}
export interface ApiErrorContext {
error: string | null;
apiError: ApiError | null;
diagnostic: ErrorDiagnostic | null;
status?: number | null;
}
const ACTIVITY_RESCHEDULER_MS = 1000;
const DEFAULT_TIMEOUT_MS = 4500;
@@ -72,7 +79,8 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
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" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error", timeoutName: options.timeoutName });
return { ok: response.ok, status: response.status, data: payload, error: response.ok ? null : errorMessage(payload, `HTTP ${response.status}`) };
const context = response.ok ? emptyErrorContext() : responseErrorContext(payload, response, path, `HTTP ${response.status}`);
return { ok: response.ok, status: response.status, data: payload, error: context.error, apiError: context.apiError, diagnostic: context.diagnostic };
} catch (error) {
const outcome = error instanceof DOMException && error.name === "AbortError" ? "timeout" : "network_error";
const activity = inactivityRef.current;
@@ -93,11 +101,11 @@ export async function fetchJson<T>(path: string, options: ApiRequestOptions = {}
if (error instanceof DOMException && error.name === "AbortError") {
if (activity) {
const detail = activity;
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}swaitingFor=${detail.waitingFor ?? "unknown"}lastEventLabel=${detail.lastEventLabel ?? "none"}` };
return browserFailure<T>(path, options, `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}swaitingFor=${detail.waitingFor ?? "unknown"}lastEventLabel=${detail.lastEventLabel ?? "none"}`, "browser_timeout", "timeout");
}
return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` };
return browserFailure<T>(path, options, `${options.timeoutName ?? path} 超时`, "browser_timeout", "timeout");
}
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
return browserFailure<T>(path, options, error instanceof Error ? error.message : String(error), "browser_network_error", "network");
} finally {
if (timer !== null) clearTimeout(timer);
}
@@ -110,25 +118,247 @@ export async function fetchText(path: string, options: ApiRequestOptions = {}):
const text = await response.text();
recordApiTiming({ route: path, method: options.method, status: response.status, startedAt, outcome: response.ok ? "ok" : "http_error" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: response.status, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: response.ok ? "ok" : "http_error", timeoutName: options.timeoutName });
return { ok: response.ok, status: response.status, data: text, error: response.ok ? null : `HTTP ${response.status}` };
const context = response.ok ? emptyErrorContext() : responseErrorContext(null, response, path, `HTTP ${response.status}`);
return { ok: response.ok, status: response.status, data: text, error: context.error, apiError: context.apiError, diagnostic: context.diagnostic };
} catch (error) {
recordApiTiming({ route: path, method: options.method, status: 0, startedAt, outcome: "network_error" });
recordWorkbenchApiRequest({ route: path, method: options.method, status: 0, startedAtEpochMs: startedAt, endedAtEpochMs: Date.now(), outcome: "network_error", timeoutName: options.timeoutName, errorName: error instanceof Error ? error.name : undefined });
return { ok: false, status: 0, data: null, error: error instanceof Error ? error.message : String(error) };
return browserFailure<string>(path, options, error instanceof Error ? error.message : String(error), "browser_network_error", "network");
}
}
export function apiErrorContextFromUnknown(err: unknown, fallback = "操作失败"): ApiErrorContext {
if (isApiResultLike(err)) {
const diagnostic = normalizeDiagnostic(err.diagnostic) ?? normalizeDiagnostic(err.apiError?.diagnostic) ?? null;
const apiError = normalizeApiError(err.apiError, err.error ?? fallback, diagnostic);
const message = firstString(err.error, apiError?.userMessage, apiError?.message, fallback) ?? fallback;
return { error: message, apiError, diagnostic, status: err.status };
}
if (err instanceof Error) return { error: err.message || fallback, apiError: null, diagnostic: null };
if (isRecord(err)) {
const nested = isRecord(err.error) ? err.error : null;
const diagnostic = normalizeDiagnostic(err.diagnostic) ?? normalizeDiagnostic(nested?.diagnostic) ?? null;
const apiError = normalizeApiError(nested ?? err.apiError, firstString(err.message, err.error, fallback) ?? fallback, diagnostic);
const message = firstString(err.message, err.reason, err.error, apiError?.userMessage, apiError?.message, fallback) ?? fallback;
return { error: message, apiError, diagnostic, status: firstNumber(err.status) };
}
return { error: String(err || fallback), apiError: null, diagnostic: null };
}
function resolveActivitySource(ref: ApiRequestOptions["activityRef"]): ActivityRefSource {
if (typeof ref === "function") return ref;
if (ref && typeof ref === "object") return () => ref;
return null;
}
function emptyErrorContext(): ApiErrorContext {
return { error: null, apiError: null, diagnostic: null };
}
function responseErrorContext(payload: unknown, response: Response, path: string, fallback: string): ApiErrorContext {
const diagnostic = responseDiagnostic(payload, response, path);
const message = errorMessage(payload, fallback);
return {
error: message,
apiError: responseApiError(payload, path, message, diagnostic),
diagnostic,
status: response.status
};
}
function responseApiError(payload: unknown, path: string, message: string, diagnostic: ErrorDiagnostic): ApiError {
const record = isRecord(payload) ? payload : null;
const nested = record && isRecord(record.error) ? record.error : null;
const source = nested ?? record ?? {};
const normalized = normalizeApiError(source, message, diagnostic) ?? { message, diagnostic };
return {
...normalized,
message,
userMessage: firstString(normalized.userMessage, message),
code: firstStringOrNumber(normalized.code, diagnostic.code),
retryable: firstBoolean(normalized.retryable, diagnostic.retryable),
layer: firstString(normalized.layer, diagnostic.layer),
category: firstString(normalized.category, diagnostic.category),
route: firstString(normalized.route, diagnostic.route, path),
traceId: firstString(normalized.traceId, diagnostic.traceId),
requestId: firstString(normalized.requestId, diagnostic.requestId),
source: firstString(normalized.source, diagnostic.source, "server"),
diagnostic,
valuesPrinted: normalized.valuesPrinted === true ? true : diagnostic.valuesPrinted === true
};
}
function responseDiagnostic(payload: unknown, response: Response, path: string): ErrorDiagnostic {
const record = isRecord(payload) ? payload : null;
const nested = record && isRecord(record.error) ? record.error : null;
const body = normalizeDiagnostic(nested?.diagnostic) ?? normalizeDiagnostic(record?.diagnostic) ?? {};
const headerTraceId = firstString(response.headers.get("x-hwlab-otel-trace-id"), traceIdFromTraceparent(response.headers.get("traceparent")));
const requestId = response.headers.get("x-request-id");
return {
...body,
contractVersion: firstString(body.contractVersion, "hwlab-error-diagnostic-v1"),
traceId: firstString(headerTraceId, body.traceId, nested?.traceId, record?.traceId),
requestId: firstString(requestId, body.requestId, nested?.requestId, record?.requestId),
route: firstString(body.route, nested?.route, record?.route, path),
layer: firstString(body.layer, nested?.layer, record?.layer),
category: firstString(body.category, nested?.category, record?.category),
code: firstStringOrNumber(body.code, nested?.code, record?.code),
httpStatus: firstNumber(body.httpStatus, response.status) ?? response.status,
serviceId: firstString(body.serviceId, nested?.serviceId, record?.serviceId),
source: firstString(body.source, "server"),
observedAt: firstString(body.observedAt, new Date().toISOString()),
retryable: firstBoolean(body.retryable, nested?.retryable, record?.retryable),
valuesPrinted: body.valuesPrinted === true
};
}
function browserFailure<T>(path: string, options: ApiRequestOptions, message: string, code: string, category: string): ApiResult<T> {
const diagnostic = browserDiagnostic(path, options, code, category);
return {
ok: false,
status: 0,
data: null,
error: message,
apiError: {
message,
userMessage: message,
code,
retryable: true,
layer: "web",
category,
route: path,
traceId: diagnostic.traceId,
requestId: diagnostic.requestId,
source: "browser",
diagnostic,
valuesPrinted: false
},
diagnostic
};
}
function browserDiagnostic(path: string, options: ApiRequestOptions, code: string, category: string): ErrorDiagnostic {
return {
contractVersion: "hwlab-error-diagnostic-v1",
traceId: randomTraceId(),
requestId: randomRequestId(),
route: path,
layer: "web",
category,
code,
httpStatus: 0,
source: "browser",
observedAt: new Date().toISOString(),
retryable: true,
timeoutName: options.timeoutName ?? null,
valuesPrinted: false
};
}
function normalizeApiError(value: unknown, fallback: string, diagnostic: ErrorDiagnostic | null): ApiError | null {
if (!isRecord(value)) return null;
const normalizedDiagnostic = normalizeDiagnostic(value.diagnostic) ?? diagnostic;
return {
...value,
message: firstString(value.message, value.userMessage, value.reason, value.code, fallback) ?? fallback,
userMessage: firstString(value.userMessage, value.message),
code: firstStringOrNumber(value.code, normalizedDiagnostic?.code),
reason: firstString(value.reason),
retryable: firstBoolean(value.retryable, normalizedDiagnostic?.retryable),
layer: firstString(value.layer, normalizedDiagnostic?.layer),
category: firstString(value.category, normalizedDiagnostic?.category),
route: firstString(value.route, normalizedDiagnostic?.route),
traceId: firstString(value.traceId, normalizedDiagnostic?.traceId),
requestId: firstString(value.requestId, normalizedDiagnostic?.requestId),
source: firstString(value.source, normalizedDiagnostic?.source),
diagnostic: normalizedDiagnostic,
valuesPrinted: value.valuesPrinted === true
} as ApiError;
}
function normalizeDiagnostic(value: unknown): ErrorDiagnostic | null {
if (!isRecord(value)) return null;
return {
...value,
contractVersion: firstString(value.contractVersion, value.contract_version),
traceId: firstString(value.traceId, value.trace_id, value.otelTraceId, value.otel_trace_id),
requestId: firstString(value.requestId, value.request_id),
serviceId: firstString(value.serviceId, value.service_id),
route: firstString(value.route),
layer: firstString(value.layer),
category: firstString(value.category),
code: firstStringOrNumber(value.code),
httpStatus: firstNumber(value.httpStatus, value.http_status),
source: firstString(value.source),
observedAt: firstString(value.observedAt, value.observed_at),
retryable: firstBoolean(value.retryable),
valuesPrinted: value.valuesPrinted === true
};
}
function isApiResultLike(value: unknown): value is ApiResult<unknown> {
return isRecord(value) && typeof value.ok === "boolean" && typeof value.status === "number" && "data" in value && "error" in value;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function firstStringOrNumber(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function firstNumber(...values: unknown[]): number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
}
return null;
}
function firstBoolean(...values: unknown[]): boolean | null {
for (const value of values) {
if (typeof value === "boolean") return value;
}
return null;
}
function traceIdFromTraceparent(value: string | null): string | null {
const match = /^[\da-f]{2}-([\da-f]{32})-[\da-f]{16}-[\da-f]{2}$/iu.exec(value?.trim() ?? "");
const traceId = match?.[1]?.toLowerCase() ?? null;
return traceId && !/^0+$/u.test(traceId) ? traceId : null;
}
function randomTraceId(): string {
const bytes = new Uint8Array(16);
if (globalThis.crypto?.getRandomValues) {
globalThis.crypto.getRandomValues(bytes);
} else {
for (let index = 0; index < bytes.length; index += 1) bytes[index] = Math.floor(Math.random() * 256);
}
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
}
function randomRequestId(): string {
return `req_browser_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
function errorMessage(payload: unknown, fallback: string): string {
if (payload && typeof payload === "object") {
const record = payload as Record<string, unknown>;
const nested = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : null;
const candidate = nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error;
const candidate = nested?.userMessage ?? nested?.message ?? nested?.code ?? record.message ?? record.reason ?? record.error;
if (typeof candidate === "string" && candidate.trim()) return candidate;
}
return fallback;
@@ -0,0 +1,87 @@
<script setup lang="ts">
import { computed } from "vue";
import { useClipboard } from "@/composables/useClipboard";
import type { ApiError, ErrorDiagnostic } from "@/types";
const props = withDefaults(defineProps<{
title?: string;
error?: string | null;
apiError?: ApiError | null;
diagnostic?: ErrorDiagnostic | null;
compact?: boolean;
showMessage?: boolean;
}>(), {
title: "",
compact: false,
showMessage: true
});
const { copied, copy } = useClipboard();
const resolvedDiagnostic = computed(() => props.diagnostic ?? props.apiError?.diagnostic ?? null);
const message = computed(() => firstString(props.error, props.apiError?.userMessage, props.apiError?.message));
const facts = computed(() => {
const diagnostic = resolvedDiagnostic.value;
const valuesPrinted = diagnostic?.valuesPrinted === false || props.apiError?.valuesPrinted === false ? "false" : null;
return [
fact("trace_id", firstString(diagnostic?.traceId, props.apiError?.traceId)),
fact("requestId", firstString(diagnostic?.requestId, props.apiError?.requestId)),
fact("code", firstValue(props.apiError?.code, diagnostic?.code)),
fact("layer", layerText(diagnostic, props.apiError)),
fact("route", firstString(diagnostic?.route, props.apiError?.route)),
fact("source", firstString(diagnostic?.source, props.apiError?.source)),
fact("status", firstValue(diagnostic?.httpStatus)),
fact("valuesPrinted", valuesPrinted)
].filter((item): item is { label: string; value: string } => Boolean(item));
});
const hasContent = computed(() => Boolean(message.value || facts.value.length));
function fact(label: string, value: string | number | null | undefined): { label: string; value: string } | null {
if (value === null || value === undefined) return null;
const text = String(value).trim();
return text ? { label, value: text } : null;
}
function firstString(...values: unknown[]): string | null {
for (const value of values) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function firstValue(...values: unknown[]): string | number | null {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) return value.trim();
}
return null;
}
function layerText(diagnostic: ErrorDiagnostic | null, apiError?: ApiError | null): string | null {
const layer = firstString(diagnostic?.layer, apiError?.layer);
const category = firstString(diagnostic?.category, apiError?.category);
return [layer, category].filter(Boolean).join(" / ") || null;
}
function copyDiagnostic(): void {
const lines = [message.value ? `message=${message.value}` : null, ...facts.value.map((item) => `${item.label}=${item.value}`)].filter(Boolean);
void copy(lines.join("\n"));
}
</script>
<template>
<section v-if="hasContent" class="api-error-diagnostic" :data-compact="compact ? 'true' : 'false'">
<header v-if="title || (showMessage && message)">
<div>
<strong v-if="title">{{ title }}</strong>
<p v-if="showMessage && message">{{ message }}</p>
</div>
<button v-if="facts.length" class="table-action" type="button" @click="copyDiagnostic">{{ copied ? "已复制" : "复制诊断" }}</button>
</header>
<dl v-if="facts.length" class="api-error-diagnostic-grid">
<div v-for="item in facts" :key="item.label">
<dt>{{ item.label }}</dt>
<dd><code>{{ item.value }}</code></dd>
</div>
</dl>
</section>
</template>
@@ -1,5 +1,8 @@
<script setup lang="ts">
defineProps<{ title: string; description?: string; actionLabel?: string }>();
import ApiErrorDiagnostic from "./ApiErrorDiagnostic.vue";
import type { ApiError, ErrorDiagnostic } from "@/types";
defineProps<{ title: string; description?: string; actionLabel?: string; apiError?: ApiError | null; diagnostic?: ErrorDiagnostic | null }>();
defineEmits<{ action: [] }>();
</script>
@@ -8,6 +11,7 @@ defineEmits<{ action: [] }>();
<div class="empty-state-mark">HW</div>
<h2>{{ title }}</h2>
<p v-if="description">{{ description }}</p>
<ApiErrorDiagnostic v-if="apiError || diagnostic" :error="description" :api-error="apiError" :diagnostic="diagnostic" compact :show-message="false" />
<button v-if="actionLabel" class="btn btn-primary" type="button" @click="$emit('action')">{{ actionLabel }}</button>
</section>
</template>
+13 -2
View File
@@ -1,5 +1,7 @@
import { ref } from "vue";
import { apiErrorContextFromUnknown } from "@/api/client";
import { useAppStore } from "@/stores/app";
import type { ApiError, ErrorDiagnostic } from "@/types";
export interface UseFormOptions {
successMessage?: string;
@@ -8,6 +10,8 @@ export interface UseFormOptions {
}
export function messageFromError(err: unknown): string {
const context = apiErrorContextFromUnknown(err, "操作失败");
if (context.error) return context.error;
if (err instanceof Error && err.message.trim()) return err.message;
if (err && typeof err === "object") {
const record = err as Record<string, unknown>;
@@ -21,6 +25,8 @@ export function messageFromError(err: unknown): string {
export function useForm(options: UseFormOptions = {}) {
const loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const notice = ref<string | null>(null);
const app = useAppStore();
@@ -28,6 +34,8 @@ export function useForm(options: UseFormOptions = {}) {
if (loading.value) return false;
loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
notice.value = null;
try {
await action();
@@ -36,8 +44,11 @@ export function useForm(options: UseFormOptions = {}) {
if (options.toast !== false && message) app.showSuccess(message);
return true;
} catch (err) {
const message = options.errorMessage ?? messageFromError(err);
const context = apiErrorContextFromUnknown(err, "操作失败");
const message = options.errorMessage ?? context.error ?? messageFromError(err);
error.value = message;
apiError.value = context.apiError;
diagnostic.value = context.diagnostic;
if (options.toast !== false) app.showError(message);
return false;
} finally {
@@ -45,5 +56,5 @@ export function useForm(options: UseFormOptions = {}) {
}
}
return { loading, error, notice, submit };
return { loading, error, apiError, diagnostic, notice, submit };
}
@@ -1,22 +1,43 @@
import { computed, ref, type ComputedRef, type Ref } from "vue";
import { apiErrorContextFromUnknown } from "@/api/client";
import type { ApiError, ApiResult, ErrorDiagnostic } from "@/types";
export function useTableLoader<T>(loader: () => Promise<T[]>): { rows: Ref<T[]>; loading: Ref<boolean>; error: Ref<string | null>; empty: ComputedRef<boolean>; reload: () => Promise<void> } {
type TableLoaderResult<T> = T[] | ApiResult<T[]>;
export function useTableLoader<T>(loader: () => Promise<TableLoaderResult<T>>): { rows: Ref<T[]>; loading: Ref<boolean>; error: Ref<string | null>; apiError: Ref<ApiError | null>; diagnostic: Ref<ErrorDiagnostic | null>; empty: ComputedRef<boolean>; reload: () => Promise<void> } {
const rows = ref([]) as Ref<T[]>;
const loading = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const empty = computed(() => !loading.value && rows.value.length === 0);
async function reload(): Promise<void> {
loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
try {
rows.value = await loader();
const result = await loader();
if (isApiResult(result)) {
if (!result.ok) throw result;
rows.value = result.data ?? [];
} else {
rows.value = result;
}
} catch (err) {
error.value = err instanceof Error ? err.message : String(err);
const context = apiErrorContextFromUnknown(err);
error.value = context.error;
apiError.value = context.apiError;
diagnostic.value = context.diagnostic;
} finally {
loading.value = false;
}
}
return { rows, loading, error, empty, reload };
return { rows, loading, error, apiError, diagnostic, empty, reload };
}
function isApiResult<T>(value: TableLoaderResult<T>): value is ApiResult<T[]> {
return Boolean(value && !Array.isArray(value) && typeof value === "object" && "ok" in value && "status" in value);
}
@@ -288,6 +288,75 @@
color: #b91c1c;
}
.api-error-diagnostic {
display: grid;
width: 100%;
gap: 10px;
border-left: 3px solid #b91c1c;
background: #f8fafc;
padding: 10px 12px;
text-align: left;
}
.empty-state .api-error-diagnostic {
max-width: min(760px, 100%);
margin: 4px auto 0;
}
.api-error-diagnostic header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.api-error-diagnostic strong {
display: block;
color: #991b1b;
font-size: 13px;
}
.api-error-diagnostic p {
margin: 2px 0 0;
color: #334155;
font-size: 13px;
line-height: 1.5;
}
.api-error-diagnostic-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin: 0;
}
.api-error-diagnostic-grid div {
min-width: 0;
}
.api-error-diagnostic-grid dt,
.api-error-diagnostic-grid dd {
min-width: 0;
margin: 0;
}
.api-error-diagnostic-grid dt {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.api-error-diagnostic-grid dd {
overflow-wrap: anywhere;
color: #0f172a;
font-size: 12px;
line-height: 1.45;
}
.api-error-diagnostic-grid code {
white-space: normal;
}
.workbench-diagnostics-toggle {
display: inline-flex;
min-height: 32px;
+36
View File
@@ -49,6 +49,42 @@ export interface ApiResult<T> {
status: number;
data: T | null;
error: string | null;
apiError?: ApiError | null;
diagnostic?: ErrorDiagnostic | null;
}
export interface ErrorDiagnostic {
contractVersion?: string | null;
traceId?: string | null;
requestId?: string | null;
serviceId?: string | null;
route?: string | null;
layer?: string | null;
category?: string | null;
code?: string | number | null;
httpStatus?: number | null;
source?: "server" | "browser" | string | null;
observedAt?: string | null;
retryable?: boolean | null;
valuesPrinted?: boolean;
[key: string]: unknown;
}
export interface ApiError {
message: string;
userMessage?: string | null;
code?: string | number | null;
reason?: string | null;
retryable?: boolean | null;
layer?: string | null;
category?: string | null;
route?: string | null;
traceId?: string | null;
requestId?: string | null;
source?: "server" | "browser" | string | null;
diagnostic?: ErrorDiagnostic | null;
valuesPrinted?: boolean;
[key: string]: unknown;
}
export interface LiveProbePayload {
+2 -2
View File
@@ -30,7 +30,7 @@ const pageSize = 8;
const table = useTableLoader<GateDiagnosticRow>(async () => {
const response = await systemAPI.gateDiagnostics();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
payload.value = response.data;
return response.data?.rows ?? [];
});
@@ -85,7 +85,7 @@ function displayDate(value?: string): string {
<PageHeader eyebrow="System" title="内部复核" description="Gate diagnostics 直接读取 /v1/diagnostics/gate 的 live 聚合结果,用表格、筛选和 drill-down 展示失败归因。" />
<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" />
<EmptyState title="Gate diagnostics 加载失败" :description="table.error.value" :api-error="table.apiError.value" :diagnostic="table.diagnostic.value" action-label="重试" @action="table.reload" />
</section>
<template v-else>
<section class="system-summary-grid" aria-label="Gate live aggregation">
@@ -43,7 +43,7 @@ const payload = ref<WebPerformanceSummaryResponse | null>(null);
const currentWindow = ref<WebPerformanceWindow>("15m");
const table = useTableLoader<WebPerformanceRow>(async () => {
const response = await systemAPI.performanceSummary(currentWindow.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
payload.value = response.data;
return response.data?.rows ?? [];
});
@@ -486,7 +486,7 @@ function diagnosticToneClass(diagnostic: WebPerformanceCollectionHealthDiagnosti
</section>
<LoadingState v-if="table.loading.value && !table.rows.value.length" />
<section v-else-if="table.error.value" class="data-panel">
<EmptyState title="性能监控加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<EmptyState title="性能监控加载失败" :description="table.error.value" :api-error="table.apiError.value" :diagnostic="table.diagnostic.value" action-label="重试" @action="table.reload" />
</section>
<template v-else>
<section class="performance-health-strip" aria-label="性能概览">
+22 -7
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { systemAPI, type SkillUploadFileInput } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
@@ -11,7 +12,7 @@ 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";
import type { ApiError, ErrorDiagnostic, SkillFilePayload, SkillRecord, SkillTreeEntry, SkillsResponse } from "@/types";
const columns: DataTableColumn[] = [
{ key: "name", label: "Skill" },
@@ -26,8 +27,12 @@ const payload = ref<SkillsResponse | null>(null);
const selectedSkill = ref<SkillRecord | null>(null);
const tree = ref<SkillTreeEntry[]>([]);
const treeError = ref<string | null>(null);
const treeApiError = ref<ApiError | null>(null);
const treeDiagnostic = ref<ErrorDiagnostic | null>(null);
const filePreview = ref<SkillFilePayload | null>(null);
const fileError = ref<string | null>(null);
const fileApiError = ref<ApiError | null>(null);
const fileDiagnostic = ref<ErrorDiagnostic | null>(null);
const uploadOpen = ref(false);
const uploadName = ref("");
const uploadFiles = ref<SkillUploadFileInput[]>([]);
@@ -37,7 +42,7 @@ const pageSize = 10;
const table = useTableLoader<SkillRecord>(async () => {
const response = await systemAPI.skills();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
payload.value = response.data;
return response.data?.skills ?? [];
});
@@ -61,10 +66,16 @@ async function inspectSkill(row: SkillRecord): Promise<void> {
tree.value = [];
filePreview.value = null;
treeError.value = null;
treeApiError.value = null;
treeDiagnostic.value = null;
fileError.value = null;
fileApiError.value = null;
fileDiagnostic.value = null;
const treeResponse = await systemAPI.skillTree(row.id);
if (!treeResponse.ok) {
treeError.value = treeResponse.error ?? `HTTP ${treeResponse.status}`;
treeApiError.value = treeResponse.apiError ?? null;
treeDiagnostic.value = treeResponse.diagnostic ?? null;
return;
}
tree.value = treeResponse.data?.tree ?? [];
@@ -73,10 +84,14 @@ async function inspectSkill(row: SkillRecord): Promise<void> {
async function previewFile(row: SkillRecord, path = "SKILL.md"): Promise<void> {
fileError.value = null;
fileApiError.value = null;
fileDiagnostic.value = null;
const response = await systemAPI.skillFile(row.id, path);
if (!response.ok) {
filePreview.value = null;
fileError.value = response.error ?? `HTTP ${response.status}`;
fileApiError.value = response.apiError ?? null;
fileDiagnostic.value = response.diagnostic ?? null;
return;
}
filePreview.value = response.data?.file ?? { path, content: response.data?.content ?? "" };
@@ -94,7 +109,7 @@ async function onUploadInput(event: Event): Promise<void> {
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}`);
if (!response.ok) throw response;
uploadOpen.value = false;
uploadName.value = "";
uploadFiles.value = [];
@@ -126,7 +141,7 @@ function formatBytes(value?: number): string {
<PageHeader eyebrow="System" title="Skills" description="技能包管理直接使用 /v1/skills、upload、tree 和 file preview 接口,展示 AgentRun assembly 状态。" />
<LoadingState v-if="table.loading.value && !rows.length" />
<section v-else-if="table.error.value" class="data-panel">
<EmptyState title="Skills 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<EmptyState title="Skills 加载失败" :description="table.error.value" :api-error="table.apiError.value" :diagnostic="table.diagnostic.value" action-label="重试" @action="table.reload" />
</section>
<template v-else>
<section class="system-summary-grid" aria-label="Skills summary">
@@ -157,7 +172,7 @@ function formatBytes(value?: number): string {
<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>
<ApiErrorDiagnostic v-if="treeError" :error="treeError" :api-error="treeApiError" :diagnostic="treeDiagnostic" compact />
<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">
@@ -169,7 +184,7 @@ function formatBytes(value?: number): string {
</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>
<ApiErrorDiagnostic v-if="fileError" :error="fileError" :api-error="fileApiError" :diagnostic="fileDiagnostic" compact />
<pre v-else class="mini-log">{{ filePreview?.content || '等待选择 skill 或文件。' }}</pre>
</section>
</section>
@@ -180,7 +195,7 @@ function formatBytes(value?: number): string {
<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>
<ApiErrorDiagnostic v-if="uploadForm.error.value" :error="uploadForm.error.value" :api-error="uploadForm.apiError.value" :diagnostic="uploadForm.diagnostic.value" compact />
<p v-if="uploadForm.notice.value" class="form-notice">{{ uploadForm.notice.value }}</p>
</form>
<template #footer>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { providerProfilesAPI } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import BaseDialog from "@/components/common/BaseDialog.vue";
import ConfirmDialog from "@/components/common/ConfirmDialog.vue";
import DataTable from "@/components/common/DataTable.vue";
@@ -13,7 +14,7 @@ import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { useForm } from "@/composables/useForm";
import { useTableLoader } from "@/composables/useTableLoader";
import { mergeProviderRow, normalizeProviderConfig, normalizeProviderProfilePayload, normalizeProviderProfiles, normalizeProviderValidation, orderProviderProfiles, validationComplete, type ProviderConfigView, type ProviderProfileRow, type ProviderValidationView } from "@/stores/provider-profiles-view";
import type { ProviderProfile } from "@/types";
import type { ApiError, ErrorDiagnostic, ProviderProfile } from "@/types";
const columns: DataTableColumn[] = [
{ key: "profile", label: "Profile" },
@@ -35,6 +36,8 @@ const configText = ref("");
const configInfo = ref<ProviderConfigView | null>(null);
const validation = ref<ProviderValidationView | null>(null);
const validationError = ref<string | null>(null);
const validationApiError = ref<ApiError | null>(null);
const validationDiagnostic = ref<ErrorDiagnostic | null>(null);
const validatingProfile = ref<string | null>(null);
const credentialForm = useForm();
const configForm = useForm();
@@ -49,7 +52,7 @@ onMounted(() => void table.reload());
async function loadProfiles(): Promise<ProviderProfileRow[]> {
const response = await providerProfilesAPI.list();
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
return normalizeProviderProfiles(response.data);
}
@@ -74,7 +77,7 @@ async function saveCredential(): Promise<void> {
if (!row || !apiKeyInput.value.trim()) return;
const ok = await credentialForm.submit(async () => {
const response = await providerProfilesAPI.setKey(row.profile, apiKeyInput.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
table.rows.value = mergeProviderRow(table.rows.value, normalizeProviderProfilePayload(response.data));
}, "Credential updated");
if (ok) {
@@ -87,11 +90,15 @@ async function openConfig(row: ProviderProfileRow): Promise<void> {
selectedProfile.value = row;
configDialogOpen.value = true;
configForm.error.value = null;
configForm.apiError.value = null;
configForm.diagnostic.value = null;
configInfo.value = null;
configText.value = "";
const response = await providerProfilesAPI.config(row.profile);
if (!response.ok) {
configForm.error.value = response.error ?? `HTTP ${response.status}`;
configForm.apiError.value = response.apiError ?? null;
configForm.diagnostic.value = response.diagnostic ?? null;
return;
}
const config = normalizeProviderConfig(response.data, row.profile);
@@ -104,7 +111,7 @@ async function saveConfig(): Promise<void> {
if (!row) return;
const ok = await configForm.submit(async () => {
const response = await providerProfilesAPI.setConfig(row.profile, configText.value);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
table.rows.value = mergeProviderRow(table.rows.value, normalizeProviderProfilePayload(response.data));
}, "Config updated");
if (ok) configDialogOpen.value = false;
@@ -115,7 +122,7 @@ async function removeProfile(): Promise<void> {
if (!row) return;
const ok = await removeForm.submit(async () => {
const response = await providerProfilesAPI.remove(row.profile);
if (!response.ok) throw new Error(response.error ?? `HTTP ${response.status}`);
if (!response.ok) throw response;
table.rows.value = table.rows.value.filter((item) => item.profile !== row.profile);
}, "Profile removed");
if (ok) {
@@ -127,10 +134,14 @@ async function removeProfile(): Promise<void> {
async function validateProfile(row: ProviderProfileRow): Promise<void> {
validatingProfile.value = row.profile;
validationError.value = null;
validationApiError.value = null;
validationDiagnostic.value = null;
validation.value = null;
const submit = await providerProfilesAPI.validate(row.profile);
if (!submit.ok) {
validationError.value = submit.error ?? `HTTP ${submit.status}`;
validationApiError.value = submit.apiError ?? null;
validationDiagnostic.value = submit.diagnostic ?? null;
validatingProfile.value = null;
return;
}
@@ -142,6 +153,8 @@ async function validateProfile(row: ProviderProfileRow): Promise<void> {
const poll = await providerProfilesAPI.validation(row.profile, validationId);
if (!poll.ok) {
validationError.value = poll.error ?? `HTTP ${poll.status}`;
validationApiError.value = poll.apiError ?? null;
validationDiagnostic.value = poll.diagnostic ?? null;
break;
}
current = normalizeProviderValidation(poll.data, row.profile);
@@ -168,7 +181,7 @@ function displayDate(value: string): string {
<PageHeader eyebrow="Admin" title="Provider Profiles" description="Provider credential/config 由 Cloud API 鉴权后委托 AgentRun;页面只显示 SecretRef、fingerprint 和 validation 摘要。" />
<LoadingState v-if="table.loading.value && !rows.length" />
<section v-else-if="table.error.value" class="data-panel admin-crud-panel">
<EmptyState title="Provider Profiles 加载失败" :description="table.error.value" action-label="重试" @action="table.reload" />
<EmptyState title="Provider Profiles 加载失败" :description="table.error.value" :api-error="table.apiError.value" :diagnostic="table.diagnostic.value" action-label="重试" @action="table.reload" />
<p class="muted-box">当前错误会保留为委托链路状态不再用 raw JSON dump 作为页面主体</p>
</section>
<TablePageLayout v-else title="Profiles" subtitle="AgentRun provider profile registry">
@@ -217,7 +230,7 @@ function displayDate(value: string): string {
<h2>Validation</h2>
<span class="status-pill" :data-status="validation?.status || (validationError ? 'failed' : 'pending')">{{ validation?.status || (validationError ? "failed" : "idle") }}</span>
</header>
<p v-if="validationError" class="form-error">{{ validationError }}</p>
<ApiErrorDiagnostic v-if="validationError" :error="validationError" :api-error="validationApiError" :diagnostic="validationDiagnostic" compact />
<dl v-if="validation" class="field-grid">
<div><dt>validationId</dt><dd>{{ validation.validationId || "-" }}</dd></div>
<div><dt>runId</dt><dd>{{ validation.runId || "-" }}</dd></div>
@@ -235,7 +248,7 @@ function displayDate(value: string): string {
<span>API key</span>
<input id="provider-api-key-input" v-model="apiKeyInput" type="password" autocomplete="off" placeholder="输入后只用于本次提交">
</label>
<p v-if="credentialForm.error.value" class="form-error">{{ credentialForm.error.value }}</p>
<ApiErrorDiagnostic v-if="credentialForm.error.value" :error="credentialForm.error.value" :api-error="credentialForm.apiError.value" :diagnostic="credentialForm.diagnostic.value" compact />
<p v-if="credentialForm.notice.value" class="form-notice">{{ credentialForm.notice.value }}</p>
</form>
<template #footer>
@@ -251,7 +264,7 @@ function displayDate(value: string): string {
<textarea id="provider-config-toml" v-model="configText" rows="14" spellcheck="false" />
</label>
<p v-if="configInfo" class="muted-box">config fingerprint: {{ configInfo.configHashSuffix || "-" }} / valuesPrinted={{ configInfo.valuesPrinted ? "true" : "false" }}</p>
<p v-if="configForm.error.value" class="form-error">{{ configForm.error.value }}</p>
<ApiErrorDiagnostic v-if="configForm.error.value" :error="configForm.error.value" :api-error="configForm.apiError.value" :diagnostic="configForm.diagnostic.value" compact />
<p v-if="configForm.notice.value" class="form-notice">{{ configForm.notice.value }}</p>
</form>
<template #footer>
@@ -261,6 +274,6 @@ function displayDate(value: string): string {
</BaseDialog>
<ConfirmDialog :open="removeDialogOpen" title="Remove provider profile" :message="`删除 ${selectedProfile?.profile || 'profile'} 的管理配置;内建能力仍由后端决定是否保留。`" confirm-label="删除" :pending="removeForm.loading.value" @close="removeDialogOpen = false" @confirm="removeProfile" />
<p v-if="removeForm.error.value" class="form-error admin-action-error">{{ removeForm.error.value }}</p>
<ApiErrorDiagnostic v-if="removeForm.error.value" class="admin-action-error" :error="removeForm.error.value" :api-error="removeForm.apiError.value" :diagnostic="removeForm.diagnostic.value" compact />
</section>
</template>
@@ -145,6 +145,15 @@ test.describe("performance dashboard summary error", () => {
await expect(page.getByRole("heading", { name: "性能监控" })).toBeVisible();
await expect(page.getByText("性能监控加载失败")).toBeVisible();
await expect(page.getByText("暂时无法连接上游。")).toBeVisible();
const diagnostic = page.locator(".api-error-diagnostic");
await expect(diagnostic).toContainText("trace_id");
await expect(diagnostic).toContainText("11111111111111111111111111111111");
await expect(diagnostic).toContainText("requestId");
await expect(diagnostic).toContainText("req_e2e_api_error_diagnostic");
await expect(diagnostic).toContainText("upstream_unavailable");
await expect(diagnostic).toContainText("/v1/web-performance/summary");
await expect(diagnostic).toContainText("valuesPrinted");
await expect(diagnostic).toContainText("false");
await expect(page.getByRole("button", { name: "重试" })).toBeVisible();
await expect(page.locator("body")).not.toContainText(/Code Agent||/u);
await expect(page.locator(".performance-contract-item")).toHaveCount(0);