feat(client): export digital sync diagnostics
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-react';
|
||||
import { Clipboard, Clock3, Download, Eye, PlayCircle, RefreshCw } from 'lucide-react';
|
||||
import Avatar3D from '@/components/digital/Avatar3D';
|
||||
import { basePath } from '@/lib/basePath';
|
||||
import {
|
||||
@@ -551,6 +551,57 @@ function syncAttemptTone(status: string): 'success' | 'danger' | 'info' {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
function buildSyncFailureDiagnostic(
|
||||
failure: ManagementSyncFailure,
|
||||
status: ManagementSyncStatus | null,
|
||||
): string {
|
||||
return JSON.stringify(
|
||||
{
|
||||
generatedAt: new Date().toISOString(),
|
||||
source: 'qimingclaw-digital-employee',
|
||||
failure,
|
||||
sync: status
|
||||
? {
|
||||
running: status.running,
|
||||
syncing: status.syncing,
|
||||
enabled: status.enabled,
|
||||
endpoint: status.endpoint,
|
||||
missingCredentials: status.missingCredentials ?? [],
|
||||
pending: status.pending,
|
||||
failed: status.failed,
|
||||
lastSyncAt: status.lastSyncAt,
|
||||
lastSyncError: status.lastSyncError,
|
||||
failureSummary: status.failureSummary ?? null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function syncFailureDiagnosticFileName(failure: ManagementSyncFailure): string {
|
||||
const entity = [failure.entityType, failure.entityId]
|
||||
.filter(Boolean)
|
||||
.join('-')
|
||||
.replace(/[^a-z0-9._-]+/gi, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
return `qimingclaw-sync-failure-${entity || failure.id || 'diagnostic'}.json`;
|
||||
}
|
||||
|
||||
function downloadTextFile(filename: string, content: string): void {
|
||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
if (!bridge?.getSyncStatus) return null;
|
||||
@@ -1062,6 +1113,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [selectedDate]);
|
||||
|
||||
const copySyncFailureDiagnostic = useCallback(async (failure: ManagementSyncFailure) => {
|
||||
const diagnostic = buildSyncFailureDiagnostic(failure, managementSyncStatus);
|
||||
try {
|
||||
await navigator.clipboard.writeText(diagnostic);
|
||||
setActionError(null);
|
||||
setActionNotice('同步失败诊断已复制。');
|
||||
} catch (error) {
|
||||
setActionNotice(null);
|
||||
setActionError(error instanceof Error ? error.message : '复制同步失败诊断失败');
|
||||
}
|
||||
}, [managementSyncStatus]);
|
||||
|
||||
const exportSyncFailureDiagnostic = useCallback((failure: ManagementSyncFailure) => {
|
||||
downloadTextFile(
|
||||
syncFailureDiagnosticFileName(failure),
|
||||
buildSyncFailureDiagnostic(failure, managementSyncStatus),
|
||||
);
|
||||
setActionError(null);
|
||||
setActionNotice('同步失败诊断已导出。');
|
||||
}, [managementSyncStatus]);
|
||||
|
||||
const cancelAllTasks = useCallback(() => {
|
||||
setSelectedDutyIds(new Set());
|
||||
selectionDirtyRef.current = true;
|
||||
@@ -1926,6 +1998,24 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
onClick={() => {
|
||||
void copySyncFailureDiagnostic(selectedSyncFailure);
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} />
|
||||
复制诊断
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
onClick={() => exportSyncFailureDiagnostic(selectedSyncFailure)}
|
||||
>
|
||||
<Download size={14} />
|
||||
导出诊断
|
||||
</button>
|
||||
{selectedSyncFailure.managementRecordUrl && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -13,7 +13,7 @@
|
||||
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||
}
|
||||
</script>
|
||||
<script type="module" crossorigin src="./assets/index-gtzF6MAO.js"></script>
|
||||
<script type="module" crossorigin src="./assets/index-Dfb8OO8K.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -447,7 +447,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
||||
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
||||
- 同步状态通过 bridge 返回最近失败 outbox 明细,首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
|
||||
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
|
||||
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
||||
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可复制或导出诊断 JSON;也可触发手动 `flushSync` 立即重试。若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
||||
- qimingclaw 自动生成的管理端深链只携带 `entity_type`、`entity_id`、`outbox_id`,避免在 URL 中暴露客户端 Key;管理端列表仍保留客户端 Key 手动筛选能力。
|
||||
|
||||
新增 IPC / Bridge:
|
||||
|
||||
Reference in New Issue
Block a user