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:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user