feat(caddy): route edit, system log entries, fix routes load timing
Add inline edit for custom routes (Pencil icon → inline form with all fields). Log route add/update/delete/import to the logs table (type: system) so operations appear in the Logbook. Fix loadCaddyRoutes() called without await after settings save, causing a race between the success message and route list.
This commit is contained in:
10
server-db.ts
10
server-db.ts
@ -153,8 +153,18 @@ export function addCaddyRoute(hostname: string, upstream: string, tls: boolean,
|
|||||||
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
|
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(lastInsertRowid) as CaddyRoute;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function updateCaddyRoute(id: number, hostname: string, upstream: string, tls: boolean, compress: boolean): CaddyRoute {
|
||||||
|
db.prepare('UPDATE caddy SET hostname = ?, upstream = ?, tls = ?, compress = ? WHERE id = ?')
|
||||||
|
.run(hostname, upstream, tls ? 1 : 0, compress ? 1 : 0, id);
|
||||||
|
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute;
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteCaddyRoute(id: number): void {
|
export function deleteCaddyRoute(id: number): void {
|
||||||
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
|
db.prepare('DELETE FROM caddy WHERE id = ?').run(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getCaddyRouteById(id: number): CaddyRoute | undefined {
|
||||||
|
return db.prepare('SELECT * FROM caddy WHERE id = ?').get(id) as CaddyRoute | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
45
server.ts
45
server.ts
@ -7,7 +7,7 @@ import bcrypt from 'bcryptjs';
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import DatabaseConstructor from 'better-sqlite3';
|
import DatabaseConstructor from 'better-sqlite3';
|
||||||
import { ConfidentialClientApplication } from '@azure/msal-node';
|
import { ConfidentialClientApplication } from '@azure/msal-node';
|
||||||
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, deleteCaddyRoute, DB_FILE } from './server-db';
|
import db, { getSetting, setSetting, getAllSettings, getCaddyRoutes, addCaddyRoute, updateCaddyRoute, deleteCaddyRoute, getCaddyRouteById, DB_FILE } from './server-db';
|
||||||
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
|
import { Device, LabTemplate, Booking, LogEntry, User, QuickLink } from './src/types';
|
||||||
|
|
||||||
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
const uid = (prefix: string) => `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
@ -89,11 +89,12 @@ function buildCaddyfile(): string {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function importCaddyfileRoutes(): void {
|
function importCaddyfileRoutes(userId: string): void {
|
||||||
if (getCaddyRoutes().length > 0) return;
|
if (getCaddyRoutes().length > 0) return;
|
||||||
const caddyfilePath = '/etc/caddy/Caddyfile';
|
const caddyfilePath = '/etc/caddy/Caddyfile';
|
||||||
if (!fs.existsSync(caddyfilePath)) return;
|
if (!fs.existsSync(caddyfilePath)) return;
|
||||||
const lines = fs.readFileSync(caddyfilePath, 'utf-8').split('\n');
|
const lines = fs.readFileSync(caddyfilePath, 'utf-8').split('\n');
|
||||||
|
const imported: string[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
while (i < lines.length) {
|
while (i < lines.length) {
|
||||||
const line = lines[i].trim();
|
const line = lines[i].trim();
|
||||||
@ -113,11 +114,17 @@ function importCaddyfileRoutes(): void {
|
|||||||
const tls = /tls\s+internal/.test(block);
|
const tls = /tls\s+internal/.test(block);
|
||||||
const compress = /encode/.test(block);
|
const compress = /encode/.test(block);
|
||||||
addCaddyRoute(hostname, upstream, tls, compress);
|
addCaddyRoute(hostname, upstream, tls, compress);
|
||||||
console.log(`[Caddy] Imported route from Caddyfile: ${hostname} → ${upstream}`);
|
imported.push(`${hostname} → ${upstream}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
if (imported.length > 0) {
|
||||||
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(uid('log'), new Date().toISOString(), 'system',
|
||||||
|
`Caddy: imported ${imported.length} route(s) from Caddyfile — ${imported.join(', ')}`,
|
||||||
|
null, userId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pushCaddyConfig(): Promise<void> {
|
async function pushCaddyConfig(): Promise<void> {
|
||||||
@ -143,7 +150,7 @@ async function startServer() {
|
|||||||
if (cnt === 0) {
|
if (cnt === 0) {
|
||||||
const passwordHash = bcrypt.hashSync('admin', 10);
|
const passwordHash = bcrypt.hashSync('admin', 10);
|
||||||
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
|
db.prepare('INSERT INTO users (id, name, role, email, password_hash) VALUES (?, ?, ?, ?, ?)')
|
||||||
.run(uid('u'), 'Admin', 'Admin', 'admin@ghostgrid.local', passwordHash);
|
.run(uid('u'), 'admin', 'admin', 'admin@ghostgrid.local', passwordHash);
|
||||||
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
|
console.log('[Init] Default admin user created — login: admin@ghostgrid.local / admin');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -331,7 +338,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!caddyWasEnabled && updates.caddy_enabled === 'true') {
|
if (!caddyWasEnabled && updates.caddy_enabled === 'true') {
|
||||||
importCaddyfileRoutes();
|
importCaddyfileRoutes(req.user!.userId);
|
||||||
}
|
}
|
||||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message));
|
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after settings save:', err.message));
|
||||||
res.json(maskSettings(getAllSettings()));
|
res.json(maskSettings(getAllSettings()));
|
||||||
@ -1182,6 +1189,9 @@ async function startServer() {
|
|||||||
};
|
};
|
||||||
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
||||||
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
const route = addCaddyRoute(hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
||||||
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(uid('log'), new Date().toISOString(), 'system',
|
||||||
|
`Caddy route added: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId);
|
||||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
|
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route add:', err.message));
|
||||||
res.json(route);
|
res.json(route);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -1189,11 +1199,36 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||||
|
const { hostname, upstream, tls, compress } = req.body as {
|
||||||
|
hostname?: string; upstream?: string; tls?: boolean; compress?: boolean;
|
||||||
|
};
|
||||||
|
if (!hostname || !upstream) return res.status(400).json({ error: 'hostname and upstream are required.' });
|
||||||
|
const route = updateCaddyRoute(id, hostname.trim(), upstream.trim(), tls !== false, compress !== false);
|
||||||
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(uid('log'), new Date().toISOString(), 'system',
|
||||||
|
`Caddy route updated: ${hostname.trim()} → ${upstream.trim()}`, null, req.user!.userId);
|
||||||
|
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route update:', err.message));
|
||||||
|
res.json(route);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
if (!id) return res.status(400).json({ error: 'Invalid route id.' });
|
||||||
|
const existing = getCaddyRouteById(id);
|
||||||
deleteCaddyRoute(id);
|
deleteCaddyRoute(id);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('INSERT INTO logs (id, timestamp, type, message, deviceId, userId) VALUES (?, ?, ?, ?, ?, ?)')
|
||||||
|
.run(uid('log'), new Date().toISOString(), 'system',
|
||||||
|
`Caddy route deleted: ${existing.hostname} → ${existing.upstream}`, null, req.user!.userId);
|
||||||
|
}
|
||||||
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
|
pushCaddyConfig().catch(err => console.warn('[Caddy] Could not push config after route delete:', err.message));
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { User } from '../types';
|
|||||||
import {
|
import {
|
||||||
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
Shield, Activity, Save, CheckCircle, AlertCircle, Eye, EyeOff,
|
||||||
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
|
Settings2, KeyRound, Globe, Clock, ChevronRight, Copy, Users, RefreshCw, Terminal,
|
||||||
Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server,
|
Network, Trash2, Plus, HardDrive, Download, Upload, AlertTriangle, Plug, Server, Pencil, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const SECRET_SENTINEL = '__SET__';
|
const SECRET_SENTINEL = '__SET__';
|
||||||
@ -200,6 +200,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [newTls, setNewTls] = useState(true);
|
const [newTls, setNewTls] = useState(true);
|
||||||
const [newCompress, setNewCompress] = useState(true);
|
const [newCompress, setNewCompress] = useState(true);
|
||||||
|
|
||||||
|
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||||
|
const [editHostname, setEditHostname] = useState('');
|
||||||
|
const [editUpstream, setEditUpstream] = useState('');
|
||||||
|
const [editTls, setEditTls] = useState(true);
|
||||||
|
const [editCompress, setEditCompress] = useState(true);
|
||||||
|
const [savingRoute, setSavingRoute] = useState(false);
|
||||||
|
|
||||||
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
|
const [dbInfo, setDbInfo] = useState<DbInfo | null>(null);
|
||||||
const [backingUp, setBackingUp] = useState(false);
|
const [backingUp, setBackingUp] = useState(false);
|
||||||
const [importFile, setImportFile] = useState<File | null>(null);
|
const [importFile, setImportFile] = useState<File | null>(null);
|
||||||
@ -293,7 +300,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
setAzureClientSecret('');
|
setAzureClientSecret('');
|
||||||
setCheckmkApiSecret('');
|
setCheckmkApiSecret('');
|
||||||
setSemaphoreApiToken('');
|
setSemaphoreApiToken('');
|
||||||
loadCaddyRoutes();
|
await loadCaddyRoutes();
|
||||||
setSuccessMsg('Settings saved successfully.');
|
setSuccessMsg('Settings saved successfully.');
|
||||||
setTimeout(() => setSuccessMsg(''), 4000);
|
setTimeout(() => setSuccessMsg(''), 4000);
|
||||||
} catch {
|
} catch {
|
||||||
@ -406,6 +413,40 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEditStart(r: CaddyRoute) {
|
||||||
|
setEditingRouteId(r.id);
|
||||||
|
setEditHostname(r.hostname);
|
||||||
|
setEditUpstream(r.upstream);
|
||||||
|
setEditTls(r.tls === 1);
|
||||||
|
setEditCompress(r.compress === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditCancel() {
|
||||||
|
setEditingRouteId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditSave(id: number) {
|
||||||
|
if (!editHostname.trim() || !editUpstream.trim()) return;
|
||||||
|
setSavingRoute(true);
|
||||||
|
try {
|
||||||
|
const res = await authFetch(`/api/caddy/routes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({ hostname: editHostname.trim(), upstream: editUpstream.trim(), tls: editTls, compress: editCompress }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json();
|
||||||
|
setError(d.error || 'Failed to update route.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditingRouteId(null);
|
||||||
|
await loadCaddyRoutes();
|
||||||
|
} catch {
|
||||||
|
setError('Network error updating route.');
|
||||||
|
} finally {
|
||||||
|
setSavingRoute(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadDbInfo() {
|
async function loadDbInfo() {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/database/info');
|
const res = await authFetch('/api/database/info');
|
||||||
@ -901,22 +942,61 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
|
|
||||||
{/* Custom routes */}
|
{/* Custom routes */}
|
||||||
{caddyRoutes?.custom.map(r => (
|
{caddyRoutes?.custom.map(r => (
|
||||||
<div key={r.id} className="flex items-center justify-between bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
{editingRouteId === r.id ? (
|
||||||
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
<div className="flex items-end gap-2">
|
||||||
<span className="text-slate-600 text-[11px]">></span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
|
<Label>Hostname</Label>
|
||||||
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
|
<Input value={editHostname} onChange={setEditHostname} placeholder="hostname" monospace />
|
||||||
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
|
</div>
|
||||||
</div>
|
<div className="flex-1 min-w-0">
|
||||||
<button
|
<Label>Upstream</Label>
|
||||||
type="button"
|
<Input value={editUpstream} onChange={setEditUpstream} placeholder="127.0.0.1:3000" monospace />
|
||||||
onClick={() => handleDeleteRoute(r.id)}
|
</div>
|
||||||
className="ml-3 shrink-0 p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all"
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
title="Remove route"
|
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
||||||
>
|
<button type="button" onClick={() => setEditTls(v => !v)}
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editTls ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||||||
</button>
|
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editTls ? 'left-4' : 'left-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
|
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
||||||
|
<button type="button" onClick={() => setEditCompress(v => !v)}
|
||||||
|
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
||||||
|
<span className={`absolute top-0.5 w-3 h-3 bg-white rounded-full shadow transition-all duration-200 ${editCompress ? 'left-4' : 'left-0.5'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
|
||||||
|
className="flex items-center gap-1 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={handleEditCancel}
|
||||||
|
className="p-2 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 transition-all shrink-0">
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
||||||
|
<span className="text-slate-600 text-[11px]">></span>
|
||||||
|
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
|
||||||
|
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
|
||||||
|
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 ml-3 shrink-0">
|
||||||
|
<button type="button" onClick={() => handleEditStart(r)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-950/30 transition-all" title="Edit route">
|
||||||
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => handleDeleteRoute(r.id)}
|
||||||
|
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all" title="Remove route">
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user