| 1 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 2 | import { Activity, Download, Flag, X } from "lucide-react"; |
| 3 | import { app } from "../lib/bridge"; |
| 4 | import { useToast } from "../lib/toast"; |
| 5 | import { useI18n, type Locale } from "../lib/i18n"; |
| 6 | import { |
| 7 | frontendDiagnosticSample, |
| 8 | frontendDiagnostics, |
| 9 | type FrontendDiagnosticSnapshot, |
| 10 | } from "../lib/frontendDiagnostics"; |
| 11 | |
| 12 | const defaultFilename = (reportId: string) => `reasonix-frontend-diagnostics-${reportId.slice(0, 8) || "trace"}.json`; |
| 13 | const COPY: Record<Locale, { |
| 14 | start: string; |
| 15 | startHint: string; |
| 16 | recordingLabel: string; |
| 17 | readyLabel: string; |
| 18 | recording: (seconds: number, events: number) => string; |
| 19 | ready: (events: number) => string; |
| 20 | export: string; |
| 21 | stopExport: string; |
| 22 | mark: string; |
| 23 | cancel: string; |
| 24 | }> = { |
| 25 | en: { |
| 26 | start: "Frontend diagnostics", |
| 27 | startHint: "Start recording a local frontend interaction timeline", |
| 28 | recordingLabel: "Frontend diagnostics · on", |
| 29 | readyLabel: "Frontend diagnostics · ready", |
| 30 | recording: (seconds, events) => `Recording - ${seconds}s - ${events} events`, |
| 31 | ready: (events) => `Trace ready - ${events} events`, |
| 32 | export: "Export", |
| 33 | stopExport: "Stop & export", |
| 34 | mark: "Mark current issue", |
| 35 | cancel: "Cancel recording", |
| 36 | }, |
| 37 | zh: { |
| 38 | start: "前端诊断", |
| 39 | startHint: "开始记录一段仅保存在本地的前端交互时间线", |
| 40 | recordingLabel: "前端诊断 · 已开启", |
| 41 | readyLabel: "前端诊断 · 待导出", |
| 42 | recording: (seconds, events) => `记录中 - ${seconds} 秒 - ${events} 条事件`, |
| 43 | ready: (events) => `记录完成 - ${events} 条事件`, |
| 44 | export: "导出", |
| 45 | stopExport: "停止并导出", |
| 46 | mark: "标记当前问题", |
| 47 | cancel: "取消记录", |
| 48 | }, |
| 49 | "zh-TW": { |
| 50 | start: "前端診斷", |
| 51 | startHint: "開始記錄一段僅儲存在本機的前端互動時間線", |
| 52 | recordingLabel: "前端診斷 · 已開啟", |
| 53 | readyLabel: "前端診斷 · 待匯出", |
| 54 | recording: (seconds, events) => `記錄中 - ${seconds} 秒 - ${events} 筆事件`, |
| 55 | ready: (events) => `記錄完成 - ${events} 筆事件`, |
| 56 | export: "匯出", |
| 57 | stopExport: "停止並匯出", |
| 58 | mark: "標記目前問題", |
| 59 | cancel: "取消記錄", |
| 60 | }, |
| 61 | }; |
| 62 | |
| 63 | export type FrontendDiagnosticsControlProps = { |
| 64 | scrollElement?: HTMLElement | null; |
| 65 | totalRows?: number; |
| 66 | embedded?: boolean; |
| 67 | }; |
| 68 | |
| 69 | export function FrontendDiagnosticsControl({ |
| 70 | scrollElement, |
| 71 | totalRows = 0, |
| 72 | embedded = false, |
| 73 | }: FrontendDiagnosticsControlProps) { |
| 74 | const { locale } = useI18n(); |
| 75 | const copy = COPY[locale]; |
| 76 | const scrollElementRef = useRef<HTMLElement | null>(scrollElement ?? null); |
| 77 | const totalRowsRef = useRef(totalRows); |
| 78 | scrollElementRef.current = scrollElement ?? null; |
| 79 | totalRowsRef.current = totalRows; |
| 80 | const { showToast } = useToast(); |
| 81 | const [snapshot, setSnapshot] = useState<FrontendDiagnosticSnapshot>(() => frontendDiagnostics.getSnapshot()); |
| 82 | const [exporting, setExporting] = useState(false); |
| 83 | |
| 84 | const refresh = useCallback(() => setSnapshot(frontendDiagnostics.getSnapshot()), []); |
| 85 | useEffect(() => frontendDiagnostics.subscribe(refresh), [refresh]); |
| 86 | useEffect(() => { |
| 87 | if (snapshot.status !== "recording") return; |
| 88 | const timer = window.setInterval(refresh, 500); |
| 89 | return () => window.clearInterval(timer); |
| 90 | }, [refresh, snapshot.status]); |
| 91 | |
| 92 | const start = useCallback(() => { |
| 93 | frontendDiagnostics.start(() => frontendDiagnosticSample(scrollElementRef.current, totalRowsRef.current)); |
| 94 | }, []); |
| 95 | |
| 96 | const exportTrace = useCallback(async () => { |
| 97 | setExporting(true); |
| 98 | try { |
| 99 | const payload = frontendDiagnostics.stop(); |
| 100 | const path = await app.PickExportFile(defaultFilename(payload.manifest.reportId), "application/json"); |
| 101 | if (path) { |
| 102 | await app.SaveExportFile(path, JSON.stringify(payload, null, 2), false); |
| 103 | frontendDiagnostics.reset(); |
| 104 | } |
| 105 | } catch (error) { |
| 106 | showToast(error instanceof Error ? error.message : String(error), "error"); |
| 107 | } finally { |
| 108 | setExporting(false); |
| 109 | } |
| 110 | }, [showToast]); |
| 111 | |
| 112 | const toggle = useCallback(() => { |
| 113 | if (snapshot.status === "idle") { |
| 114 | start(); |
| 115 | return; |
| 116 | } |
| 117 | void exportTrace(); |
| 118 | }, [exportTrace, snapshot.status, start]); |
| 119 | const rootClass = `scroll-diagnostics frontend-diagnostics${embedded ? " frontend-diagnostics--embedded" : ""}`; |
| 120 | |
| 121 | if (snapshot.status === "idle") { |
| 122 | return ( |
| 123 | <div className={rootClass} data-testid="frontend-diagnostics"> |
| 124 | <button |
| 125 | type="button" |
| 126 | className="frontend-diagnostics__toggle" |
| 127 | role="switch" |
| 128 | aria-checked="false" |
| 129 | onClick={start} |
| 130 | title={copy.startHint} |
| 131 | > |
| 132 | <Activity size={15} aria-hidden="true" /> |
| 133 | <span>{copy.start}</span> |
| 134 | </button> |
| 135 | </div> |
| 136 | ); |
| 137 | } |
| 138 | |
| 139 | const elapsedSeconds = Math.min(120, Math.floor(snapshot.durationMs / 1_000)); |
| 140 | const recording = snapshot.status === "recording"; |
| 141 | return ( |
| 142 | <div className={`${rootClass} scroll-diagnostics--${snapshot.status}`} data-testid="frontend-diagnostics"> |
| 143 | <button |
| 144 | type="button" |
| 145 | className={`frontend-diagnostics__toggle${recording ? " frontend-diagnostics__toggle--on" : ""}`} |
| 146 | role="switch" |
| 147 | aria-checked={recording} |
| 148 | onClick={toggle} |
| 149 | disabled={exporting} |
| 150 | title={recording ? copy.stopExport : copy.export} |
| 151 | > |
| 152 | <Activity size={15} aria-hidden="true" /> |
| 153 | <span>{recording ? copy.recordingLabel : copy.readyLabel}</span> |
| 154 | </button> |
| 155 | <span className="scroll-diagnostics__status" role="status"> |
| 156 | <span className="scroll-diagnostics__dot" aria-hidden="true" /> |
| 157 | {recording ? copy.recording(elapsedSeconds, snapshot.eventCount) : copy.ready(snapshot.eventCount)} |
| 158 | </span> |
| 159 | {recording && ( |
| 160 | <button |
| 161 | type="button" |
| 162 | className="scroll-diagnostics__icon-button" |
| 163 | onClick={() => frontendDiagnostics.mark()} |
| 164 | aria-label={copy.mark} |
| 165 | title={copy.mark} |
| 166 | > |
| 167 | <Flag size={15} aria-hidden="true" /> |
| 168 | </button> |
| 169 | )} |
| 170 | <button |
| 171 | type="button" |
| 172 | className="scroll-diagnostics__export" |
| 173 | onClick={() => void exportTrace()} |
| 174 | disabled={exporting} |
| 175 | title={recording ? copy.stopExport : copy.export} |
| 176 | > |
| 177 | <Download size={15} aria-hidden="true" /> |
| 178 | <span>{copy.export}</span> |
| 179 | </button> |
| 180 | <button |
| 181 | type="button" |
| 182 | className="scroll-diagnostics__icon-button" |
| 183 | onClick={() => frontendDiagnostics.reset()} |
| 184 | aria-label={copy.cancel} |
| 185 | title={copy.cancel} |
| 186 | > |
| 187 | <X size={15} aria-hidden="true" /> |
| 188 | </button> |
| 189 | </div> |
| 190 | ); |
| 191 | } |
| 192 |