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