Files
Claude-Code-Monitor/client/src/components/ImportHistory.tsx
T
nntrivi2001 b673363351 feat(theme): dark/light mode with a Radix Colors-based palette
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.
2026-07-31 10:54:31 +07:00

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`;
}