返回 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 { useT } from "../lib/i18n";
6 import type { CapabilityDiagnosticsReport, CapabilityIssue, SettingsTab } from "../lib/types";
7
8 export function DiagnosticsSettingsPage({
9 onNavigate,
10 }: {
11 onNavigate?: (tab: SettingsTab) => void;
12 }) {
13 const t = useT();
14 const [report, setReport] = useState<CapabilityDiagnosticsReport | null>(null);
15 const [loading, setLoading] = useState(true);
16 const [error, setError] = useState<string | null>(null);
17 const [includeRuntime, setIncludeRuntime] = useState(false);
18 const [copied, setCopied] = useState(false);
19 const [open, setOpen] = useState<Record<string, boolean>>({
20 issues: true,
21 instructions: false,
22 skills: false,
23 commands: false,
24 hooks: false,
25 plugins: false,
26 mcp: false,
27 });
28
29 const loadSeq = useRef(0);
30
31 const load = useCallback(async (runtime: boolean) => {
32 const seq = ++loadSeq.current;
33 setLoading(true);
34 setError(null);
35 try {
36 const next = normalizeDiagnosticsReport(await app.CapabilityDiagnostics(runtime));
37 // Last-request-wins: ignore stale responses after rapid refresh/toggle.
38 if (seq !== loadSeq.current) return;
39 setReport(next);
40 } catch (err) {
41 if (seq !== loadSeq.current) return;
42 setError(err instanceof Error ? err.message : String(err));
43 setReport(null);
44 } finally {
45 if (seq === loadSeq.current) {
46 setLoading(false);
47 }
48 }
49 }, []);
50
51 useEffect(() => {
52 void load(includeRuntime);
53 }, [includeRuntime, load]);
54
55 const issuesBySeverity = useMemo(() => {
56 const groups: Record<string, CapabilityIssue[]> = { error: [], warning: [], info: [] };
57 for (const issue of report?.issues ?? []) {
58 const key = issue.severity === "error" || issue.severity === "warning" ? issue.severity : "info";
59 groups[key].push(issue);
60 }
61 return groups;
62 }, [report]);
63
64 const copyJSON = async () => {
65 if (!report) return;
66 try {
67 await navigator.clipboard.writeText(JSON.stringify(report, null, 2));
68 setCopied(true);
69 window.setTimeout(() => setCopied(false), 1500);
70 } catch {
71 setError(t("diag.copyFailed"));
72 }
73 };
74
75 const toggle = (key: string) => setOpen((prev) => ({ ...prev, [key]: !prev[key] }));
76
77 const goSettings = (tab?: string) => {
78 if (!tab || !onNavigate) return;
79 const allowed: SettingsTab[] = ["mcp", "skills", "plugins", "hooks"];
80 if (allowed.includes(tab as SettingsTab)) {
81 onNavigate(tab as SettingsTab);
82 }
83 };
84
85 return (
86 <div className="diag-page">
87 <div className="diag-page__toolbar">
88 <label className="diag-page__runtime">
89 <input
90 type="checkbox"
91 checked={includeRuntime}
92 onChange={(e) => setIncludeRuntime(e.target.checked)}
93 />
94 <span>{t("diag.includeRuntime")}</span>
95 </label>
96 <div className="diag-page__actions">
97 <button type="button" className="btn btn--ghost" onClick={() => void load(includeRuntime)} disabled={loading}>
98 {loading ? <Loader2 size={14} className="spin" /> : <RefreshCw size={14} />}
99 <span>{t("diag.refresh")}</span>
100 </button>
101 <button type="button" className="btn btn--ghost" onClick={() => void copyJSON()} disabled={!report}>
102 <Clipboard size={14} />
103 <span>{copied ? t("diag.copied") : t("diag.copyJson")}</span>
104 </button>
105 </div>
106 </div>
107
108 <p className="diag-page__hint">{t("diag.hint")}</p>
109
110 {loading && !report && <div className="empty">{t("settings.loading")}</div>}
111 {error && <div className="settings-error" role="alert">{error}</div>}
112
113 {report && (
114 <>
115 <div className="diag-summary">
116 <div className="diag-summary__item diag-summary__item--error">
117 <strong>{report.summary.errors}</strong>
118 <span>{t("diag.errors")}</span>
119 </div>
120 <div className="diag-summary__item diag-summary__item--warning">
121 <strong>{report.summary.warnings}</strong>
122 <span>{t("diag.warnings")}</span>
123 </div>
124 <div className="diag-summary__item diag-summary__item--info">
125 <strong>{report.summary.infos}</strong>
126 <span>{t("diag.infos")}</span>
127 </div>
128 <div className="diag-summary__meta">
129 <span className="diag-path">{report.root}</span>
130 <span>
131 {t("diag.counts", {
132 skills: report.summary.skills,
133 commands: report.summary.commands,
134 hooks: report.summary.hooks,
135 plugins: report.summary.plugins,
136 mcp: report.summary.mcp_servers,
137 })}
138 </span>
139 </div>
140 </div>
141
142 <section className="diag-section">
143 <button type="button" className="diag-section__header" onClick={() => toggle("issues")}>
144 {open.issues ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
145 <span>{t("diag.issues")} ({report.issues.length})</span>
146 </button>
147 {open.issues && (
148 <div className="diag-section__body">
149 {report.issues.length === 0 && <div className="empty">{t("diag.noIssues")}</div>}
150 {(["error", "warning", "info"] as const).map((sev) =>
151 issuesBySeverity[sev].length === 0 ? null : (
152 <div key={sev} className={`diag-issue-group diag-issue-group--${sev}`}>
153 <h4>{t(`diag.severity.${sev}` as "diag.severity.error")}</h4>
154 {issuesBySeverity[sev].map((issue, idx) => (
155 <article key={`${issue.code}-${issue.name ?? ""}-${idx}`} className="diag-issue">
156 <header>
157 <code>{issue.code}</code>
158 {issue.name ? <span className="diag-issue__name">{issue.name}</span> : null}
159 </header>
160 <p className="diag-issue__msg">{issue.message}</p>
161 {issue.source ? <p className="diag-path">{issue.source}</p> : null}
162 {issue.remediation ? <p className="diag-issue__fix">{issue.remediation}</p> : null}
163 {issue.settings_tab && onNavigate ? (
164 <button type="button" className="btn btn--ghost btn--small" onClick={() => goSettings(issue.settings_tab)}>
165 {t("diag.gotoSettings")}
166 </button>
167 ) : null}
168 </article>
169 ))}
170 </div>
171 ),
172 )}
173 </div>
174 )}
175 </section>
176
177 <Collapsible
178 title={t("diag.instructions")}
179 count={report.instructions.docs.length}
180 open={!!open.instructions}
181 onToggle={() => toggle("instructions")}
182 >
183 {report.instructions.docs.map((d) => (
184 <div key={`${d.order}-${d.path}`} className="diag-row">
185 <span>{d.order}. [{d.scope} · {d.depth}]</span>
186 <span className="diag-path">{d.path}</span>
187 </div>
188 ))}
189 {report.instructions.docs.length === 0 && <div className="empty">{t("common.none")}</div>}
190 </Collapsible>
191
192 <Collapsible
193 title={t("diag.skills")}
194 count={report.skills.winners}
195 open={!!open.skills}
196 onToggle={() => toggle("skills")}
197 >
198 {report.skills.entries.filter((e) => e.status === "winner").map((e) => (
199 <div key={`${e.name}-${e.path}`} className="diag-row">
200 <span>{e.name}</span>
201 <span className="diag-path">{e.path}</span>
202 </div>
203 ))}
204 </Collapsible>
205
206 <Collapsible
207 title={t("diag.commands")}
208 count={report.commands.winners}
209 open={!!open.commands}
210 onToggle={() => toggle("commands")}
211 >
212 {report.commands.entries.filter((e) => e.status === "winner").map((e) => (
213 <div key={`${e.name}-${e.path}`} className="diag-row">
214 <span>/{e.name}</span>
215 <span className="diag-path">{e.path}</span>
216 </div>
217 ))}
218 </Collapsible>
219
220 <Collapsible
221 title={t("diag.hooks")}
222 count={report.hooks.entries.length}
223 open={!!open.hooks}
224 onToggle={() => toggle("hooks")}
225 >
226 {report.hooks.entries.map((e, i) => (
227 <div key={`${e.event}-${e.source}-${i}`} className="diag-row">
228 <span>{e.event} [{e.scope}]</span>
229 <span className="diag-path">{e.source}</span>
230 </div>
231 ))}
232 </Collapsible>
233
234 <Collapsible
235 title={t("diag.plugins")}
236 count={report.plugins.packages.length}
237 open={!!open.plugins}
238 onToggle={() => toggle("plugins")}
239 >
240 {report.plugins.packages.map((p) => (
241 <div key={p.name} className="diag-row">
242 <span>{p.name} ({p.status})</span>
243 <span className="diag-path">{p.root}</span>
244 </div>
245 ))}
246 </Collapsible>
247
248 <Collapsible
249 title={t("diag.mcp")}
250 count={report.mcp.servers.length}
251 open={!!open.mcp}
252 onToggle={() => toggle("mcp")}
253 >
254 {report.mcp.servers.map((s) => (
255 <div key={s.name} className="diag-row">
256 <span>
257 {s.name} · {s.transport} · {s.start_intent}
258 {s.runtime_status ? ` · ${s.runtime_status}` : ""}
259 </span>
260 <span className="diag-path">{s.source || s.command || s.url_host || ""}</span>
261 </div>
262 ))}
263 </Collapsible>
264 </>
265 )}
266 </div>
267 );
268 }
269
270 function normalizeDiagnosticsReport(report: CapabilityDiagnosticsReport): CapabilityDiagnosticsReport {
271 return {
272 ...report,
273 issues: asArray(report.issues),
274 instructions: { ...report.instructions, docs: asArray(report.instructions?.docs) },
275 skills: {
276 ...report.skills,
277 roots: asArray(report.skills?.roots),
278 entries: asArray(report.skills?.entries),
279 },
280 commands: {
281 ...report.commands,
282 roots: asArray(report.commands?.roots),
283 entries: asArray(report.commands?.entries),
284 },
285 hooks: {
286 ...report.hooks,
287 sources: asArray(report.hooks?.sources),
288 entries: asArray(report.hooks?.entries),
289 },
290 plugins: { ...report.plugins, packages: asArray(report.plugins?.packages) },
291 mcp: { ...report.mcp, servers: asArray(report.mcp?.servers) },
292 };
293 }
294
295 function Collapsible({
296 title,
297 count,
298 open,
299 onToggle,
300 children,
301 }: {
302 title: string;
303 count: number;
304 open: boolean;
305 onToggle: () => void;
306 children: ReactNode;
307 }) {
308 return (
309 <section className="diag-section">
310 <button type="button" className="diag-section__header" onClick={onToggle}>
311 {open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
312 <span>
313 {title} ({count})
314 </span>
315 </button>
316 {open && <div className="diag-section__body">{children}</div>}
317 </section>
318 );
319 }
320
320 lines Plain Text