b673363351
Adds a working Dark/Light toggle (next to the language switcher, same row
as EN/VI) and re-themes the whole dashboard, not just the handful of
components that already used semantic tokens.
- Tailwind darkMode:"class" + CSS-variable color tokens (client/src/index.css,
tailwind.config.js): surface.0-5, border/border-light, accent/accent-hover,
fg.primary/secondary/muted, status.success/danger/warning. One class flip
on <html> re-themes everything — no per-element dark: variant pairs.
- useTheme() hook: localStorage-persisted, defaults to dark, no
prefers-color-scheme fallback (client/src/hooks/useTheme.ts).
- Mechanical, table-driven migration (scripts/migrate-color-tokens.mjs,
scripts/tokenize-status-colors.mjs, scripts/darken-status-colors.mjs) of
every raw neutral/gray/slate + emerald/red/amber Tailwind utility across
client/src onto the new tokens, so every badge/button/component pulls the
same shade per status/role instead of each picking its own.
- Palette values are the literal Radix Colors (radix-ui.com/colors) scale
constants — slate/blue/green/red/amber steps 1-12 — adopted after three
rounds of hand-picked values that kept overshooting (flat, then too dark,
then glaring); see docs/superpowers/specs/2026-07-31-color-redesign-
dark-light-mode-design.md for the full history and role mapping.
- PipelineMap: done/current/failed/passed-no-evidence/detected share one
visual language (border + text + translucent wash of the same status
color); `current` alone stays a solid accent fill, the one state that
gets to look bolder ("you are here").
- LaneCard: removed the stage/kind/auto-stage chips that duplicated the
Workspace lane-detail header already showing them.
Categorical/decorative hues (violet, indigo, cyan, teal, sky, rose, pink,
orange, yellow, and blue where it plays a role-coloring part e.g. message
bubbles) are deliberately out of scope — collapsing those onto shared
tokens would erase the distinction between different kinds of thing, not
a status.
772 lines
30 KiB
TypeScript
772 lines
30 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
/* =============================================================================
|
|
* 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<ReturnType<typeof api.import.guide>>;
|
|
type Progress = ImportProgressMessage;
|
|
|
|
export function ImportHistory() {
|
|
const { t } = useTranslation("settings");
|
|
const [mode, setMode] = useState<Mode>("rescan");
|
|
const [guide, setGuide] = useState<GuideResponse | null>(null);
|
|
const [folderPath, setFolderPath] = useState("");
|
|
const [files, setFiles] = useState<File[]>([]);
|
|
const [running, setRunning] = useState(false);
|
|
const [progress, setProgress] = useState<Progress | null>(null);
|
|
const [result, setResult] = useState<ImportResult | null>(null);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [instructionsOpen, setInstructionsOpen] = useState(true);
|
|
const [copied, setCopied] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
const fileInputRef = useRef<HTMLInputElement | null>(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<File | null>(null);
|
|
const [backupResult, setBackupResult] = useState<ImportBackupResult | null>(null);
|
|
const backupInputRef = useRef<HTMLInputElement | null>(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 (
|
|
<section>
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<History className="w-4 h-4 text-fg-muted" />
|
|
{t("import.title")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-1">{t("import.description")}</p>
|
|
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
|
|
|
<div className="card p-5 space-y-5">
|
|
{/* Step-by-step instructions */}
|
|
<div className="border border-border rounded-lg overflow-hidden">
|
|
<button
|
|
onClick={() => setInstructionsOpen((v) => !v)}
|
|
className="w-full flex items-center justify-between px-4 py-3 bg-surface-2 hover:bg-surface-3 transition-colors"
|
|
>
|
|
<span className="flex items-center gap-2 text-xs font-semibold text-fg-secondary uppercase tracking-wider">
|
|
<ListChecks className="w-3.5 h-3.5 text-blue-500" />
|
|
{t("import.instructions")}
|
|
</span>
|
|
<span className="text-[11px] text-fg-muted">{instructionsOpen ? "▾" : "▸"}</span>
|
|
</button>
|
|
{instructionsOpen && (
|
|
<div className="px-4 py-4 space-y-4 text-sm text-fg-secondary bg-surface-1">
|
|
{/* Default location card */}
|
|
{guide && (
|
|
<div className="flex flex-wrap items-center gap-2 text-xs bg-surface-2 border border-border rounded-md px-3 py-2">
|
|
<HardDrive className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
|
<span className="text-fg-secondary">{t("import.defaultLocation")}:</span>
|
|
<code className="font-mono text-fg-secondary truncate">
|
|
{guide.default_projects_dir_display}
|
|
</code>
|
|
{guide.default_projects_dir_exists ? (
|
|
<span className="inline-flex items-center gap-1 text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
|
<CheckCircle2 className="w-3 h-3" />
|
|
{t("import.locationFound")}
|
|
<span className="text-fg-muted ml-1">
|
|
· {guide.default_projects_dir_stats.projects} {t("import.projectsLabel")},{" "}
|
|
{guide.default_projects_dir_stats.jsonl_files} {t("import.jsonlLabel")}
|
|
</span>
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1 text-status-warning bg-status-warning/10 border border-status-warning/20 px-2 py-0.5 rounded-full">
|
|
<AlertTriangle className="w-3 h-3" />
|
|
{t("import.locationMissing")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Steps */}
|
|
<div className="space-y-3">
|
|
<Step title={t("import.stepLocate")} body={t("import.stepLocateBody")} />
|
|
<Step title={t("import.stepArchive")} body={t("import.stepArchiveBody")}>
|
|
{guide && (
|
|
<div className="mt-2 flex items-center gap-2 bg-surface-2 border border-border rounded-md px-3 py-2">
|
|
<Terminal className="w-3.5 h-3.5 text-fg-muted flex-shrink-0" />
|
|
<code className="flex-1 text-xs font-mono text-fg-secondary truncate">
|
|
{guide.archive_command}
|
|
</code>
|
|
<button
|
|
onClick={copyArchiveCmd}
|
|
className="text-xs text-fg-secondary hover:text-fg-primary flex items-center gap-1 flex-shrink-0"
|
|
>
|
|
{copied ? (
|
|
<>
|
|
<Check className="w-3 h-3" /> {t("import.copied")}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Copy className="w-3 h-3" /> {t("import.copy")}
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</Step>
|
|
<Step title={t("import.stepChoose")} body={t("import.stepChooseBody")} />
|
|
<Step title={t("import.stepVerify")} body={t("import.stepVerifyBody")} />
|
|
</div>
|
|
|
|
<div className="text-[11px] text-fg-muted flex items-start gap-2 pt-2 border-t border-border">
|
|
<Info className="w-3 h-3 mt-0.5 flex-shrink-0" />
|
|
<span>{t("import.accuracyNote")}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mode switcher */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-2">
|
|
<ModeButton
|
|
active={mode === "rescan"}
|
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
|
title={t("import.modeRescan")}
|
|
desc={t("import.modeRescanDesc")}
|
|
onClick={() => setMode("rescan")}
|
|
/>
|
|
<ModeButton
|
|
active={mode === "path"}
|
|
icon={<FolderInput className="w-3.5 h-3.5" />}
|
|
title={t("import.modeFolder")}
|
|
desc={t("import.modeFolderDesc")}
|
|
onClick={() => setMode("path")}
|
|
/>
|
|
<ModeButton
|
|
active={mode === "upload"}
|
|
icon={<UploadCloud className="w-3.5 h-3.5" />}
|
|
title={t("import.modeUpload")}
|
|
desc={t("import.modeUploadDesc")}
|
|
onClick={() => setMode("upload")}
|
|
/>
|
|
<ModeButton
|
|
active={mode === "backup"}
|
|
icon={<DatabaseBackup className="w-3.5 h-3.5" />}
|
|
title={t("import.modeBackup")}
|
|
desc={t("import.modeBackupDesc")}
|
|
onClick={() => setMode("backup")}
|
|
/>
|
|
</div>
|
|
|
|
{/* Mode panel */}
|
|
<div className="bg-surface-2 border border-border rounded-lg p-4">
|
|
{mode === "rescan" && (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<FolderOpen className="w-4 h-4 text-fg-muted flex-shrink-0" />
|
|
<code className="font-mono text-xs text-fg-secondary truncate">
|
|
{guide?.default_projects_dir_display || "~/.claude/projects"}
|
|
</code>
|
|
</div>
|
|
<button
|
|
onClick={handleRescan}
|
|
disabled={running}
|
|
className="btn-primary text-xs disabled:opacity-50"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("import.runRescan")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "path" && (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<input
|
|
type="text"
|
|
value={folderPath}
|
|
onChange={(e) => setFolderPath(e.target.value)}
|
|
placeholder={t("import.folderPlaceholder")}
|
|
className="input w-full text-sm font-mono"
|
|
spellCheck={false}
|
|
/>
|
|
<p className="text-[11px] text-fg-muted mt-1.5">{t("import.folderHelper")}</p>
|
|
</div>
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleScanPath}
|
|
disabled={running}
|
|
className="btn-primary text-xs disabled:opacity-50"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<FolderInput className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("import.runScan")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "upload" && (
|
|
<div className="space-y-3">
|
|
<div
|
|
onClick={() => 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"
|
|
}`}
|
|
>
|
|
<UploadCloud className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
|
<p className="text-sm text-fg-secondary">{t("import.dropzoneHint")}</p>
|
|
<p className="text-[11px] text-fg-muted mt-1">{t("import.dropzoneSub")}</p>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
accept=".jsonl,.json,.zip,.tar,.tgz,.gz,application/gzip,application/zip,application/x-tar,application/octet-stream"
|
|
onChange={(e) => onSelectFiles(e.target.files)}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
{files.length > 0 && (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
|
<span className="text-fg-secondary">
|
|
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
|
|
{t("import.filesSelected", { count: files.length })}
|
|
<span className="text-fg-muted ml-2">({formatBytes(totalSize)})</span>
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setFiles([]);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
}}
|
|
className="text-fg-muted hover:text-fg-secondary text-[11px]"
|
|
>
|
|
{t("import.clearSelection")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleUpload}
|
|
disabled={running || files.length === 0}
|
|
className="btn-primary text-xs disabled:opacity-50"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<UploadCloud className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("import.runUpload")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{mode === "backup" && (
|
|
<div className="space-y-3">
|
|
<div
|
|
onClick={() => 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"
|
|
}`}
|
|
>
|
|
<DatabaseBackup className="w-6 h-6 text-fg-muted mx-auto mb-2" />
|
|
<p className="text-sm text-fg-secondary">{t("import.backupHint")}</p>
|
|
<p className="text-[11px] text-fg-muted mt-1">{t("import.backupSub")}</p>
|
|
<input
|
|
ref={backupInputRef}
|
|
type="file"
|
|
accept=".json,application/json"
|
|
onChange={(e) => setBackupFile(e.target.files?.[0] || null)}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
{backupFile && (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 text-xs bg-surface-3 rounded-md px-3 py-2">
|
|
<span className="text-fg-secondary min-w-0">
|
|
<FileArchive className="w-3.5 h-3.5 inline mr-1.5 text-fg-muted" />
|
|
<span className="font-mono truncate">{backupFile.name}</span>
|
|
<span className="text-fg-muted ml-2">({formatBytes(backupFile.size)})</span>
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
setBackupFile(null);
|
|
if (backupInputRef.current) backupInputRef.current.value = "";
|
|
}}
|
|
className="text-fg-muted hover:text-fg-secondary text-[11px]"
|
|
>
|
|
{t("import.clearSelection")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={handleRestore}
|
|
disabled={running || !backupFile}
|
|
className="btn-primary text-xs disabled:opacity-50"
|
|
>
|
|
{running ? (
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("import.runRestore")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* In-flight progress */}
|
|
{running && progressText && (
|
|
<div className="flex items-center gap-2 text-xs text-fg-secondary bg-surface-2 border border-border rounded-md px-3 py-2">
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin text-blue-500 flex-shrink-0" />
|
|
<span className="truncate">{progressText}</span>
|
|
{progress?.current && (
|
|
<code className="font-mono text-[11px] text-fg-muted truncate">
|
|
· {progress.current.split("/").slice(-2).join("/")}
|
|
</code>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Errors */}
|
|
{errorMsg && (
|
|
<div className="flex items-start gap-2 text-xs text-status-danger bg-status-danger/10 border border-status-danger/20 rounded-md px-3 py-2">
|
|
<XCircle className="w-3.5 h-3.5 mt-0.5 flex-shrink-0" />
|
|
<span>{errorMsg}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result summary */}
|
|
{result && !running && (
|
|
<div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
{t("import.result.title")}
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
<ResultStat
|
|
label={t("import.result.imported", { count: result.imported })}
|
|
value={result.imported}
|
|
color="text-status-success"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.result.backfilled", { count: result.backfilled ?? 0 })}
|
|
value={result.backfilled ?? 0}
|
|
color="text-blue-400"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.result.skipped", { count: result.skipped })}
|
|
value={result.skipped}
|
|
color="text-fg-secondary"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.result.errors", { count: result.errors })}
|
|
value={result.errors}
|
|
color={result.errors > 0 ? "text-status-danger" : "text-fg-muted"}
|
|
/>
|
|
</div>
|
|
{typeof result.files_scanned === "number" && (
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t("import.result.filesScanned", { count: result.files_scanned })}
|
|
{result.path ? ` · ${result.path}` : ""}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Restore-from-backup result summary */}
|
|
{backupResult && !running && (
|
|
<div className="border border-status-success/20 bg-status-success/5 rounded-lg px-4 py-3 space-y-2">
|
|
<div className="flex items-center gap-2 text-xs font-semibold text-status-success uppercase tracking-wider">
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
{t("import.backupResult.title")}
|
|
</div>
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
|
<ResultStat
|
|
label={t("import.backupResult.sessionsImported", {
|
|
count: backupResult.sessions_imported,
|
|
})}
|
|
value={backupResult.sessions_imported}
|
|
color="text-status-success"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.backupResult.sessionsSkipped", {
|
|
count: backupResult.sessions_skipped,
|
|
})}
|
|
value={backupResult.sessions_skipped}
|
|
color="text-fg-secondary"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.backupResult.events", { count: backupResult.events })}
|
|
value={backupResult.events}
|
|
color="text-violet-300"
|
|
/>
|
|
<ResultStat
|
|
label={t("import.backupResult.pricing", { count: backupResult.model_pricing })}
|
|
value={backupResult.model_pricing}
|
|
color="text-cyan-300"
|
|
/>
|
|
</div>
|
|
<p className="text-[11px] text-fg-muted">
|
|
{t("import.backupResult.detail", {
|
|
agents: backupResult.agents,
|
|
workflows: backupResult.workflows,
|
|
runs: backupResult.dashboard_runs,
|
|
rules: backupResult.alert_rules,
|
|
})}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Step({
|
|
title,
|
|
body,
|
|
children,
|
|
}: {
|
|
title: string;
|
|
body: string;
|
|
children?: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<p className="text-sm font-medium text-fg-secondary">{title}</p>
|
|
<p className="text-xs text-fg-secondary mt-1 whitespace-pre-line">{body}</p>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ModeButton({
|
|
active,
|
|
icon,
|
|
title,
|
|
desc,
|
|
onClick,
|
|
}: {
|
|
active: boolean;
|
|
icon: React.ReactNode;
|
|
title: string;
|
|
desc: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={`text-left p-3 rounded-lg border transition-colors ${
|
|
active
|
|
? "border-blue-600/40 bg-blue-600/10"
|
|
: "border-border bg-surface-2 hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<div
|
|
className={`flex items-center gap-1.5 text-xs font-medium mb-1 ${
|
|
active ? "text-blue-400" : "text-fg-secondary"
|
|
}`}
|
|
>
|
|
{icon}
|
|
{title}
|
|
</div>
|
|
<p className="text-[11px] text-fg-muted leading-snug">{desc}</p>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ResultStat({ label, value, color }: { label: string; value: number; color: string }) {
|
|
return (
|
|
<div className="bg-surface-2 rounded-md px-2.5 py-2">
|
|
<p className={`text-sm font-semibold ${color}`}>{value.toLocaleString()}</p>
|
|
<p className="text-[10px] text-fg-muted uppercase tracking-wider mt-0.5">{label}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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`;
|
|
}
|