style(usage): restyle request rows and include HTTP error details

Render each request as a rounded card without changing columns or fields. Prefix upstream error messages with their HTTP status when it is not already present.
This commit is contained in:
lzt404 committed 2026-10-05 16:10:37 +08:00
1 parent b1fe595f0b
commit ce3d54acd0
11 files changed
+184 -53

No files matched your search

+1
View File
@@ -1824,6 +1824,7 @@ export const jaOverrides = {
'management.notConfigured': '未設定',
'management.error.upstream': '上流リクエストに失敗しました',
'management.error.upstreamHttp': '上流が HTTP {status} を返しました',
'management.error.upstreamHttpDetail': 'HTTP {status}: {message}',
'model.error.invalidBaseUrl': 'Base URL が無効です。https://api.example.com のような URL を入力してください',
'model.error.unsupportedBaseUrl': 'Base URL は http:// または https:// のみ使用できます',
'model.error.baseUrlRequired': '先に Base URL を入力してください',
+1
View File
@@ -1824,6 +1824,7 @@ export const en: Record<MessageKey, string> = {
'management.notConfigured': 'Not configured',
'management.error.upstream': 'Upstream request failed',
'management.error.upstreamHttp': 'Upstream returned HTTP {status}',
'management.error.upstreamHttpDetail': 'HTTP {status}: {message}',
'model.error.invalidBaseUrl': 'Base URL is invalid. Enter a URL such as https://api.example.com',
'model.error.unsupportedBaseUrl': 'Base URL must use http:// or https://',
'model.error.baseUrlRequired': 'Enter a Base URL first',
+1
View File
@@ -1823,6 +1823,7 @@ export const zhCN = {
'management.notConfigured': '未配置',
'management.error.upstream': '上游请求失败',
'management.error.upstreamHttp': '上游返回 HTTP {status}',
'management.error.upstreamHttpDetail': 'HTTP {status}:{message}',
'model.error.invalidBaseUrl': 'Base URL 不是有效地址,请输入类似 https://api.example.com 的 URL',
'model.error.unsupportedBaseUrl': 'Base URL 只支持 http:// 或 https:// 地址',
'model.error.baseUrlRequired': '请先输入 Base URL',
+78 -29
View File
@@ -180,44 +180,93 @@ export function normalizeAuthIndex(value: unknown): string {
return typeof value === 'string' || typeof value === 'number' ? String(value).trim() : '';
}
const messageFromPayload = (value: unknown, depth = 0): string => {
if (value === null || value === undefined || depth > 3) return '';
if (typeof value === 'string') {
const text = value.trim();
if (!text) return '';
try {
const parsed = JSON.parse(text) as unknown;
const nested = messageFromPayload(parsed, depth + 1);
if (nested) return nested;
} catch {
}
return text;
}
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
if (Array.isArray(value)) {
for (const item of value) {
const nested = messageFromPayload(item, depth + 1);
if (nested) return nested;
}
return '';
}
if (isRecord(value)) {
for (const key of ['message', 'error', 'detail', 'error_description', 'title']) {
const nested = messageFromPayload(value[key], depth + 1);
if (nested) return nested;
type ApiErrorDetail = { message: string; code: string };
const emptyApiErrorDetail = (): ApiErrorDetail => ({ message: '', code: '' });
const mergeApiErrorDetail = (current: ApiErrorDetail, next: ApiErrorDetail): ApiErrorDetail => ({
message: current.message || next.message,
code: current.code || next.code,
});
const errorCodeFromRecord = (value: Record<string, unknown>): string => {
for (const key of ['code', 'error_code', 'errorCode']) {
const candidate = value[key];
if (typeof candidate === 'number' && Number.isFinite(candidate)) return String(Math.trunc(candidate));
if (typeof candidate === 'string') {
const text = candidate.trim();
if (text && text.length <= 64) return text;
}
}
return '';
};
const detailFromPayload = (value: unknown, depth = 0): ApiErrorDetail => {
if (value === null || value === undefined || depth > 3) return emptyApiErrorDetail();
if (typeof value === 'string') {
const text = value.trim();
if (!text) return emptyApiErrorDetail();
try {
const parsed = JSON.parse(text) as unknown;
const nested = detailFromPayload(parsed, depth + 1);
if (nested.message || nested.code) return nested;
} catch {
}
return { message: text, code: '' };
}
if (typeof value === 'number' || typeof value === 'boolean') return { message: String(value), code: '' };
if (Array.isArray(value)) {
let detail = emptyApiErrorDetail();
for (const item of value) {
detail = mergeApiErrorDetail(detail, detailFromPayload(item, depth + 1));
if (detail.message && detail.code) break;
}
return detail;
}
if (isRecord(value)) {
let detail: ApiErrorDetail = { message: '', code: errorCodeFromRecord(value) };
for (const key of ['message', 'error', 'detail', 'error_description', 'title']) {
detail = mergeApiErrorDetail(detail, detailFromPayload(value[key], depth + 1));
if (detail.message && detail.code) break;
}
return detail;
}
return emptyApiErrorDetail();
};
const httpStatusFromResponse = (response: Record<string, unknown>): number => {
const status = Number(response.status_code ?? response.statusCode ?? response.status ?? 0);
return Number.isFinite(status) && status > 0 ? Math.trunc(status) : 0;
};
const mentionsHttpStatus = (text: string, status: number): boolean => {
if (!status) return false;
if (text.trim() === String(status)) return true;
return new RegExp(`(?:\\bhttp\\s*|\\(|\\[|^|\\s)${status}(?:\\b|\\)|\\]|\\s|$)`, 'i').test(text);
};
export function apiCallErrorMessage(
response: Record<string, unknown>,
fallback = translate(getCurrentLocale(), 'management.error.upstream'),
): string {
const status = Number(response.status_code ?? response.statusCode ?? 0);
const message = messageFromPayload(response.body ?? response.bodyText);
if (message) return message;
const locale = getCurrentLocale();
const status = httpStatusFromResponse(response);
const detail = detailFromPayload(response.body ?? response.bodyText);
const message = status > 0 && detail.message.trim() === String(status) ? '' : detail.message;
const code = detail.code.trim();
const showCode = Boolean(
code
&& code !== String(status)
&& code.toLowerCase() !== message.toLowerCase()
&& !message.toLowerCase().includes(code.toLowerCase()),
);
const text = message ? (showCode ? `${message} (${code})` : message) : code;
const httpError = status > 0 && (status < 200 || status >= 300);
if (httpError && text && !mentionsHttpStatus(text, status)) {
return translate(locale, 'management.error.upstreamHttpDetail', { status, message: text });
}
if (text) return text;
return status > 0
? translate(getCurrentLocale(), 'management.error.upstreamHttp', { status })
? translate(locale, 'management.error.upstreamHttp', { status })
: fallback;
}
+79 -7
View File
@@ -122,15 +122,90 @@
padding: 0 20px 0 0;
}
/* The default event view is intentionally compact; secondary diagnostics remain available via tooltips and Columns. */
/* Request log reads as separate rounded cards, matching the reference layout. */
.usage-records-page .usage-request-log {
--usage-log-canvas: transparent;
gap: 10px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.usage-records-page .usage-request-log .usage-table-wrap,
.usage-records-page .usage-request-log .usage-table-top-scrollbar {
background: transparent;
}
.usage-records-page .usage-request-log .usage-events-table {
border-collapse: separate;
border-spacing: 0 10px;
margin-top: -10px;
}
.usage-records-page .usage-request-log .usage-events-table th,
.usage-records-page .usage-request-log .usage-events-table th .usage-th-content {
height: 38px;
height: 44px;
}
.usage-records-page .usage-request-log .usage-events-table th {
position: sticky;
top: 0;
border: 0;
background: #fff;
color: #8b97a8;
font-size: 12px;
font-weight: 650;
letter-spacing: .01em;
box-shadow: none;
}
.usage-records-page .usage-request-log .usage-events-table th .usage-th-content {
padding: 0 16px;
}
.usage-records-page .usage-request-log .usage-events-table thead th:first-child {
border-radius: 14px 0 0 14px;
}
.usage-records-page .usage-request-log .usage-events-table thead th:last-child {
border-radius: 0 14px 14px 0;
}
.usage-records-page .usage-request-log .usage-events-table td {
height: 60px;
padding: 7px 9px;
height: 78px;
padding: 14px 16px;
border: 0;
background: #fff;
box-shadow: 0 1px 2px rgb(16 24 40 / 4%);
}
.usage-records-page .usage-request-log .usage-events-table tbody td:first-child {
border-radius: 16px 0 0 16px;
}
.usage-records-page .usage-request-log .usage-events-table tbody td:last-child {
border-radius: 0 16px 16px 0;
}
.usage-records-page .usage-request-log .usage-events-table tbody tr:hover td {
background: #fff !important;
box-shadow: 0 8px 22px rgb(16 24 40 / 7%);
}
.usage-records-page .usage-request-log .usage-th-total,
.usage-records-page .usage-request-log .usage-td-total {
border-left: 0;
}
.usage-records-page .usage-request-log .usage-events-footer {
min-height: 48px;
margin-top: 2px;
padding: 8px 4px 2px;
border: 0;
border-radius: 0;
background: transparent;
}
.usage-records-page .usage-request-log .usage-events-table td strong {
@@ -151,7 +226,4 @@
display: none;
}
.usage-records-page .usage-request-log .usage-events-table tbody tr:nth-child(even) td {
background: color-mix(in srgb, var(--usage-log-surface) 97%, var(--ui-accent));
}
+2 -4
View File
@@ -129,9 +129,7 @@
:root:not([data-theme='dark']) .workspace :is(
.usage-records-page,
.usage-tab-panel,
.usage-filter-panel,
.usage-request-log,
.usage-events-footer
.usage-filter-panel
) {
background: #fff;
}
@@ -160,7 +158,7 @@
.usage-request-log .usage-events-table tbody tr:nth-child(even) td,
:root:not([data-theme='dark']) .workspace .usage-records-page
.usage-request-log .usage-events-table tbody tr:hover td {
background: #fff !important;
background: #fff;
}
:root:not([data-theme='dark']) .workspace .usage-records-page