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:
1 parent
b1fe595f0b
commit
ce3d54acd0
11 files changed
+184
-53
No files matched your search
@@ -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 を入力してください',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -39,7 +39,7 @@ test('edge accounts expose exhausted, unknown, disabled and upstream-error state
|
||||
expect((await quota('antigravity-empty-quota.json')).status).toBe('error');
|
||||
expect((await quota('xai-paid-api.json')).rows[0].remainingPercent).toBeNull();
|
||||
expect((await quota('codex-disabled.json')).status).toBe('error');
|
||||
expect(await quota('claude-expired.json')).toMatchObject({ status: 'error', error: 'Mock OAuth token expired' });
|
||||
expect(await quota('claude-expired.json')).toMatchObject({ status: 'error', error: 'HTTP 401: Mock OAuth token expired' });
|
||||
});
|
||||
|
||||
test('Codex and Claude resets consume credits and refresh only that account', async () => {
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ describe('Devin live quota', () => {
|
||||
it('reports upstream errors and empty successful responses', async () => {
|
||||
const post = spyOn(managementApi, 'post').mockResolvedValue({ status_code: 401, body: { error: 'expired' } } as never);
|
||||
mocks.push(post);
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'expired' });
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [], error: 'HTTP 401: expired' });
|
||||
post.mockResolvedValue({ status_code: 200, body: liveStatus({ planInfo: { planName: 'Pro' } }) } as never);
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', rows: [] });
|
||||
});
|
||||
|
||||
@@ -118,19 +118,28 @@ describe('v8 Management API requests', () => {
|
||||
});
|
||||
|
||||
describe('apiCallErrorMessage', () => {
|
||||
it('从对象和 JSON 字符串错误体中提取可读消息', () => {
|
||||
it('保留 HTTP 状态,并从对象和 JSON 字符串错误体中提取可读消息', () => {
|
||||
expect(apiCallErrorMessage({
|
||||
status_code: 401,
|
||||
body: { error: { message: 'token expired' } },
|
||||
})).toBe('token expired');
|
||||
body: { error: { message: 'token expired', code: 'invalid_api_key' } },
|
||||
})).toBe('HTTP 401: token expired (invalid_api_key)');
|
||||
|
||||
expect(apiCallErrorMessage({
|
||||
status_code: 401,
|
||||
body: { error: { message: 'Encountered invalidated oauth token for user, failing request', code: 401 } },
|
||||
})).toBe('HTTP 401: Encountered invalidated oauth token for user, failing request');
|
||||
|
||||
expect(apiCallErrorMessage({
|
||||
statusCode: 403,
|
||||
bodyText: JSON.stringify({ detail: 'permission denied' }),
|
||||
})).toBe('permission denied');
|
||||
})).toBe('HTTP 403: permission denied');
|
||||
});
|
||||
|
||||
it('没有错误体时回退到 HTTP 状态', () => {
|
||||
it('消息已经包含状态码时不重复,没有错误体时回退到 HTTP 状态', () => {
|
||||
expect(apiCallErrorMessage({
|
||||
status_code: 401,
|
||||
body: { error: { message: 'HTTP 401: token expired' } },
|
||||
})).toBe('HTTP 401: token expired');
|
||||
expect(apiCallErrorMessage({ status_code: 429 })).toBe('Upstream returned HTTP 429');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +140,7 @@ describe('quota action confirmation with fully mocked IPC', () => {
|
||||
consumeError = true;
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('error');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'reset denied' },
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'HTTP 409: reset denied' },
|
||||
});
|
||||
expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true);
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled');
|
||||
@@ -154,7 +154,7 @@ describe('quota action confirmation with fully mocked IPC', () => {
|
||||
refreshError = true;
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('refresh-error');
|
||||
expect(getQuotaCacheSnapshot()[key]).toMatchObject({
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'usage unavailable' },
|
||||
rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'HTTP 503: usage unavailable' },
|
||||
});
|
||||
expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true);
|
||||
expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled');
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('quota API compatibility', () => {
|
||||
...codexFile,
|
||||
metadata: { id_token: { chatgpt_account_id: 'account-test', plan_type: 'pro', chatgpt_subscription_active_until: '2030-01-01T00:00:00Z' } },
|
||||
});
|
||||
expect(result).toMatchObject({ status: 'success', plan: 'pro', creditBalance: '12.5', creditsUnlimited: false, resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'credits forbidden' });
|
||||
expect(result).toMatchObject({ status: 'success', plan: 'pro', creditBalance: '12.5', creditsUnlimited: false, resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'HTTP 403: credits forbidden' });
|
||||
expect(result.subscriptionActiveUntil).toBe('2030-01-01T00:00:00Z');
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
@@ -168,7 +168,7 @@ describe('xAI quota queries aligned with Management Center', () => {
|
||||
it('账单和回退都失败时保留原始账单错误', async () => {
|
||||
handler = (request) => request.url.includes('cli-chat-proxy')
|
||||
? { status_code: 403, body: 'billing denied' } : { status_code: 429, body: 'paid denied' };
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', error: 'billing denied' });
|
||||
expect(await loadQuota(file)).toMatchObject({ status: 'error', error: 'HTTP 403: billing denied' });
|
||||
expect(calls).toHaveLength(4);
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('xAI quota queries aligned with Management Center', () => {
|
||||
handler = (request) => request.url.endsWith('/me')
|
||||
? success({}) : { status_code: 429, body: 'chat denied' };
|
||||
expect(await loadQuota({ ...file, using_api: true, prefix: 'paid' }))
|
||||
.toMatchObject({ status: 'error', error: 'chat denied' });
|
||||
.toMatchObject({ status: 'error', error: 'HTTP 429: chat denied' });
|
||||
expect(calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('quota request synchronization', () => {
|
||||
const reset = consumeCodexResetCredit(codexFile);
|
||||
const refresh = loadQuota(codexFile);
|
||||
await expect(reset).rejects.toThrow('reset denied');
|
||||
expect(await refresh).toMatchObject({ status: 'error', error: 'reset denied' });
|
||||
expect(await refresh).toMatchObject({ status: 'error', error: 'HTTP 409: reset denied' });
|
||||
expect((await loadQuota(codexFile)).status).toBe('success');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user