已开启
feat(web): copy error diagnostics and submit issue from error surfaces(Issue #101) #142
hugoxk创建于 27 天前
feat(web): copy error diagnostics and submit issue from error surfaces(Issue #101) #142
已开启
共 9 个文件变更+185-4
| @@ -14,6 +14,11 @@ | |||
| 14 | 14 | ||
| 15 | import { Component, type ErrorInfo, type ReactNode } from "react"; | 15 | import { 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 | + | ||
| 17 | interface ErrorBoundaryProps { | 22 | interface 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 and | 12 | // 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"; |
| 16 | import { useLocale } from "./i18n/index.js"; | 16 | import { useLocale } from "./i18n/index.js"; |
| 17 | +import { buildErrorDiagnostic, buildIssueUrl } from "./diagnostics.js"; | ||
| 18 | +import { CopyButton } from "./CopyButton.js"; | ||
| 17 | 19 | ||
| 18 | export function updateInlineErrors(current: string[], message?: string): string[] { | 20 | export 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 ? <button | 58 | {onDismiss ? <button |
| 45 | aria-label={t("error.dismiss")} | 59 | aria-label={t("error.dismiss")} |
| @@ -14,8 +14,10 @@ | |||
| 14 | 14 | ||
| 15 | import { useCallback, useEffect, useRef, useState } from "react"; | 15 | import { 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"; |
| 18 | import { useLocale } from "./i18n/index.js"; | 18 | import { useLocale } from "./i18n/index.js"; |
| 19 | +import { buildErrorDiagnostic, buildIssueUrl } from "./diagnostics.js"; | ||
| 20 | +import { CopyButton } from "./CopyButton.js"; | ||
| 19 | 21 | ||
| 20 | export type ToastTone = "error" | "info" | "success"; | 22 | export 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; } } |
| @@ -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 | +}); | ||