Compare commits

..

2 Commits

Author SHA1 Message Date
47e7b65613 chore: replace arrow glyphs with ASCII and tidy whitespace 2026-06-08 09:31:44 +02:00
e5e7c571a4 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.
2026-06-08 09:31:35 +02:00
7 changed files with 382 additions and 34 deletions

View File

@ -180,7 +180,7 @@ sudo -u ghostgrid cat /opt/ghostgrid/.ssh/id_ed25519.pub
Add the public key in Gitea:
```text
Repository Settings Deploy Keys Add Deploy Key
Repository > Settings > Deploy Keys > Add Deploy Key
```
Keep the deploy key read-only.
@ -317,7 +317,7 @@ After installation or deployment, verify the following:
7. Verify the deployment flow:
```text
local change push to dev deploy.sh dev test merge to main deploy.sh main
local change > push to dev > deploy.sh dev > test > merge to main > deploy.sh main
```
## Manual Setup of the Staging Instance

View File

@ -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);

View File

@ -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<string, number> = {};
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<typeof DatabaseConstructor> | 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<string, unknown>[] = [];
try { rows = importDb!.prepare(`SELECT * FROM "${table}"`).all() as Record<string, unknown>[]; } 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
// -------------------------------------------------------------

View File

@ -271,7 +271,7 @@ export default function BookingCalendar({
))}
</div>
<p className="text-[10px] text-slate-500 font-mono">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {' '}
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} &gt;{' '}
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>

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,6 +484,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="space-y-6">
{/* Page header */}
<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" />
@ -412,6 +495,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<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>
<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 */}
{error && (
@ -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>
);
}

View File

@ -54,7 +54,7 @@
color: var(--text) !important;
}
/* ── Backgrounds: all dark hex variants card/inner */
/* ── Backgrounds: all dark hex variants > card/inner */
:root.light .bg-\[\#0B0F19\],
:root.light .bg-\[\#0b0f19\] {
background-color: var(--bg) !important;