已开启
feat(web): copy error diagnostics and submit issue from error surfaces(Issue #101) #142
feat(web): copy error diagnostics and submit issue from error surfaces(Issue #101) #142
已开启
hugoxk创建于 27 天前
共 9 个文件变更+185-4
@@ -14,6 +14,11 @@
14 14 
15import { Component, type ErrorInfo, type ReactNode } from "react";15import { Component, type ErrorInfo, type ReactNode } from "react";
16 16 
17+import { CopyButton } from "./CopyButton.js";
18+import { buildErrorDiagnostic, buildIssueUrl } from "./diagnostics.js";
19+import { translateActive } from "./i18n/index.js";
20+import { ExternalIcon } from "./icons.js";
21+ 
17interface ErrorBoundaryProps {22interface ErrorBoundaryProps {
18 children: ReactNode;23 children: ReactNode;
19 /** Shown above the error text so the user knows which panel failed. */24 /** Shown above the error text so the user knows which panel failed. */
@@ -54,9 +59,23 @@ export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundarySt
54 59 
55 render(): ReactNode {60 render(): ReactNode {
56 if (this.state.message === undefined) return this.props.children;61 if (this.state.message === undefined) return this.props.children;
62+ const label = this.props.label;
63+ const message = this.state.message;
64+ const errText = `${label}: ${message}`;
65+ const copyLabel = translateActive("error.diagnostics.copy");
66+ const issueLabel = translateActive("error.diagnostics.submitIssue");
57 return <div className="error-boundary-fallback" role="alert">67 return <div className="error-boundary-fallback" role="alert">
58- <strong>{this.props.label}</strong>68+ <strong>{label}</strong>
59- <p>{this.state.message}</p>69+ <p>{message}</p>
70+ <div className="toast-error-actions" style={{ marginTop: 8 }}>
71+ <CopyButton getText={() => buildErrorDiagnostic(errText, label)} label={copyLabel} />
72+ <a className="error-issue-link" href={buildIssueUrl(
73+ `Error: ${label}`,
74+ buildErrorDiagnostic(errText, label),
75+ )} rel="noreferrer" target="_blank" title={issueLabel}>
76+ <ExternalIcon size={13} /> {issueLabel}
77+ </a>
78+ </div>
60 </div>;79 </div>;
61 }80 }
62}81}
@@ -12,8 +12,10 @@
12// See the License for the specific language governing permissions and12// See the License for the specific language governing permissions and
13// limitations under the License.13// limitations under the License.
14 14 
15-import { AlertCircleIcon, CloseIcon } from "./icons.js";15+import { AlertCircleIcon, CloseIcon, ExternalIcon } from "./icons.js";
16import { useLocale } from "./i18n/index.js";16import { useLocale } from "./i18n/index.js";
17+import { buildErrorDiagnostic, buildIssueUrl } from "./diagnostics.js";
18+import { CopyButton } from "./CopyButton.js";
17 19 
18export function updateInlineErrors(current: string[], message?: string): string[] {20export function updateInlineErrors(current: string[], message?: string): string[] {
19 if (!message) return [];21 if (!message) return [];
@@ -34,12 +36,24 @@ export function InlineErrorAlert({
34 title?: string;36 title?: string;
35}) {37}) {
36 const { t } = useLocale();38 const { t } = useLocale();
39+ const issueUrl = buildIssueUrl(
40+ `Error: ${title ?? t("error.settingsTitle")}`,
41+ buildErrorDiagnostic(detail, title ?? t("error.settingsTitle")),
42+ );
37 43 
38 return <div className="inline-error-alert" role="alert">44 return <div className="inline-error-alert" role="alert">
39 <span className="inline-error-icon"><AlertCircleIcon size={16} /></span>45 <span className="inline-error-icon"><AlertCircleIcon size={16} /></span>
40 <div>46 <div>
41 <strong>{title ?? t("error.settingsTitle")}</strong>47 <strong>{title ?? t("error.settingsTitle")}</strong>
42 <small>{detail}</small>48 <small>{detail}</small>
49+ <div className="inline-error-actions">
50+ <CopyButton getText={() => buildErrorDiagnostic(detail, title ?? t("error.settingsTitle"))}
51+ label={t("error.diagnostics.copy")} />
52+ <a className="error-issue-link" href={issueUrl} rel="noreferrer" target="_blank"
53+ title={t("error.diagnostics.submitIssue")}>
54+ <ExternalIcon size={13} /> {t("error.diagnostics.submitIssue")}
55+ </a>
56+ </div>
43 </div>57 </div>
44 {onDismiss ? <button58 {onDismiss ? <button
45 aria-label={t("error.dismiss")}59 aria-label={t("error.dismiss")}
@@ -14,8 +14,10 @@
14 14 
15import { useCallback, useEffect, useRef, useState } from "react";15import { useCallback, useEffect, useRef, useState } from "react";
16 16 
17-import { AlertCircleIcon, CheckIcon, CloseIcon, InfoIcon } from "./icons.js";17+import { AlertCircleIcon, CheckIcon, CloseIcon, ExternalIcon, InfoIcon } from "./icons.js";
18import { useLocale } from "./i18n/index.js";18import { useLocale } from "./i18n/index.js";
19+import { buildErrorDiagnostic, buildIssueUrl } from "./diagnostics.js";
20+import { CopyButton } from "./CopyButton.js";
19 21 
20export type ToastTone = "error" | "info" | "success";22export type ToastTone = "error" | "info" | "success";
21 23 
@@ -119,6 +121,16 @@ export function ToastViewport({
119 <div className="toast-body">121 <div className="toast-body">
120 <strong>{toast.title}</strong>122 <strong>{toast.title}</strong>
121 {toast.detail ? <small title={toast.detail}>{toast.detail}</small> : null}123 {toast.detail ? <small title={toast.detail}>{toast.detail}</small> : null}
124+ {toast.tone === "error" ? <div className="toast-error-actions">
125+ <CopyButton getText={() => buildErrorDiagnostic(toast.detail ?? toast.title, toast.title)}
126+ label={t("error.diagnostics.copy")} />
127+ <a className="error-issue-link" href={buildIssueUrl(
128+ `Error: ${toast.title}`,
129+ buildErrorDiagnostic(toast.detail ?? toast.title, toast.title),
130+ )} rel="noreferrer" target="_blank" title={t("error.diagnostics.submitIssue")}>
131+ <ExternalIcon size={13} /> {t("error.diagnostics.submitIssue")}
132+ </a>
133+ </div> : null}
122 </div>134 </div>
123 <button aria-label={t("common.dismiss")} className="icon-button" onClick={() => onDismiss(toast.id)} title={t("common.dismiss")} type="button"><CloseIcon size={13} /></button>135 <button aria-label={t("common.dismiss")} className="icon-button" onClick={() => onDismiss(toast.id)} title={t("common.dismiss")} type="button"><CloseIcon size={13} /></button>
124 </div>136 </div>
@@ -0,0 +1,53 @@
1+// Copyright (C) 2026-2026 Huawei Technologies Co., Ltd
2+//
3+// Licensed under the Apache License, Version 2.0 (the "License");
4+// you may not use this file except in compliance with the License.
5+// You may obtain a copy of the License at
6+//
7+// http://www.apache.org/licenses/LICENSE-2.0
8+//
9+// Unless required by applicable law or agreed to in writing, software
10+// distributed under the License is distributed on an "AS IS" BASIS,
11+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+// See the License for the specific language governing permissions and
13+// limitations under the License.
14+ 
15+const SECRET_PATTERNS = [
16+ /\bBearer\s+\S+/gi,
17+ /\bapi[_-]?key['":]?\s*[:=]\s*\S+/gi,
18+ /\btoken['":]?\s*[:=]\s*\S+/gi,
19+ /\bsecret['":]?\s*[:=]\s*\S+/gi,
20+ /\bpassword['":]?\s*[:=]\s*\S+/gi,
21+ /(?:authorization|set-cookie)['":]?\s*[:=]\s*\S+/gi,
22+] as const;
23+ 
24+function redactSecrets(text: string): string {
25+ return SECRET_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, "[REDACTED]"), text);
26+}
27+ 
28+export function buildErrorDiagnostic(detail: string, title?: string): string {
29+ const lines = [
30+ "## Error diagnostic",
31+ "",
32+ `- Product: ScienceDiscovery`,
33+ `- Platform: ${typeof navigator !== "undefined" ? `${navigator.platform || "unknown"} (${navigator.userAgent})` : "unknown"}`,
34+ `- Language: ${typeof navigator !== "undefined" ? navigator.language : "unknown"}`,
35+ `- Time: ${new Date().toISOString()}`,
36+ "",
37+ "### Error",
38+ "",
39+ title ?? "",
40+ "",
41+ "### Detail",
42+ "",
43+ redactSecrets(detail),
44+ "",
45+ "> Please redact any personal or proprietary data before submitting.",
46+ ];
47+ return lines.join("\n");
48+}
49+ 
50+export function buildIssueUrl(title: string, body: string): string {
51+ const query = new URLSearchParams({ body, title });
52+ return `https://gitcode.com/openJiuwen/sciencediscovery/issues/new?${query.toString()}`;
53+}
@@ -285,6 +285,8 @@ export const en = {
285 "error.request": "Request error",285 "error.request": "Request error",
286 "error.settingsTitle": "Settings error",286 "error.settingsTitle": "Settings error",
287 "error.dismiss": "Dismiss error",287 "error.dismiss": "Dismiss error",
288+ "error.diagnostics.copy": "Copy diagnostics",
289+ "error.diagnostics.submitIssue": "Submit issue",
288 "error.artifactNotFound": "Artifact not found",290 "error.artifactNotFound": "Artifact not found",
289 "error.createProject": "Could not create project",291 "error.createProject": "Could not create project",
290 "error.selectProject": "Create or select a Project first",292 "error.selectProject": "Create or select a Project first",
@@ -2881,6 +2883,8 @@ export const zhCN: Partial<Record<MessageKey, string>> = {
2881 "error.request": "请求错误",2883 "error.request": "请求错误",
2882 "error.settingsTitle": "设置错误",2884 "error.settingsTitle": "设置错误",
2883 "error.dismiss": "关闭错误",2885 "error.dismiss": "关闭错误",
2886+ "error.diagnostics.copy": "复制诊断信息",
2887+ "error.diagnostics.submitIssue": "提交 Issue",
2884 "error.artifactNotFound": "未找到产物",2888 "error.artifactNotFound": "未找到产物",
2885 "error.createProject": "无法创建项目",2889 "error.createProject": "无法创建项目",
2886 "error.selectProject": "请先创建或选择项目",2890 "error.selectProject": "请先创建或选择项目",
@@ -53,4 +53,13 @@
53.inline-error-alert small { margin-top: 3px; color: var(--danger); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }53.inline-error-alert small { margin-top: 3px; color: var(--danger); font-size: 12px; line-height: 1.45; overflow-wrap: anywhere; }
54.inline-error-alert > button { width: 26px; height: 26px; border: 0; color: var(--danger); }54.inline-error-alert > button { width: 26px; height: 26px; border: 0; color: var(--danger); }
55.inline-error-alert > button:hover { background: var(--surface); color: var(--danger-deep); }55.inline-error-alert > button:hover { background: var(--surface); color: var(--danger-deep); }
56+.inline-error-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
57+.inline-error-actions .copy-button { opacity: 1; border: 1px solid var(--danger-border); background: var(--surface); color: var(--danger); }
58+.inline-error-actions .copy-button:hover { background: var(--surface-hover); }
59+.error-issue-link { display: inline-flex; align-items: center; gap: 4px; color: var(--danger); font-size: 12px; font-weight: 650; text-decoration: none; }
60+.error-issue-link:hover { color: var(--danger-deep); text-decoration: underline; }
61+.toast-error-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 6px; }
62+.toast-error-actions .copy-button { opacity: 1; border: 1px solid var(--border); background: var(--surface); color: var(--muted); }
63+.toast-error-actions .error-issue-link { color: var(--accent-ink); }
64+.toast-error-actions .error-issue-link:hover { color: var(--accent-strong); }
56@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }65@keyframes toast-in { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: none; } }
Mapps/web/tests/InlineErrorAlert.test.tsx+12-0文件内容审核中,请稍后刷新重试
Mapps/web/tests/Toasts.test.tsx+10-0文件内容审核中,请稍后刷新重试
@@ -0,0 +1,48 @@
1+// Copyright (C) 2026-2026 Huawei Technologies Co., Ltd
2+//
3+// Licensed under the Apache License, Version 2.0 (the "License");
4+// you may not use this file except in compliance with the License.
5+// You may obtain a copy of the License at
6+//
7+// http://www.apache.org/licenses/LICENSE-2.0
8+//
9+// Unless required by applicable law or agreed to in writing, software
10+// distributed under the License is distributed on an "AS IS" BASIS,
11+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+// See the License for the specific language governing permissions and
13+// limitations under the License.
14+ 
15+import assert from "node:assert/strict";
16+import test from "node:test";
17+ 
18+import { buildErrorDiagnostic, buildIssueUrl } from "../src/diagnostics.js";
19+ 
20+test("buildErrorDiagnostic includes product, platform, language and error text", () => {
21+ const diagnostic = buildErrorDiagnostic("401 Unauthorized", "Request failed");
22+ 
23+ assert.match(diagnostic, /Product: ScienceDiscovery/);
24+ assert.match(diagnostic, /Platform: .+Language:/s);
25+ assert.match(diagnostic, /### Error\s+Request failed/);
26+ assert.match(diagnostic, /### Detail\s+401 Unauthorized/);
27+});
28+ 
29+test("buildErrorDiagnostic redacts secrets embedded in the detail text", () => {
30+ const diagnostic = buildErrorDiagnostic(
31+ "Authorization: Bearer abc123xyz and api_key=secret-token with password=guessme",
32+ "Request failed",
33+ );
34+ 
35+ assert.doesNotMatch(diagnostic, /abc123xyz/);
36+ assert.doesNotMatch(diagnostic, /secret-token/);
37+ assert.doesNotMatch(diagnostic, /guessme/);
38+ assert.match(diagnostic, /\[REDACTED\]/);
39+});
40+ 
41+test("buildIssueUrl encodes title and body into the tracker URL", () => {
42+ const url = buildIssueUrl("Error: boom", "body with #hash & spaces");
43+ 
44+ assert.ok(url.startsWith("https://gitcode.com/openJiuwen/sciencediscovery/issues/new?"));
45+ const parsed = new URL(url);
46+ assert.equal(parsed.searchParams.get("title"), "Error: boom");
47+ assert.equal(parsed.searchParams.get("body"), "body with #hash & spaces");
48+});