feat(settings): add database panel with info, backup and import

Add a Database section under Settings (split into Integrations/System
tabs) showing SQLite file size, last-modified date, a proportional
table-usage bar and per-table row counts. Supports downloading a
consistent backup and importing a .db file that overwrites the entire
database, with an explicit overwrite warning and confirmation.

Backend adds GET /api/database/info, GET /api/database/backup and
POST /api/database/import; DB_FILE is now exported from server-db.
This commit is contained in:
Brückner
2026-06-08 09:31:35 +02:00
parent f1200425af
commit e5e7c571a4
3 changed files with 377 additions and 29 deletions

View File

@ -4,7 +4,7 @@ import { User } from '../types';
import {
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
Network, Trash2, Plus,
Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server,
} from 'lucide-react';
const SECRET_SENTINEL = '__SET__';
@ -37,6 +37,13 @@ interface CaddyRoute {
compress: number;
}
interface DbInfo {
sizeBytes: number;
lastModified: string;
tables: Record<string, number>;
path: string;
}
interface SettingsProps {
currentUser: User;
}
@ -149,6 +156,7 @@ function SectionCard({ accentColor, children }: {
export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [activeSection, setActiveSection] = useState<'integrations' | 'system'>('integrations');
const [error, setError] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const [copied, setCopied] = useState(false);
@ -192,8 +200,16 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [newTls, setNewTls] = useState(true);
const [newCompress, setNewCompress] = useState(true);
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
const [backingUp, setBackingUp] = useState(false);
const [importFile, setImportFile] = useState<File | null>(null);
const [importing, setImporting] = useState(false);
const [importConfirmed, setImportConfirmed] = useState(false);
const [importResult, setImportResult] = useState<{ ok: boolean; msg: string } | null>(null);
useEffect(() => {
loadSettings();
loadDbInfo();
fetch('/api/auth/config')
.then(r => r.json())
.then(d => { if (d.effectiveRedirectUri) setEffectiveRedirectUri(d.effectiveRedirectUri); })
@ -390,6 +406,72 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
}
}
async function loadDbInfo() {
try {
const res = await authFetch('/api/database/info');
if (res.ok) setDbInfo(await res.json());
} catch {}
}
async function handleBackup() {
setBackingUp(true);
setError('');
try {
const res = await authFetch('/api/database/backup');
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setError((d as any).error || 'Backup failed.');
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch {
setError('Network error during backup.');
} finally {
setBackingUp(false);
}
}
async function handleImport() {
if (!importFile || !importConfirmed) return;
setImporting(true);
setImportResult(null);
try {
const res = await authFetch('/api/database/import', {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: importFile,
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
setImportResult({ ok: false, msg: (d as any).error || 'Import failed.' });
return;
}
setImportResult({ ok: true, msg: 'Database imported successfully. Reload the page to reflect all changes.' });
setImportFile(null);
setImportConfirmed(false);
loadDbInfo();
loadSettings();
} catch {
setImportResult({ ok: false, msg: 'Network error during import.' });
} finally {
setImporting(false);
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
@ -402,15 +484,29 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="space-y-6">
{/* Page header */}
<div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
<Settings2 className="w-3 h-3" />
<span>SYSTEM</span>
<ChevronRight className="w-3 h-3" />
<span className="text-slate-400">SETTINGS</span>
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
<Settings2 className="w-3 h-3" />
<span>SYSTEM</span>
<ChevronRight className="w-3 h-3" />
<span className="text-slate-400">SETTINGS</span>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
</div>
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
<button
onClick={handleSave}
disabled={saving}
className="shrink-0 flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
>
{saving ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? 'Saving…' : 'Save Settings'}
</button>
</div>
{/* Feedback banners */}
@ -427,8 +523,31 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
)}
{/* Integration cards: three columns on large screens */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 items-start">
{/* Section tabs — switch between Integrations and System to keep the page light */}
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
{([
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
{ id: 'system', label: 'System', icon: <Server className="w-3.5 h-3.5" /> },
] as const).map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveSection(tab.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
activeSection === tab.id
? 'bg-cyan-950/40 border border-cyan-900/50 text-cyan-400'
: 'border border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800'
}`}
>
{tab.icon}
{tab.label}
</button>
))}
</div>
{/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */}
{activeSection === 'integrations' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* ── Microsoft Entra ID ── */}
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
@ -532,6 +651,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
)}
</SectionCard>
{/* Monitoring & automation, stacked together in the second column */}
<div className="space-y-6">
{/* ── CheckMK ── */}
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<div className="flex items-center justify-between">
@ -716,7 +838,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
</SectionCard>
</div>{/* end grid */}
</div>{/* end second column */}
</div>
)}
{/* ── System ── */}
{activeSection === 'system' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* ── Caddy Reverse Proxy ── */}
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
@ -836,22 +965,154 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
</SectionCard>
{/* Save bar */}
<div className="flex items-center justify-between pt-2 border-t border-slate-800/60">
<p className="text-[11px] text-slate-600">Changes are applied after saving and a server restart.</p>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
>
{saving ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
{saving ? 'Saving…' : 'Save Settings'}
</button>
{/* ── Database ── */}
<SectionCard accentColor="bg-gradient-to-r from-violet-600 to-purple-600">
{/* Header: icon + title + file size */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-violet-950/60 border border-violet-900/40 rounded-xl">
<HardDrive className="w-4 h-4 text-violet-400" />
</div>
<div>
<h2 className="text-sm font-semibold text-white">Database</h2>
<p className="text-[11px] text-slate-500 mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
</div>
</div>
<div className="text-right">
<p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'}
</p>
<p className="text-[10px] text-slate-500 font-mono mt-1">
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
</p>
</div>
</div>
<div className="h-px bg-slate-800/60" />
{/* Proportional usage bar + table stats */}
{dbInfo ? (() => {
const tableEntries = Object.entries(dbInfo.tables) as [string, number][];
const total = tableEntries.reduce((sum, [, n]) => sum + n, 0);
const palette: Record<string, string> = {
users: 'bg-blue-500', devices: 'bg-emerald-500', labs: 'bg-orange-500',
bookings: 'bg-cyan-500', logs: 'bg-slate-400', links: 'bg-violet-500',
settings: 'bg-slate-600', caddy: 'bg-sky-500',
};
return (
<div className="space-y-3">
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
{total === 0
? <div className="flex-1 bg-slate-700" />
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
<div
key={t}
title={`${t}: ${n}`}
className={`${palette[t] ?? 'bg-slate-500'} transition-all`}
style={{ width: `${(n / total) * 100}%` }}
/>
))
}
</div>
<div className="grid grid-cols-4 gap-1.5">
{tableEntries.map(([t, n]) => (
<div key={t} className="bg-slate-900/50 border border-slate-700/60 rounded-lg px-2 py-1.5">
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
<p className="text-[8px] font-semibold text-slate-600 uppercase tracking-wide truncate">{t}</p>
<p className="text-sm font-bold text-white font-mono">{n}</p>
</div>
))}
</div>
</div>
);
})() : (
<div className="h-16 flex items-center justify-center">
<span className="w-4 h-4 border-2 border-slate-700 border-t-violet-400 rounded-full animate-spin" />
</div>
)}
<div className="h-px bg-slate-800/60" />
{/* Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Backup */}
<div className="space-y-2">
<Label>Backup</Label>
<button
type="button"
onClick={handleBackup}
disabled={backingUp}
className="w-full flex items-center justify-center gap-2 bg-violet-950/60 hover:bg-violet-900/40 border border-violet-900/50 text-violet-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
{backingUp ? 'Creating backup…' : 'Download Backup'}
</button>
<Hint>Downloads a consistent snapshot of the SQLite database.</Hint>
</div>
{/* Import */}
<div className="space-y-2">
<Label>Import</Label>
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400 shrink-0 mt-0.5" />
<p className="text-[11px] text-amber-300 leading-relaxed">
<strong>Import overwrites the entire database</strong> this cannot be undone.
</p>
</div>
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
<Upload className="w-3.5 h-3.5 shrink-0" />
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
<input
type="file"
accept=".db"
className="sr-only"
onChange={e => {
setImportFile(e.target.files?.[0] ?? null);
setImportConfirmed(false);
setImportResult(null);
}}
/>
</label>
{importFile && (
<div className="space-y-2">
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={importConfirmed}
onChange={e => setImportConfirmed(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-violet-500"
/>
<span className="text-[11px] text-slate-400">I confirm this will overwrite all existing data</span>
</label>
<button
type="button"
onClick={handleImport}
disabled={importing || !importConfirmed}
className="w-full flex items-center justify-center gap-2 bg-red-950/60 hover:bg-red-900/40 border border-red-900/50 text-red-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{importing
? <span className="w-3.5 h-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
: <Upload className="w-3.5 h-3.5" />
}
{importing ? 'Importing…' : 'Import Database'}
</button>
</div>
)}
{importResult && (
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
{importResult.msg}
</p>
)}
</div>
</div>
</SectionCard>
</div>
)}
</div>
);
}