feat(client): export digital sync diagnostics
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } 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 Avatar3D from '@/components/digital/Avatar3D';
|
||||||
import { basePath } from '@/lib/basePath';
|
import { basePath } from '@/lib/basePath';
|
||||||
import {
|
import {
|
||||||
@@ -551,6 +551,57 @@ function syncAttemptTone(status: string): 'success' | 'danger' | 'info' {
|
|||||||
return '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> {
|
async function readManagementSyncStatus(): Promise<ManagementSyncStatus | null> {
|
||||||
const bridge = window.QimingClawBridge?.digital;
|
const bridge = window.QimingClawBridge?.digital;
|
||||||
if (!bridge?.getSyncStatus) return null;
|
if (!bridge?.getSyncStatus) return null;
|
||||||
@@ -1062,6 +1113,27 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
}
|
}
|
||||||
}, [selectedDate]);
|
}, [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(() => {
|
const cancelAllTasks = useCallback(() => {
|
||||||
setSelectedDutyIds(new Set());
|
setSelectedDutyIds(new Set());
|
||||||
selectionDirtyRef.current = true;
|
selectionDirtyRef.current = true;
|
||||||
@@ -1926,6 +1998,24 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
</button>
|
</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 && (
|
{selectedSyncFailure.managementRecordUrl && (
|
||||||
<button
|
<button
|
||||||
type="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);
|
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
|
||||||
}
|
}
|
||||||
</script>
|
</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">
|
<link rel="stylesheet" crossorigin href="./assets/index-ReBtI0Fy.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -447,7 +447,7 @@ crates/agent-electron-client/src/preload/webviewPerfBridge.ts
|
|||||||
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
- 数字员工首页已增加管理端同步状态条,展示待同步数量、失败数量、可重试/退避分布、失败实体类型分布、最近同步时间和最近失败原因摘要。
|
||||||
- 同步状态通过 bridge 返回最近失败 outbox 明细,首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
|
- 同步状态通过 bridge 返回最近失败 outbox 明细,首页可直接看到实体类型、outbox ID、操作类型、重试次数、失败时间和下次自动重试时间。
|
||||||
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
|
- 本地 SQLite 额外记录 `digital_sync_attempts`,每次 outbox 推送成功、失败或部分 ack 缺失都会留下一次尝试历史,默认保留最近 30 天。
|
||||||
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可触发手动 `flushSync` 立即重试;若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
- 点击失败 outbox 可打开详情弹窗,查看完整错误、实体 ID、退避状态、最近重试历史,并可复制或导出诊断 JSON;也可触发手动 `flushSync` 立即重试。若已配置管理端同步地址,详情弹窗可直接打开对应的管理端同步记录深链。
|
||||||
- qimingclaw 自动生成的管理端深链只携带 `entity_type`、`entity_id`、`outbox_id`,避免在 URL 中暴露客户端 Key;管理端列表仍保留客户端 Key 手动筛选能力。
|
- qimingclaw 自动生成的管理端深链只携带 `entity_type`、`entity_id`、`outbox_id`,避免在 URL 中暴露客户端 Key;管理端列表仍保留客户端 Key 手动筛选能力。
|
||||||
|
|
||||||
新增 IPC / Bridge:
|
新增 IPC / Bridge:
|
||||||
|
|||||||
Reference in New Issue
Block a user