232 lines
9.5 KiB
TypeScript
232 lines
9.5 KiB
TypeScript
import { useState, useEffect } from "react";
|
||
import { useVault } from "../App";
|
||
import {
|
||
scanIntegrity,
|
||
computeChecksums,
|
||
verifyChecksums,
|
||
findOrphanAttachments,
|
||
createBackup,
|
||
listBackups,
|
||
restoreBackup,
|
||
type IntegrityIssue,
|
||
type ChecksumMismatch,
|
||
type OrphanAttachment,
|
||
type BackupEntry,
|
||
} from "../lib/commands";
|
||
|
||
export default function IntegrityReport({ onClose }: { onClose: () => void }) {
|
||
const { vaultPath } = useVault();
|
||
const [issues, setIssues] = useState<IntegrityIssue[]>([]);
|
||
const [mismatches, setMismatches] = useState<ChecksumMismatch[]>([]);
|
||
const [orphans, setOrphans] = useState<OrphanAttachment[]>([]);
|
||
const [backups, setBackups] = useState<BackupEntry[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<"scan" | "checksums" | "orphans" | "backups">("scan");
|
||
const [status, setStatus] = useState("");
|
||
|
||
useEffect(() => {
|
||
runScan();
|
||
loadBackups();
|
||
}, [vaultPath]);
|
||
|
||
const runScan = async () => {
|
||
if (!vaultPath) return;
|
||
setLoading(true);
|
||
setStatus("Scanning vault...");
|
||
try {
|
||
const result = await scanIntegrity(vaultPath);
|
||
setIssues(result);
|
||
setStatus(`Found ${result.length} issue(s)`);
|
||
} catch (e) {
|
||
setStatus(`Scan failed: ${e}`);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const runChecksumVerify = async () => {
|
||
if (!vaultPath) return;
|
||
setLoading(true);
|
||
setStatus("Computing checksums...");
|
||
try {
|
||
await computeChecksums(vaultPath);
|
||
const result = await verifyChecksums(vaultPath);
|
||
setMismatches(result);
|
||
setStatus(result.length === 0 ? "All checksums valid ✓" : `${result.length} mismatch(es) found`);
|
||
} catch (e) {
|
||
setStatus(`Checksum verification failed: ${e}`);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const runOrphanScan = async () => {
|
||
if (!vaultPath) return;
|
||
setLoading(true);
|
||
setStatus("Scanning attachments...");
|
||
try {
|
||
const result = await findOrphanAttachments(vaultPath);
|
||
setOrphans(result);
|
||
setStatus(result.length === 0 ? "No orphan attachments ✓" : `${result.length} orphan(s) found`);
|
||
} catch (e) {
|
||
setStatus(`Orphan scan failed: ${e}`);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const handleCreateBackup = async () => {
|
||
if (!vaultPath) return;
|
||
setLoading(true);
|
||
setStatus("Creating backup...");
|
||
try {
|
||
const name = await createBackup(vaultPath);
|
||
setStatus(`Backup created: ${name}`);
|
||
loadBackups();
|
||
} catch (e) {
|
||
setStatus(`Backup failed: ${e}`);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const loadBackups = async () => {
|
||
if (!vaultPath) return;
|
||
try {
|
||
const list = await listBackups(vaultPath);
|
||
setBackups(list);
|
||
} catch { /* ignore */ }
|
||
};
|
||
|
||
const handleRestore = async (name: string) => {
|
||
if (!vaultPath) return;
|
||
if (!confirm(`Restore from ${name}? This will overwrite current files.`)) return;
|
||
setLoading(true);
|
||
try {
|
||
const count = await restoreBackup(vaultPath, name);
|
||
setStatus(`Restored ${count} files from ${name}`);
|
||
} catch (e) {
|
||
setStatus(`Restore failed: ${e}`);
|
||
}
|
||
setLoading(false);
|
||
};
|
||
|
||
const formatSize = (bytes: number) => {
|
||
if (bytes < 1024) return `${bytes} B`;
|
||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||
};
|
||
|
||
const severityIcon = (s: string) => s === "error" ? "🔴" : s === "warning" ? "🟡" : "🔵";
|
||
|
||
return (
|
||
<div className="integrity-report">
|
||
<div className="integrity-header">
|
||
<h3>🛡️ Integrity Report</h3>
|
||
<button className="panel-close-btn" onClick={onClose}>✕</button>
|
||
</div>
|
||
|
||
<div className="integrity-tabs">
|
||
{(["scan", "checksums", "orphans", "backups"] as const).map((tab) => (
|
||
<button
|
||
key={tab}
|
||
className={`integrity-tab ${activeTab === tab ? "active" : ""}`}
|
||
onClick={() => {
|
||
setActiveTab(tab);
|
||
if (tab === "checksums") runChecksumVerify();
|
||
if (tab === "orphans") runOrphanScan();
|
||
}}
|
||
>
|
||
{tab === "scan" ? "🔍 Scan" : tab === "checksums" ? "🔐 Checksums"
|
||
: tab === "orphans" ? "📎 Orphans" : "💾 Backups"}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{status && <div className="integrity-status">{loading ? "⏳ " : ""}{status}</div>}
|
||
|
||
<div className="integrity-body">
|
||
{activeTab === "scan" && (
|
||
<>
|
||
<button className="integrity-action-btn" onClick={runScan} disabled={loading}>Re-scan Vault</button>
|
||
{issues.length === 0 && !loading && (
|
||
<div className="integrity-empty">✅ No issues found — vault is clean</div>
|
||
)}
|
||
{issues.map((issue, i) => (
|
||
<div key={i} className="integrity-issue">
|
||
<span className="integrity-severity">{severityIcon(issue.severity)}</span>
|
||
<div className="integrity-issue-body">
|
||
<span className="integrity-issue-path">{issue.path}</span>
|
||
<span className="integrity-issue-desc">{issue.description}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "checksums" && (
|
||
<>
|
||
<button className="integrity-action-btn" onClick={runChecksumVerify} disabled={loading}>
|
||
Recompute & Verify
|
||
</button>
|
||
{mismatches.length === 0 && !loading && (
|
||
<div className="integrity-empty">✅ All checksums match</div>
|
||
)}
|
||
{mismatches.map((m, i) => (
|
||
<div key={i} className="integrity-issue">
|
||
<span className="integrity-severity">⚠️</span>
|
||
<div className="integrity-issue-body">
|
||
<span className="integrity-issue-path">{m.path}</span>
|
||
<span className="integrity-issue-desc">
|
||
Expected: {m.expected.slice(0, 12)}… → Got: {m.actual.slice(0, 12)}…
|
||
</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "orphans" && (
|
||
<>
|
||
<button className="integrity-action-btn" onClick={runOrphanScan} disabled={loading}>
|
||
Rescan Orphans
|
||
</button>
|
||
{orphans.length === 0 && !loading && (
|
||
<div className="integrity-empty">✅ No orphan attachments</div>
|
||
)}
|
||
{orphans.map((o, i) => (
|
||
<div key={i} className="integrity-issue">
|
||
<span className="integrity-severity">📎</span>
|
||
<div className="integrity-issue-body">
|
||
<span className="integrity-issue-path">{o.path}</span>
|
||
<span className="integrity-issue-desc">{formatSize(o.size)}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
|
||
{activeTab === "backups" && (
|
||
<>
|
||
<button className="integrity-action-btn" onClick={handleCreateBackup} disabled={loading}>
|
||
Create Backup Now
|
||
</button>
|
||
{backups.length === 0 && !loading && (
|
||
<div className="integrity-empty">No backups yet</div>
|
||
)}
|
||
{backups.map((b, i) => (
|
||
<div key={i} className="integrity-issue">
|
||
<span className="integrity-severity">💾</span>
|
||
<div className="integrity-issue-body">
|
||
<span className="integrity-issue-path">{b.name}</span>
|
||
<span className="integrity-issue-desc">
|
||
{b.created} · {formatSize(b.size)}
|
||
</span>
|
||
</div>
|
||
<button className="integrity-restore-btn" onClick={() => handleRestore(b.name)}>
|
||
Restore
|
||
</button>
|
||
</div>
|
||
))}
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|