From 98b94b7fd1512aea0950ba191889d6f677e865b2 Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 15:38:11 +0800 Subject: [PATCH] feat(web): surface api error diagnostics --- .../scripts/workbench-e2e-server.ts | 27 +- web/hwlab-cloud-web/src/api/client.ts | 246 +++++++++++++++++- .../components/common/ApiErrorDiagnostic.vue | 87 +++++++ .../src/components/common/EmptyState.vue | 6 +- .../src/composables/useForm.ts | 15 +- .../src/composables/useTableLoader.ts | 29 ++- web/hwlab-cloud-web/src/styles/workbench.css | 69 +++++ web/hwlab-cloud-web/src/types/index.ts | 36 +++ web/hwlab-cloud-web/src/views/GateView.vue | 4 +- .../src/views/PerformanceView.vue | 4 +- web/hwlab-cloud-web/src/views/SkillsView.vue | 29 ++- .../src/views/admin/ProviderProfilesView.vue | 33 ++- .../specs/admin-hwpod-performance.spec.ts | 9 + 13 files changed, 555 insertions(+), 39 deletions(-) create mode 100644 web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue diff --git a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts index 7c539ac5..824a3806 100644 --- a/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts +++ b/web/hwlab-cloud-web/scripts/workbench-e2e-server.ts @@ -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 { } } -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 = {}): void { + response.writeHead(status, { "content-type": "application/json; charset=utf-8", ...headers }); response.end(JSON.stringify(body)); } diff --git a/web/hwlab-cloud-web/src/api/client.ts b/web/hwlab-cloud-web/src/api/client.ts index 1d41deca..cf3a74d6 100644 --- a/web/hwlab-cloud-web/src/api/client.ts +++ b/web/hwlab-cloud-web/src/api/client.ts @@ -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(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(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)}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})` }; + return browserFailure(path, options, `${options.timeoutName ?? path} 超过 ${timeoutMs}ms 无新活动(idle ${Math.round(detail.idleMs / 1000)}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})`, "browser_timeout", "timeout"); } - return { ok: false, status: 0, data: null, error: `${options.timeoutName ?? path} 超时` }; + return browserFailure(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(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(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(path: string, options: ApiRequestOptions, message: string, code: string, category: string): ApiResult { + 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 { + return isRecord(value) && typeof value.ok === "boolean" && typeof value.status === "number" && "data" in value && "error" in value; +} + +function isRecord(value: unknown): value is Record { + 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; const nested = record.error && typeof record.error === "object" ? record.error as Record : 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; diff --git a/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue new file mode 100644 index 00000000..03ed0091 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/common/ApiErrorDiagnostic.vue @@ -0,0 +1,87 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/common/EmptyState.vue b/web/hwlab-cloud-web/src/components/common/EmptyState.vue index 3a52b4f5..28667557 100644 --- a/web/hwlab-cloud-web/src/components/common/EmptyState.vue +++ b/web/hwlab-cloud-web/src/components/common/EmptyState.vue @@ -1,5 +1,8 @@ @@ -8,6 +11,7 @@ defineEmits<{ action: [] }>();
HW

{{ title }}

{{ description }}

+ diff --git a/web/hwlab-cloud-web/src/composables/useForm.ts b/web/hwlab-cloud-web/src/composables/useForm.ts index d6f1ffa7..2334cf98 100644 --- a/web/hwlab-cloud-web/src/composables/useForm.ts +++ b/web/hwlab-cloud-web/src/composables/useForm.ts @@ -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; @@ -21,6 +25,8 @@ export function messageFromError(err: unknown): string { export function useForm(options: UseFormOptions = {}) { const loading = ref(false); const error = ref(null); + const apiError = ref(null); + const diagnostic = ref(null); const notice = ref(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 }; } diff --git a/web/hwlab-cloud-web/src/composables/useTableLoader.ts b/web/hwlab-cloud-web/src/composables/useTableLoader.ts index da8b3e1f..338a240f 100644 --- a/web/hwlab-cloud-web/src/composables/useTableLoader.ts +++ b/web/hwlab-cloud-web/src/composables/useTableLoader.ts @@ -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(loader: () => Promise): { rows: Ref; loading: Ref; error: Ref; empty: ComputedRef; reload: () => Promise } { +type TableLoaderResult = T[] | ApiResult; + +export function useTableLoader(loader: () => Promise>): { rows: Ref; loading: Ref; error: Ref; apiError: Ref; diagnostic: Ref; empty: ComputedRef; reload: () => Promise } { const rows = ref([]) as Ref; const loading = ref(false); const error = ref(null); + const apiError = ref(null); + const diagnostic = ref(null); const empty = computed(() => !loading.value && rows.value.length === 0); async function reload(): Promise { 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(value: TableLoaderResult): value is ApiResult { + return Boolean(value && !Array.isArray(value) && typeof value === "object" && "ok" in value && "status" in value); } diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 7d7a5505..59c6481f 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 1676cf9a..f9b66598 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -49,6 +49,42 @@ export interface ApiResult { 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 { diff --git a/web/hwlab-cloud-web/src/views/GateView.vue b/web/hwlab-cloud-web/src/views/GateView.vue index 63cccc37..67ea9394 100644 --- a/web/hwlab-cloud-web/src/views/GateView.vue +++ b/web/hwlab-cloud-web/src/views/GateView.vue @@ -30,7 +30,7 @@ const pageSize = 8; const table = useTableLoader(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 {
- +