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