/** * @file Import History panel - step-by-step instructions and three import modes * (rescan default folder, scan any path, upload files/archives). Renders inside * the Settings page and keeps all I/O isolated behind the api.import.* client. * * Robustness notes: * • Every mode funnels through the same server-side parser used for live * ingestion, so token counts and per-model cost are computed identically. * • Re-imports are idempotent: sessions are deduplicated by session ID and * compaction baselines prevent token double-counting. * • Archive extraction is guarded against path traversal on the server. * * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed) * ============================================================================= * **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode. * * ## Design constraints * - Local-first: no telemetry leaves the machine unless the user configures webhooks. * - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that * philosophy by degrading gracefully (empty states, stale badges, reconnect loops). * - Destructive flows stay behind explicit confirmation modals and server-side gates. * - Internationalization: user-visible strings belong in i18n JSON, not literals here. * * ## Remote data & SSH * Remote Data Sources let operators aggregate multiple machines. SSH entries describe * how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every * scoped GET via `?sources=`. Health checks and import history surface in Settings. * * ## Observability * Prometheus scrapes `GET /api/metrics` (see `monitoring/`). Grafana ships four * provisioned boards (overview, sessions, tools, alerts). Native npm scripts and * Docker Compose profiles are documented in `monitoring/README.md`. * * ## Internal dependencies * - `../lib/api` * - `../lib/eventBus` * - `../lib/types` * * ## Public surface * - `ImportHistory` — exported API; see TSDoc on the symbol for behavior. * * ## Testing pointers * - Prefer colocated `__tests__` with Vitest + Testing Library for UI. * - Server contract changes require `npm run test:server` and OpenAPI sync. * - MCP edits: `npm run mcp:typecheck` and `npm run mcp:build`. * * ## Related docs * - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline. * - `docs/API.md` — REST reference. * - `.claude/skills/file-headers/` — mandatory `@author` header policy. * ============================================================================= */ /* ----------------------------------------------------------------------------- * EXPORT CATALOG — quick index of symbols defined below (documentation only). * ----------------------------------------------------------------------------- * **ImportHistory** * Part of this module's public contract. Downstream imports should treat * the signature and return type as stable unless release notes say otherwise. * When behavior changes, update the `@file` overview and relevant tests. * * ----------------------------------------------------------------------------- */ import { useEffect, useRef, useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { FolderOpen, RefreshCw, UploadCloud, FileArchive, FolderInput, CheckCircle2, AlertTriangle, Loader2, HardDrive, ListChecks, Info, Copy, Check, XCircle, History, Terminal, DatabaseBackup, RotateCcw, } from "lucide-react"; import { api, type ImportResult, type ImportBackupResult } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import type { WSMessage, ImportProgressMessage } from "../lib/types"; type Mode = "rescan" | "path" | "upload" | "backup"; type GuideResponse = Awaited>; type Progress = ImportProgressMessage; export function ImportHistory() { const { t } = useTranslation("settings"); const [mode, setMode] = useState("rescan"); const [guide, setGuide] = useState(null); const [folderPath, setFolderPath] = useState(""); const [files, setFiles] = useState([]); const [running, setRunning] = useState(false); const [progress, setProgress] = useState(null); const [result, setResult] = useState(null); const [errorMsg, setErrorMsg] = useState(null); const [instructionsOpen, setInstructionsOpen] = useState(true); const [copied, setCopied] = useState(false); const [dragging, setDragging] = useState(false); const fileInputRef = useRef(null); // "Restore backup" mode: import a full dashboard export (.json) produced by // the Export data button — the round-trip for consolidating machines. const [backupFile, setBackupFile] = useState(null); const [backupResult, setBackupResult] = useState(null); const backupInputRef = useRef(null); // Load the guide once. If the API isn't reachable, fall back to sensible // defaults so the UI still explains what to do. useEffect(() => { api.import .guide() .then(setGuide) .catch(() => { setGuide({ platform: "unknown", default_projects_dir: "~/.claude/projects", default_projects_dir_display: "~/.claude/projects", default_projects_dir_exists: false, default_projects_dir_stats: { projects: 0, jsonl_files: 0 }, archive_command: "tar -czf claude-history.tar.gz -C ~/.claude projects", supported_extensions: [".jsonl", ".meta.json", ".zip", ".tar.gz", ".tgz", ".gz"], max_upload_bytes: 1024 * 1024 * 1024, max_upload_files: 2000, steps: [], }); }); }, []); // Stream import progress from the websocket so long-running imports stay // responsive. We only render the latest snapshot. useEffect(() => { return eventBus.subscribe((msg: WSMessage) => { if (msg.type !== "import.progress") return; setProgress(msg.data as Progress); }); }, []); const reset = useCallback(() => { setErrorMsg(null); setResult(null); setBackupResult(null); setProgress(null); }, []); const handleRescan = async () => { reset(); setRunning(true); try { const res = await api.import.rescan(); setResult(res); } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setRunning(false); setProgress(null); } }; const handleScanPath = async () => { reset(); const trimmed = folderPath.trim(); if (!trimmed) { setErrorMsg(t("import.errors.pathRequired")); return; } setRunning(true); try { const res = await api.import.scanPath(trimmed); setResult(res); } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setRunning(false); setProgress(null); } }; const handleUpload = async () => { reset(); if (files.length === 0) { setErrorMsg(t("import.errors.noFiles")); return; } setRunning(true); try { const res = await api.import.upload(files); setResult(res); setFiles([]); if (fileInputRef.current) fileInputRef.current.value = ""; } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setRunning(false); setProgress(null); } }; const handleRestore = async () => { reset(); if (!backupFile) { setErrorMsg(t("import.errors.noFiles")); return; } setRunning(true); try { const res = await api.settings.importData(backupFile); setBackupResult(res); setBackupFile(null); if (backupInputRef.current) backupInputRef.current.value = ""; } catch (err) { setErrorMsg(err instanceof Error ? err.message : String(err)); } finally { setRunning(false); setProgress(null); } }; const onSelectFiles = (list: FileList | null) => { if (!list) return; const arr = Array.from(list).filter((f) => { const lower = f.name.toLowerCase(); return ( lower.endsWith(".jsonl") || lower.endsWith(".meta.json") || lower.endsWith(".zip") || lower.endsWith(".tar") || lower.endsWith(".tar.gz") || lower.endsWith(".tgz") || lower.endsWith(".gz") ); }); setFiles((prev) => { const seen = new Set(prev.map((f) => `${f.name}:${f.size}`)); const next = [...prev]; for (const f of arr) { const key = `${f.name}:${f.size}`; if (!seen.has(key)) next.push(f); } return next; }); }; const copyArchiveCmd = async () => { if (!guide) return; try { await navigator.clipboard.writeText(guide.archive_command); setCopied(true); setTimeout(() => setCopied(false), 1500); } catch { /* clipboard unavailable */ } }; const progressText = (() => { if (!progress) return null; if (progress.phase === "scan") return t("import.progress.scan"); if (progress.phase === "extract") { return t("import.progress.extract", { processed: progress.processed ?? 0, total: progress.total ?? 0, }); } if (progress.phase === "parse") { return t("import.progress.parse", { processed: progress.processed ?? 0, total: progress.total ?? 0, }); } if (progress.phase === "complete") return t("import.progress.complete"); if (progress.phase === "error") return t("import.progress.error"); return null; })(); const totalSize = files.reduce((s, f) => s + f.size, 0); return (

{t("import.title")}

{t("import.description")}

{t("cursorPathsNote")}

{/* Step-by-step instructions */}
{instructionsOpen && (
{/* Default location card */} {guide && (
{t("import.defaultLocation")}: {guide.default_projects_dir_display} {guide.default_projects_dir_exists ? ( {t("import.locationFound")} · {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "} {guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")} ) : ( {t("import.locationMissing")} )}
)} {/* Steps */}
{guide && (
{guide.archive_command}
)}
{t("import.accuracyNote")}
)}
{/* Mode switcher */}
} title={t("import.modeRescan")} desc={t("import.modeRescanDesc")} onClick={() => setMode("rescan")} /> } title={t("import.modeFolder")} desc={t("import.modeFolderDesc")} onClick={() => setMode("path")} /> } title={t("import.modeUpload")} desc={t("import.modeUploadDesc")} onClick={() => setMode("upload")} /> } title={t("import.modeBackup")} desc={t("import.modeBackupDesc")} onClick={() => setMode("backup")} />
{/* Mode panel */}
{mode === "rescan" && (
{guide?.default_projects_dir_display || "~/.claude/projects"}
)} {mode === "path" && (
setFolderPath(e.target.value)} placeholder={t("import.folderPlaceholder")} className="input w-full text-sm font-mono" spellCheck={false} />

{t("import.folderHelper")}

)} {mode === "upload" && (
fileInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); onSelectFiles(e.dataTransfer.files); }} className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ dragging ? "border-blue-500 bg-blue-600/5" : "border-border hover:border-border-light bg-surface-1" }`} >

{t("import.dropzoneHint")}

{t("import.dropzoneSub")}

onSelectFiles(e.target.files)} className="hidden" />
{files.length > 0 && (
{t("import.filesSelected", { count: files.length })} ({formatBytes(totalSize)})
)}
)} {mode === "backup" && (
backupInputRef.current?.click()} onDragOver={(e) => { e.preventDefault(); setDragging(true); }} onDragLeave={() => setDragging(false)} onDrop={(e) => { e.preventDefault(); setDragging(false); const f = e.dataTransfer.files?.[0]; if (f) setBackupFile(f); }} className={`border-2 border-dashed rounded-lg px-4 py-8 text-center cursor-pointer transition-colors ${ dragging ? "border-blue-500 bg-blue-600/5" : "border-border hover:border-border-light bg-surface-1" }`} >

{t("import.backupHint")}

{t("import.backupSub")}

setBackupFile(e.target.files?.[0] || null)} className="hidden" />
{backupFile && (
{backupFile.name} ({formatBytes(backupFile.size)})
)}
)}
{/* In-flight progress */} {running && progressText && (
{progressText} {progress?.current && ( · {progress.current.split("/").slice(-2).join("/")} )}
)} {/* Errors */} {errorMsg && (
{errorMsg}
)} {/* Result summary */} {result && !running && (
{t("import.result.title")}
0 ? "text-status-danger" : "text-fg-muted"} />
{typeof result.files_scanned === "number" && (

{t("import.result.filesScanned", { count: result.files_scanned })} {result.path ? ` · ${result.path}` : ""}

)}
)} {/* Restore-from-backup result summary */} {backupResult && !running && (
{t("import.backupResult.title")}

{t("import.backupResult.detail", { agents: backupResult.agents, workflows: backupResult.workflows, runs: backupResult.dashboard_runs, rules: backupResult.alert_rules, })}

)}
); } function Step({ title, body, children, }: { title: string; body: string; children?: React.ReactNode; }) { return (

{title}

{body}

{children}
); } function ModeButton({ active, icon, title, desc, onClick, }: { active: boolean; icon: React.ReactNode; title: string; desc: string; onClick: () => void; }) { return ( ); } function ResultStat({ label, value, color }: { label: string; value: number; color: string }) { return (

{value.toLocaleString()}

{label}

); } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; }