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:
89
server.ts
89
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<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
|
||||
// -------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user