From e5e7c571a4a7af64ac6a2433261ea38bf365ab52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Mon, 8 Jun 2026 09:31:35 +0200 Subject: [PATCH] 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. --- server-db.ts | 2 +- server.ts | 89 +++++++++- src/components/Settings.tsx | 315 ++++++++++++++++++++++++++++++++---- 3 files changed, 377 insertions(+), 29 deletions(-) diff --git a/server-db.ts b/server-db.ts index 2d92be1..b0afe87 100644 --- a/server-db.ts +++ b/server-db.ts @@ -1,7 +1,7 @@ import Database from 'better-sqlite3'; import path from 'path'; -const DB_FILE = path.join(process.cwd(), 'ghostgrid.db'); +export const DB_FILE = path.join(process.cwd(), 'ghostgrid.db'); console.log(`[Database] Connecting to SQLite database at: ${DB_FILE}`); const db = new Database(DB_FILE); diff --git a/server.ts b/server.ts index 65f0f6e..91460ee 100644 --- a/server.ts +++ b/server.ts @@ -1,11 +1,13 @@ import 'dotenv/config'; import express, { Request, Response, NextFunction } from 'express'; import path from 'path'; +import fs from 'fs'; import { createServer as createViteServer } from 'vite'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; +import DatabaseConstructor from 'better-sqlite3'; import { ConfidentialClientApplication } from '@azure/msal-node'; -import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, deleteCaddyRoute } from './server-db'; +import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, deleteCaddyRoute, DB_FILE } from './server-db'; import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types'; const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; @@ -730,6 +732,91 @@ async function startServer() { } }); + // ------------------------------------------------------------- + // DATABASE API + // ------------------------------------------------------------- + app.get('/api/database/info', requireAuth, (_req, res) => { + try { + const stats = fs.statSync(DB_FILE); + const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy']; + const counts: Record = {}; + for (const t of tables) { + counts[t] = (db.prepare(`SELECT COUNT(*) as n FROM "${t}"`).get() as { n: number }).n; + } + res.json({ + sizeBytes: stats.size, + lastModified: stats.mtime.toISOString(), + tables: counts, + path: path.basename(DB_FILE), + }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } + }); + + app.get('/api/database/backup', requireAuth, async (_req, res) => { + const tempPath = `${DB_FILE}.backup-${Date.now()}`; + try { + await db.backup(tempPath); + const filename = `ghostgrid-backup-${new Date().toISOString().slice(0, 10)}.db`; + res.download(tempPath, filename, () => { + fs.unlink(tempPath, () => {}); + }); + } catch (err: any) { + fs.unlink(tempPath, () => {}); + if (!res.headersSent) res.status(500).json({ error: err.message }); + } + }); + + app.post('/api/database/import', requireAuth, + express.raw({ type: 'application/octet-stream', limit: '50mb' }), + (req, res) => { + const tempPath = `${DB_FILE}.import-${Date.now()}`; + try { + const buf = req.body as Buffer; + if (!Buffer.isBuffer(buf) || buf.length < 16) { + return res.status(400).json({ error: 'No file data received.' }); + } + // Validate SQLite magic header: "SQLite format 3\0" + if (buf.slice(0, 16).toString('latin1') !== 'SQLite format 3\x00') { + return res.status(400).json({ error: 'Not a valid SQLite database file.' }); + } + fs.writeFileSync(tempPath, buf); + + let importDb: InstanceType | null = null; + try { + importDb = new DatabaseConstructor(tempPath, { readonly: true }); + const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy']; + + db.transaction(() => { + for (const table of tables) { + const schemaCols = (db.prepare(`PRAGMA table_info("${table}")`).all() as { name: string }[]).map(c => c.name); + db.prepare(`DELETE FROM "${table}"`).run(); + let rows: Record[] = []; + try { rows = importDb!.prepare(`SELECT * FROM "${table}"`).all() as Record[]; } catch { continue; } + if (rows.length === 0) continue; + const importCols = Object.keys(rows[0]); + const cols = schemaCols.filter(c => importCols.includes(c)); + if (cols.length === 0) continue; + const stmt = db.prepare( + `INSERT INTO "${table}" (${cols.map(c => `"${c}"`).join(', ')}) VALUES (${cols.map(() => '?').join(', ')})` + ); + for (const row of rows) stmt.run(cols.map(c => row[c])); + } + })(); + + res.json({ ok: true }); + } finally { + importDb?.close(); + fs.unlink(tempPath, () => {}); + } + } catch (err: any) { + fs.unlink(tempPath, () => {}); + res.status(500).json({ error: err.message }); + } + } + ); + // ------------------------------------------------------------- // VITE / STATIC SERVING // ------------------------------------------------------------- diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 8e8abf1..1710726 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -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; + 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(null); + const [backingUp, setBackingUp] = useState(false); + const [importFile, setImportFile] = useState(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 (
@@ -402,15 +484,29 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* Page header */} -
-
- - SYSTEM - - SETTINGS +
+
+
+ + SYSTEM + + SETTINGS +
+

Settings

+

Configure integrations and authentication providers.

-

Settings

-

Configure integrations and authentication providers.

+
{/* Feedback banners */} @@ -427,8 +523,31 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
)} - {/* Integration cards: three columns on large screens */} -
+ {/* Section tabs — switch between Integrations and System to keep the page light */} +
+ {([ + { id: 'integrations', label: 'Integrations', icon: }, + { id: 'system', label: 'System', icon: }, + ] as const).map(tab => ( + + ))} +
+ + {/* ── Integrations: Azure in column one, monitoring & automation stacked in column two ── */} + {activeSection === 'integrations' && ( +
{/* ── Microsoft Entra ID ── */} @@ -532,6 +651,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) { )} + {/* Monitoring & automation, stacked together in the second column */} +
+ {/* ── CheckMK ── */}
@@ -716,7 +838,14 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
-
{/* end grid */} +
{/* end second column */} + +
+ )} + + {/* ── System ── */} + {activeSection === 'system' && ( +
{/* ── Caddy Reverse Proxy ── */} @@ -836,22 +965,154 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
- {/* Save bar */} -
-

Changes are applied after saving and a server restart.

- + {/* ── Database ── */} + + + {/* Header: icon + title + file size */} +
+
+
+ +
+
+

Database

+

SQLite · {dbInfo?.path ?? 'ghostgrid.db'}

+
+
+
+

+ {dbInfo ? formatBytes(dbInfo.sizeBytes) : '—'} +

+

+ {dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'} +

+
+
+ +
+ + {/* 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 = { + 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 ( +
+
+ {total === 0 + ?
+ : tableEntries.filter(([, n]) => n > 0).map(([t, n]) => ( +
+ )) + } +
+
+ {tableEntries.map(([t, n]) => ( +
+
+

{t}

+

{n}

+
+ ))} +
+
+ ); + })() : ( +
+ +
+ )} + +
+ + {/* Actions */} +
+ + {/* Backup */} +
+ + + Downloads a consistent snapshot of the SQLite database. +
+ + {/* Import */} +
+ +
+ +

+ Import overwrites the entire database — this cannot be undone. +

+
+ + {importFile && ( +
+ + +
+ )} + {importResult && ( +

+ {importResult.msg} +

+ )} +
+ +
+ +
+ )} +
); }