返回 DeepSeek-Reasonix
DiagnosticsSettingsPage.tsx
根目录 / desktop / frontend / src / components / DiagnosticsSettingsPage.tsx
1 import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2 import { ChevronDown, ChevronRight, Clipboard, Loader2, RefreshCw } from "lucide-react";
3 import { app } from "../lib/bridge";
4 import { asArray } from "../lib/array";
5 import { useI18n, useT, type Locale } from "../lib/i18n";
6 import type { CapabilityDiagnosticsReport, CapabilityIssue, CredentialDiagnosticReport, RuntimeDoctorReport, SettingsTab } from "../lib/types";
7 import { FrontendDiagnosticsControl } from "./FrontendDiagnosticsControl";
8
9 const FRONTEND_COPY: Record<Locale, { title: string; hint: string }> = {
10 en: {
11 title: "Frontend interaction recording",
12 hint: "When scrolling jumps, sessions switch, or the UI flickers, turn this on, reproduce the issue, then turn it off and choose where to export. Only timing, events, and geometry are recorded; conversation content, input values, paths, and secrets are excluded.",
13 },
14 zh: {
15 title: "前端交互记录",
16 hint: "遇到滚动闪回、会话切换或界面抖动时,打开记录开关,复现问题后关闭并选择路径导出。仅记录时间、事件和几何信息,不记录对话内容、输入值、路径或密钥。",
17 },
18 "zh-TW": {
19 title: "前端互動記錄",
20 hint: "遇到捲動閃回、工作階段切換或介面抖動時,開啟記錄開關,重現問題後關閉並選擇路徑匯出。僅記錄時間、事件與幾何資訊,不記錄對話內容、輸入值、路徑或密鑰。",
21 },
22 };
23
24 const CREDENTIAL_COPY: Record<Locale, { title: string; probe: string; preview: string; repair: string }> = {
25 en: { title: "Credential diagnostics", probe: "Check write access", preview: "Preview repair", repair: "Repair credential access" },
26 zh: { title: "凭据诊断", probe: "检查写入能力", preview: "预览修复", repair: "修复凭据访问" },
27 "zh-TW": { title: "憑據診斷", probe: "檢查寫入能力", preview: "預覽修復", repair: "修復憑據存取" },
28 };
29
30 export function DiagnosticsSettingsPage({
31 onNavigate,
32 }: {
33 onNavigate?: (tab: SettingsTab) => void;
34 }) {
35 const t = useT();
36 const { locale } = useI18n();
37 const frontendCopy = FRONTEND_COPY[locale];
38 const credentialCopy = CREDENTIAL_COPY[locale];
39 const [report, setReport] = useState<CapabilityDiagnosticsReport | null>(null);
40 const [runtimeDoctor, setRuntimeDoctor] = useState<RuntimeDoctorReport | null>(null);
41 const [credentialReport, setCredentialReport] = useState<CredentialDiagnosticReport | null>(null);
42 const [credentialBusy, setCredentialBusy] = useState(false);
43 const [loading, setLoading] = useState(true);
44 const [error, setError] = useState<string | null>(null);
45 const [includeRuntime, setIncludeRuntime] = useState(false);
46 const [copied, setCopied] = useState(false);
47 const [open, setOpen] = useState<Record<string, boolean>>({
48 issues: true,
49 runtime: true,
50 instructions: false,
51 skills: false,
52 commands: false,
53 hooks: false,
54 plugins: false,
55 mcp: false,
56 });
57
58 const loadSeq = useRef(0);
59
60 const load = useCallback(async (runtime: boolean) => {
61 const seq = ++loadSeq.current;
62 setLoading(true);
63 setError(null);
64 try {
65 const next = normalizeDiagnosticsReport(await app.CapabilityDiagnostics(runtime));
66 let doctor: RuntimeDoctorReport | null = null;
67 try {
68 doctor = await app.RuntimeDoctor();
69 } catch {
70 doctor = null;
71 }
72 // Last-request-wins: ignore stale responses after rapid refresh/toggle.
73 if (seq !== loadSeq.current) return;
74 setReport(next);
75 setRuntimeDoctor(doctor);
76 try {
77 setCredentialReport(await app.CredentialDiagnostics(false));
78 } catch {
79 setCredentialReport(null);
80 }
81 } catch (err) {
82 if (seq !== loadSeq.current) return;
83 setError(err instanceof Error ? err.message : String(err));
84 setReport(null);
85 setRuntimeDoctor(null);
86 } finally {
87 if (seq === loadSeq.current) {
88 setLoading(false);
89 }
90 }
91 }, []);
92
93 useEffect(() => {
94 void load(includeRuntime);
95 }, [includeRuntime, load]);
96
97 const issuesBySeverity = useMemo(() => {
98 const groups: Record<string, CapabilityIssue[]> = { error: [], warning: [], info: [] };
99 for (const issue of report?.issues ?? []) {
100 const key = issue.severity === "error" || issue.severity === "warning" ? issue.severity : "info";
101 groups[key].push(issue);
102 }
103 return groups;
104 }, [report]);
105
106 const copyJSON = async () => {
107 if (!report) return;
108 try {
109 await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
110 setCopied(true);
111 window.setTimeout(() => setCopied(false), 1500);
112 } catch {
113 setError(t("diag.copyFailed"));
114 }
115 };
116
117 const toggle = (key: string) => setOpen((prev) => ({ ...prev, [key]: !prev[key] }));
118
119 const runCredentialAction = async (kind: "probe" | "preview" | "repair") => {
120 setCredentialBusy(true);
121 setError(null);
122 try {
123 setCredentialReport(kind === "probe" ? await app.CredentialDiagnostics(true) : await app.RepairCredentials(kind === "preview"));
124 } catch (err) {
125 setError(err instanceof Error ? err.message : String(err));
126 } finally {
127 setCredentialBusy(false);
128 }
129 };
130
131 const goSettings = (tab?: string) => {
132 if (!tab || !onNavigate) return;
133 const allowed: SettingsTab[] = ["mcp", "skills", "plugins", "hooks"];
134 if (allowed.includes(tab as SettingsTab)) {
135 onNavigate(tab as SettingsTab);
136 }
137 };
138
139 return (
140 <div className="diag-page">
141 <div className="diag-page__toolbar settings-toolbar">
142 <label className="diag-page__runtime">
143 <input
144 type="checkbox"
145 checked={includeRuntime}
146 onChange={(e) => setIncludeRuntime(e.target.checked)}
147 />
148 <span>{t("diag.includeRuntime")}</span>
149 </label>
150 <div className="diag-page__actions">
151 <button type="button" className="btn btn--ghost" onClick={() => void load(includeRuntime)} disabled={loading}>
152 {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
153 <span>{t("diag.refresh")}</span>
154 </button>
155 <button type="button" className="btn btn--ghost" onClick={() => void copyJSON()} disabled={!report}>
156 <Clipboard size={14} />
157 <span>{copied ? t("diag.copied") : t("diag.copyJson")}</span>
158 </button>
159 </div>
160 </div>
161
162 <p className="diag-page__hint">{t("diag.hint")}</p>
163
164 <section className="diag-section" data-testid="credential-diagnostics-settings">
165 <div className="diag-section__header">
166 <span>{credentialCopy.title}</span>
167 </div>
168 <div className="diag-section__body">
169 <p className="diag-path">{credentialReport?.credentialPath}</p>
170 {(credentialReport?.checks ?? []).map((check) => (
171 <div key={check.id} className={`diag-issue diag-issue--${check.status === "failed" ? "error" : "info"}`}>
172 <header><code>{check.id}</code><span>{check.status}</span></header>
173 {check.message && <p className="diag-issue__msg">{check.message}</p>}
174 </div>
175 ))}
176 {(credentialReport?.actions ?? []).map((action) => <p key={action} className="diag-issue__fix">{action}</p>)}
177 <div className="diag-page__actions">
178 <button type="button" className="btn btn--ghost" disabled={credentialBusy} onClick={() => void runCredentialAction("probe")}>{credentialCopy.probe}</button>
179 <button type="button" className="btn btn--ghost" disabled={credentialBusy} onClick={() => void runCredentialAction("preview")}>{credentialCopy.preview}</button>
180 <button type="button" className="btn btn--secondary" disabled={credentialBusy} onClick={() => void runCredentialAction("repair")}>{credentialCopy.repair}</button>
181 </div>
182 </div>
183 </section>
184
185 <section className="diag-section diag-section--frontend" data-testid="frontend-diagnostics-settings">
186 <div className="diag-section__body diag-section__body--frontend">
187 <div className="diag-frontend-recording__copy">
188 <strong>{frontendCopy.title}</strong>
189 <span>{frontendCopy.hint}</span>
190 </div>
191 <FrontendDiagnosticsControl embedded />
192 </div>
193 </section>
194
195 {loading && !report && <div className="empty">{t("settings.loading")}</div>}
196 {error && <div className="settings-error" role="alert">{error}</div>}
197
198 {report && (
199 <>
200 <div className="diag-summary">
201 <div className="diag-summary__item diag-summary__item--error">
202 <strong>{report.summary.errors}</strong>
203 <span>{t("diag.errors")}</span>
204 </div>
205 <div className="diag-summary__item diag-summary__item--warning">
206 <strong>{report.summary.warnings}</strong>
207 <span>{t("diag.warnings")}</span>
208 </div>
209 <div className="diag-summary__item diag-summary__item--info">
210 <strong>{report.summary.infos}</strong>
211 <span>{t("diag.infos")}</span>
212 </div>
213 <div className="diag-summary__meta">
214 <span className="diag-path">{report.root}</span>
215 <span>
216 {t("diag.counts", {
217 skills: report.summary.skills,
218 commands: report.summary.commands,
219 hooks: report.summary.hooks,
220 plugins: report.summary.plugins,
221 mcp: report.summary.mcp_servers,
222 })}
223 </span>
224 </div>
225 </div>
226
227 {runtimeDoctor && (
228 <section className="diag-section">
229 <button type="button" className="diag-section__header" onClick={() => toggle("runtime")}>
230 {open.runtime ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
231 <span>Extension runtime (v2)</span>
232 </button>
233 {open.runtime && (
234 <div className="diag-section__body">
235 <div className="diag-summary">
236 <div className="diag-summary__item">
237 <strong>{runtimeDoctor.publishedGeneration}</strong>
238 <span>generation</span>
239 </div>
240 <div className="diag-summary__item">
241 <strong>{runtimeDoctor.allowResume ? "yes" : "no"}</strong>
242 <span>allow resume</span>
243 </div>
244 <div className="diag-summary__item">
245 <strong>{runtimeDoctor.cleanRollback ? "yes" : "no"}</strong>
246 <span>clean rollback</span>
247 </div>
248 <div className="diag-summary__meta">
249 <span>
250 no-op={runtimeDoctor.noOpRebuilds} subgraph={runtimeDoctor.subgraphRebuilds} full={runtimeDoctor.fullRebuilds}{" "}
251 staleDrops={runtimeDoctor.staleDrops} admitReject={runtimeDoctor.admissionRejected} ownerFallbacks={runtimeDoctor.runtimeOwnerFallbacks}
252 </span>
253 </div>
254 </div>
255 <pre className="diag-path" style={{ whiteSpace: "pre-wrap", marginTop: 8 }}>
256 {runtimeDoctor.text}
257 </pre>
258 {runtimeDoctor.skillWatch && (
259 <div className="diag-summary" data-testid="skill-watch-diagnostics">
260 <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.physicalWatches}</strong><span>physical watches</span></div>
261 <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.logicalSubscriptions}</strong><span>subscriptions</span></div>
262 <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.scans}</strong><span>fallback scans</span></div>
263 <div className="diag-summary__item"><strong>{runtimeDoctor.skillWatch.degradedRoots}</strong><span>degraded roots</span></div>
264 <div className="diag-summary__meta">
265 <span>entries={runtimeDoctor.skillWatch.scannedEntries} events={runtimeDoctor.skillWatch.eventsReceived} notifications={runtimeDoctor.skillWatch.notifications} helperRestarts={runtimeDoctor.skillWatch.helperRestarts}</span>
266 </div>
267 </div>
268 )}
269 </div>
270 )}
271 </section>
272 )}
273
274 <section className="diag-section">
275 <button type="button" className="diag-section__header" onClick={() => toggle("issues")}>
276 {open.issues ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
277 <span>{t("diag.issues")} ({report.issues.length})</span>
278 </button>
279 {open.issues && (
280 <div className="diag-section__body">
281 {report.issues.length === 0 && <div className="empty">{t("diag.noIssues")}</div>}
282 {(["error", "warning", "info"] as const).map((sev) =>
283 issuesBySeverity[sev].length === 0 ? null : (
284 <div key={sev} className={`diag-issue-group diag-issue-group--${sev}`}>
285 <h4>{t(`diag.severity.${sev}` as "diag.severity.error")}</h4>
286 {issuesBySeverity[sev].map((issue, idx) => (
287 <article key={`${issue.code}-${issue.name ?? ""}-${idx}`} className="diag-issue">
288 <header>
289 <code>{issue.code}</code>
290 {issue.name ? <span className="diag-issue__name">{issue.name}</span> : null}
291 </header>
292 <p className="diag-issue__msg">{issue.message}</p>
293 {issue.source ? <p className="diag-path">{issue.source}</p> : null}
294 {issue.remediation ? <p className="diag-issue__fix">{issue.remediation}</p> : null}
295 {issue.settings_tab && onNavigate ? (
296 <button type="button" className="btn btn--ghost btn--small" onClick={() => goSettings(issue.settings_tab)}>
297 {t("diag.gotoSettings")}
298 </button>
299 ) : null}
300 </article>
301 ))}
302 </div>
303 ),
304 )}
305 </div>
306 )}
307 </section>
308
309 <Collapsible
310 title={t("diag.instructions")}
311 count={report.instructions.docs.length}
312 open={!!open.instructions}
313 onToggle={() => toggle("instructions")}
314 >
315 {report.instructions.docs.map((d) => (
316 <div key={`${d.order}-${d.path}`} className="diag-row">
317 <span>{d.order}. [{d.scope} · {d.depth}]</span>
318 <span className="diag-path">{d.path}</span>
319 </div>
320 ))}
321 {report.instructions.docs.length === 0 && <div className="empty">{t("common.none")}</div>}
322 </Collapsible>
323
324 <Collapsible
325 title={t("diag.skills")}
326 count={report.skills.winners}
327 open={!!open.skills}
328 onToggle={() => toggle("skills")}
329 >
330 {report.skills.entries.filter((e) => e.status === "winner").map((e) => (
331 <div key={`${e.name}-${e.path}`} className="diag-row">
332 <span>{e.name}</span>
333 <span className="diag-path">{e.path}</span>
334 </div>
335 ))}
336 </Collapsible>
337
338 <Collapsible
339 title={t("diag.commands")}
340 count={report.commands.winners}
341 open={!!open.commands}
342 onToggle={() => toggle("commands")}
343 >
344 {report.commands.entries.filter((e) => e.status === "winner").map((e) => (
345 <div key={`${e.name}-${e.path}`} className="diag-row">
346 <span>/{e.name}</span>
347 <span className="diag-path">{e.path}</span>
348 </div>
349 ))}
350 </Collapsible>
351
352 <Collapsible
353 title={t("diag.hooks")}
354 count={report.hooks.entries.length}
355 open={!!open.hooks}
356 onToggle={() => toggle("hooks")}
357 >
358 {report.hooks.entries.map((e, i) => (
359 <div key={`${e.event}-${e.source}-${i}`} className="diag-row">
360 <span>{e.event} [{e.scope}]</span>
361 <span className="diag-path">{e.source}</span>
362 </div>
363 ))}
364 </Collapsible>
365
366 <Collapsible
367 title={t("diag.plugins")}
368 count={report.plugins.packages.length}
369 open={!!open.plugins}
370 onToggle={() => toggle("plugins")}
371 >
372 {report.plugins.packages.map((p) => (
373 <div key={p.name} className="diag-row">
374 <span>{p.name} ({p.status})</span>
375 <span className="diag-path">{p.root}</span>
376 </div>
377 ))}
378 </Collapsible>
379
380 <Collapsible
381 title={t("diag.mcp")}
382 count={report.mcp.servers.length}
383 open={!!open.mcp}
384 onToggle={() => toggle("mcp")}
385 >
386 {report.mcp.servers.map((s) => (
387 <div key={s.name} className="diag-row">
388 <span>
389 {s.name} · {s.transport} · {s.start_intent}
390 {s.runtime_status ? ` · ${s.runtime_status}` : ""}
391 </span>
392 <span className="diag-path">{s.source || s.command || s.url_host || ""}</span>
393 </div>
394 ))}
395 </Collapsible>
396 </>
397 )}
398 </div>
399 );
400 }
401
402 function normalizeDiagnosticsReport(report: CapabilityDiagnosticsReport): CapabilityDiagnosticsReport {
403 return {
404 ...report,
405 issues: asArray(report.issues),
406 instructions: { ...report.instructions, docs: asArray(report.instructions?.docs) },
407 skills: {
408 ...report.skills,
409 roots: asArray(report.skills?.roots),
410 entries: asArray(report.skills?.entries),
411 },
412 commands: {
413 ...report.commands,
414 roots: asArray(report.commands?.roots),
415 entries: asArray(report.commands?.entries),
416 },
417 hooks: {
418 ...report.hooks,
419 sources: asArray(report.hooks?.sources),
420 entries: asArray(report.hooks?.entries),
421 },
422 plugins: { ...report.plugins, packages: asArray(report.plugins?.packages) },
423 mcp: { ...report.mcp, servers: asArray(report.mcp?.servers) },
424 };
425 }
426
427 function Collapsible({
428 title,
429 count,
430 open,
431 onToggle,
432 children,
433 }: {
434 title: string;
435 count: number;
436 open: boolean;
437 onToggle: () => void;
438 children: ReactNode;
439 }) {
440 return (
441 <section className="diag-section">
442 <button type="button" className="diag-section__header" onClick={onToggle}>
443 {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
444 <span>
445 {title} ({count})
446 </span>
447 </button>
448 {open && <div className="diag-section__body">{children}</div>}
449 </section>
450 );
451 }
452
452 lines Plain Text