修复客户端错误报告采集与后台交互
统一错误报告打开事件常量并补充快捷入口断言 修复后台详情竞态、下载兼容性、状态选项和错误提示 加强客户端脱敏、指纹降级并过滤预期鉴权错误
This commit is contained in:
@@ -23,6 +23,8 @@ import type {
|
||||
AdminEditorShowcaseListQuery,
|
||||
AdminEditorShowcaseListResponse,
|
||||
AdminEditorShowcaseReviewRequest,
|
||||
AdminErrorReportDetail,
|
||||
AdminErrorReportListResponse,
|
||||
AdminFeatureGateConfigResponse,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
@@ -280,14 +282,14 @@ export function listAdminErrorReports(
|
||||
limit?: number;
|
||||
} = {},
|
||||
) {
|
||||
return request<import('./adminApiTypes').AdminErrorReportListResponse>(
|
||||
return request<AdminErrorReportListResponse>(
|
||||
`/admin/api/error-reports${buildErrorReportQueryString(query)}`,
|
||||
{ token },
|
||||
);
|
||||
}
|
||||
|
||||
export function getAdminErrorReport(token: string, batchId: string) {
|
||||
return request<import('./adminApiTypes').AdminErrorReportDetail>(
|
||||
return request<AdminErrorReportDetail>(
|
||||
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
|
||||
{ token },
|
||||
);
|
||||
@@ -298,7 +300,7 @@ export function updateAdminErrorReport(
|
||||
batchId: string,
|
||||
payload: { status: string; note?: string },
|
||||
) {
|
||||
return request<import('./adminApiTypes').AdminErrorReportDetail>(
|
||||
return request<AdminErrorReportDetail>(
|
||||
`/admin/api/error-reports/${encodeURIComponent(batchId)}`,
|
||||
{ method: 'PATCH', token, body: payload },
|
||||
);
|
||||
|
||||
@@ -112,7 +112,6 @@ export interface AdminErrorReportEntry {
|
||||
ossObjectKey?: string;
|
||||
archiveSha256?: string;
|
||||
uploadStatus?: 'uploading' | 'ready' | 'failed';
|
||||
reviewStatus?: 'new' | 'in-progress' | 'resolved';
|
||||
}
|
||||
|
||||
export interface AdminErrorReportListResponse {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
downloadAdminErrorReport,
|
||||
@@ -15,14 +15,18 @@ import type {
|
||||
|
||||
type Props = { token: string; onUnauthorized: (message?: string) => void };
|
||||
|
||||
const ADMIN_ERROR_REPORT_STATUSES = ['new', 'in-progress', 'resolved'] as const;
|
||||
|
||||
export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
const [reports, setReports] = useState<AdminErrorReportEntry[]>([]);
|
||||
const [selected, setSelected] = useState<AdminErrorReportDetail | null>(null);
|
||||
const [status, setStatus] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const openReportRequestId = useRef(0);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus('');
|
||||
try {
|
||||
const response = await listAdminErrorReports(token, {
|
||||
status: filterStatus || undefined,
|
||||
@@ -40,9 +44,13 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
}, [load]);
|
||||
|
||||
async function openReport(batchId: string) {
|
||||
const requestId = ++openReportRequestId.current;
|
||||
setStatus('');
|
||||
try {
|
||||
setSelected(await getAdminErrorReport(token, batchId));
|
||||
const detail = await getAdminErrorReport(token, batchId);
|
||||
if (requestId === openReportRequestId.current) setSelected(detail);
|
||||
} catch (error) {
|
||||
if (requestId !== openReportRequestId.current) return;
|
||||
if (isAdminApiError(error) && error.status === 401)
|
||||
return onUnauthorized();
|
||||
setStatus(formatAdminApiError(error));
|
||||
@@ -52,6 +60,7 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
async function saveStatus(nextStatus: string) {
|
||||
if (!selected || busy) return;
|
||||
setBusy(true);
|
||||
setStatus('');
|
||||
try {
|
||||
setSelected(
|
||||
await updateAdminErrorReport(token, selected.batchId, {
|
||||
@@ -69,13 +78,16 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
}
|
||||
|
||||
async function download(batchId: string) {
|
||||
setStatus('');
|
||||
try {
|
||||
const blob = await downloadAdminErrorReport(token, batchId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${batchId}.zip`;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
if (isAdminApiError(error) && error.status === 401) onUnauthorized();
|
||||
@@ -97,13 +109,19 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
onChange={(event) => setFilterStatus(event.target.value)}
|
||||
>
|
||||
<option value="">全部</option>
|
||||
<option value="new">new</option>
|
||||
<option value="in-progress">in-progress</option>
|
||||
<option value="resolved">resolved</option>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{status ? <p className="admin-error-message">{status}</p> : null}
|
||||
{status ? (
|
||||
<p className="admin-alert" role="status">
|
||||
{status}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
@@ -160,9 +178,11 @@ export function AdminErrorReportsPage({ token, onUnauthorized }: Props) {
|
||||
onChange={(event) => void saveStatus(event.target.value)}
|
||||
disabled={busy}
|
||||
>
|
||||
<option value="new">new</option>
|
||||
<option value="in-progress">in-progress</option>
|
||||
<option value="resolved">resolved</option>
|
||||
{ADMIN_ERROR_REPORT_STATUSES.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{value}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import {
|
||||
captureClientError,
|
||||
installWebviewLogBridge,
|
||||
OPEN_ERROR_REPORT_EVENT,
|
||||
} from '../services/errorReporting';
|
||||
import {
|
||||
beginPlatformSessionClearTransition,
|
||||
@@ -156,9 +157,9 @@ export function AuthenticatedClient({
|
||||
});
|
||||
setErrorReportOpen(true);
|
||||
};
|
||||
window.addEventListener('agc-open-error-report', openReport);
|
||||
window.addEventListener(OPEN_ERROR_REPORT_EVENT, openReport);
|
||||
return () =>
|
||||
window.removeEventListener('agc-open-error-report', openReport);
|
||||
window.removeEventListener(OPEN_ERROR_REPORT_EVENT, openReport);
|
||||
}, []);
|
||||
|
||||
function persistServerSelection() {
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AgentStatusCard,
|
||||
ChatMessage,
|
||||
} from '../../app/types';
|
||||
import { OPEN_ERROR_REPORT_EVENT } from '../../services/errorReporting';
|
||||
import {
|
||||
summarizeAgentReviewState,
|
||||
summarizeAgentRunBudget,
|
||||
@@ -514,7 +515,7 @@ export function handleProjectSummaryChatCommand({
|
||||
},
|
||||
]);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('agc-open-error-report'));
|
||||
window.dispatchEvent(new CustomEvent(OPEN_ERROR_REPORT_EVENT));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,15 @@ export async function requestClientApi<T>(
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
const shouldCapture = response.status >= 500 || response.status === 408;
|
||||
if (shouldCapture) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
}
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
@@ -138,12 +141,15 @@ export async function requestClientApiBytes(
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
const shouldCapture = response.status >= 500 || response.status === 408;
|
||||
if (shouldCapture) {
|
||||
void captureClientError(
|
||||
new ClientAuthRequestError(`HTTP ${response.status}`, {
|
||||
status: response.status,
|
||||
}),
|
||||
{ source: 'api', action: url.split('?')[0] },
|
||||
);
|
||||
}
|
||||
throw new ClientAuthRequestError(
|
||||
await readApiErrorMessage(response, fallbackMessage),
|
||||
{ status: response.status },
|
||||
|
||||
@@ -17,6 +17,8 @@ export type DiagnosticLogFile = { name: string; content: string };
|
||||
|
||||
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
|
||||
|
||||
export const OPEN_ERROR_REPORT_EVENT = 'agc-open-error-report';
|
||||
|
||||
const pending = new Map<string, ClientErrorEvent>();
|
||||
const listeners = new Set<() => void>();
|
||||
const MAX_EVENTS = 100;
|
||||
@@ -26,13 +28,24 @@ function notify() {
|
||||
}
|
||||
|
||||
function normalizeMessage(value: string) {
|
||||
return (
|
||||
normalizeDiagnosticText(value)
|
||||
.replace(/[\r\n]+/gu, ' ')
|
||||
// 保留 HTTP 状态码、短计数等有诊断价值的小数字;仅折叠较长的可识别数字。
|
||||
.replace(/\d{5,}/gu, '<n>')
|
||||
.trim()
|
||||
.slice(0, 512)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeDiagnosticText(value: string) {
|
||||
return value
|
||||
.replace(/authorization\s*:\s*\S+/giu, 'authorization: [REDACTED]')
|
||||
.replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]')
|
||||
.replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]')
|
||||
.replace(/https?:\/\/\S+/giu, '<url>')
|
||||
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
|
||||
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>')
|
||||
.replace(/\d{2,}/gu, '<n>')
|
||||
.trim()
|
||||
.slice(0, 512);
|
||||
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
|
||||
}
|
||||
|
||||
async function sha256(value: string) {
|
||||
@@ -54,13 +67,26 @@ export async function captureClientError(
|
||||
) {
|
||||
const errorValue = error instanceof Error ? error : new Error(String(error));
|
||||
const message = normalizeMessage(errorValue.message || '未知客户端错误');
|
||||
const stack = errorValue.stack?.slice(0, 8_000);
|
||||
const stack = errorValue.stack
|
||||
? normalizeDiagnosticText(errorValue.stack).slice(0, 8_000)
|
||||
: undefined;
|
||||
// 只用栈的首行参与指纹,避免同一错误因调用位置行号变化而无法合并。
|
||||
const fingerprint = await sha256(
|
||||
[context.source ?? 'client', message, stack?.split('\n')[0]]
|
||||
.filter(Boolean)
|
||||
.join('|'),
|
||||
);
|
||||
const fingerprintInput = [
|
||||
context.source ?? 'client',
|
||||
context.action,
|
||||
context.page,
|
||||
message,
|
||||
stack?.split('\n')[0],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('|');
|
||||
let fingerprint: string;
|
||||
try {
|
||||
fingerprint = await sha256(fingerprintInput);
|
||||
} catch {
|
||||
// 错误采集绝不能反过来制造 unhandledrejection;降级为稳定的可读键。
|
||||
fingerprint = fingerprintInput;
|
||||
}
|
||||
const existing = pending.get(fingerprint);
|
||||
const now = new Date().toISOString();
|
||||
if (existing) {
|
||||
|
||||
+9
-1
@@ -1,3 +1,4 @@
|
||||
import { OPEN_ERROR_REPORT_EVENT } from '../../../../src/services/errorReporting';
|
||||
import { expect, fireEvent, screen, submitChat, within } from '../../harness';
|
||||
import type { PreviewShortcutInvoke } from './invoke-mock';
|
||||
|
||||
@@ -647,10 +648,17 @@ export async function assertPlaytestAndReleaseShortcutFlow(
|
||||
([command]) => command === 'control_agent_run',
|
||||
).length,
|
||||
};
|
||||
let openedErrorReport = false;
|
||||
const onErrorReportOpen = () => {
|
||||
openedErrorReport = true;
|
||||
};
|
||||
window.addEventListener(OPEN_ERROR_REPORT_EVENT, onErrorReportOpen);
|
||||
submitChat('/bug-report');
|
||||
expect(
|
||||
await screen.findByText('已打开“报告问题”面板,请填写遇到的问题。'),
|
||||
).toBeTruthy();
|
||||
).not.toBeNull();
|
||||
expect(openedErrorReport).toBe(true);
|
||||
window.removeEventListener(OPEN_ERROR_REPORT_EVENT, onErrorReportOpen);
|
||||
expect(
|
||||
invoke.mock.calls.filter(
|
||||
([command]) => command === 'get_local_game_manifest',
|
||||
|
||||
Reference in New Issue
Block a user