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

@ -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
// -------------------------------------------------------------