Merge pull request #1732 from pikasTech/feat/1708-p3-api-error
feat(web): surface API error diagnostics
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}s;waitingFor=${detail.waitingFor ?? "unknown"};lastEventLabel=${detail.lastEventLabel ?? "none"})` };
|
||||
return browserFailure<T>(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<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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="性能概览">
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user