From f1d46e7f56fe15681dc339ba78b44704bf2e5c61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Br=C3=BCckner?= Date: Wed, 17 Jun 2026 15:27:32 +0200 Subject: [PATCH] refactor(ui): semantic token theming + cleaner SaaS palette Replace the brittle 266-rule `:root.light` `!important` override block with a Tailwind v4 `@theme inline` semantic token system (surface/header/card/inner/ field/line/fg/fg-muted/fg-faint + success/info/primary/warning/danger/rose/ violet/sky/orange/blue, each with vivid/soft/line). Migrate all 14 components and App.tsx off hardcoded slate/hex utilities onto the tokens, so dark/light is now a pure CSS-variable swap with no per-utility overrides. - index.css ~984 -> ~150 lines; CSS bundle 145 -> 98 kB - calmer, desaturated accents; removed gratuitous glows and constant pulsing - branding, playful copy and intentionally-dark code blocks preserved Also wires `requireAdmin` onto settings, bookings-delete, database, checkmk, semaphore and caddy routes. --- ARCHITECTURE.md | 4 +- server.ts | 26 +- src/App.tsx | 59 +- src/components/BookingCalendar.tsx | 230 +++--- src/components/BookingDetailsModal.tsx | 140 ++-- src/components/Dashboard.tsx | 195 ++--- src/components/DeviceInventory.tsx | 184 ++--- src/components/Header.tsx | 88 +-- src/components/LabTemplates.tsx | 212 +++--- src/components/LinkDashboard.tsx | 104 +-- src/components/Logbook.tsx | 80 +-- src/components/LoginPage.tsx | 44 +- src/components/RegisterPage.tsx | 50 +- src/components/Settings.tsx | 230 +++--- src/components/TopologyPanel.tsx | 66 +- src/components/UserDirectory.tsx | 92 +-- src/index.css | 959 +++---------------------- 17 files changed, 995 insertions(+), 1768 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9ac48b7..f248c03 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,7 +36,7 @@ ``` +-----------------------------------------------------------------------------+ -| GHOSTGRID PLATFORM | +| GHOSTGRID PLATFORM | +-----------------------------------------------------------------------------+ | +---------------------------------------------------------------------+ | | | PRESENTATION LAYER | | @@ -49,7 +49,7 @@ | | authFetch > Bearer | | +---------------------------------------------------------------------+ | | | APPLICATION LAYER (server.ts) | | -| | Single Express process — serves API + frontend | | +| | Single Express process — serves API + frontend | | | | +-----------+ +-----------+ +-----------+ +-----------+ +--------+ | | | | | Auth | | Devices | | Labs | | Bookings | | Logs | | | | | | (JWT/MSAL)| | CRUD | | CRUD | | CRUD | | | | | diff --git a/server.ts b/server.ts index a11a62e..919b795 100644 --- a/server.ts +++ b/server.ts @@ -399,7 +399,7 @@ async function startServer() { // ------------------------------------------------------------- // RESTFUL API: Settings (admin only) // ------------------------------------------------------------- - app.get('/api/settings', requireAuth, (_req, res) => { + app.get('/api/settings', requireAuth, requireAdmin, (_req, res) => { try { res.json(maskSettings(getAllSettings())); } catch (err: any) { @@ -407,7 +407,7 @@ async function startServer() { } }); - app.put('/api/settings', requireAuth, (req, res) => { + app.put('/api/settings', requireAuth, requireAdmin, (req, res) => { try { const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret', 'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user', @@ -844,7 +844,7 @@ async function startServer() { } }); - app.delete('/api/bookings/:id', requireAuth, (req, res) => { + app.delete('/api/bookings/:id', requireAuth, requireAdmin, (req, res) => { try { const id = req.params.id; const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any; @@ -961,7 +961,7 @@ async function startServer() { // ------------------------------------------------------------- // DATABASE API // ------------------------------------------------------------- - app.get('/api/database/info', requireAuth, (_req, res) => { + app.get('/api/database/info', requireAuth, requireAdmin, (_req, res) => { try { const stats = fs.statSync(DB_FILE); const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy']; @@ -980,7 +980,7 @@ async function startServer() { } }); - app.get('/api/database/backup', requireAuth, async (_req, res) => { + app.get('/api/database/backup', requireAuth, requireAdmin, async (_req, res) => { const tempPath = `${DB_FILE}.backup-${Date.now()}`; try { await db.backup(tempPath); @@ -994,7 +994,7 @@ async function startServer() { } }); - app.post('/api/database/import', requireAuth, + app.post('/api/database/import', requireAuth, requireAdmin, express.raw({ type: 'application/octet-stream', limit: '50mb' }), (req, res) => { const tempPath = `${DB_FILE}.import-${Date.now()}`; @@ -1154,7 +1154,7 @@ async function startServer() { } scheduleSync(); - app.post('/api/checkmk/sync', requireAuth, async (_req, res) => { + app.post('/api/checkmk/sync', requireAuth, requireAdmin, async (_req, res) => { try { await syncCheckMkStatuses(); res.json({ ok: true }); @@ -1266,7 +1266,7 @@ async function startServer() { scheduleSemaphoreCheck(); // Proxy Semaphore template list so the UI can populate dropdowns - app.get('/api/semaphore/templates', requireAuth, async (_req, res) => { + app.get('/api/semaphore/templates', requireAuth, requireAdmin, async (_req, res) => { const apiUrl = getSetting('semaphore_api_url'); const token = getSetting('semaphore_api_token'); const projectId = getSetting('semaphore_project_id'); @@ -1320,7 +1320,7 @@ async function startServer() { // ------------------------------------------------------------- // CADDY API // ------------------------------------------------------------- - app.get('/api/caddy/status', requireAuth, async (_req, res) => { + app.get('/api/caddy/status', requireAuth, requireAdmin, async (_req, res) => { try { const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019'; const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) }); @@ -1330,7 +1330,7 @@ async function startServer() { } }); - app.get('/api/caddy/routes', requireAuth, (_req, res) => { + app.get('/api/caddy/routes', requireAuth, requireAdmin, (_req, res) => { try { res.json(getCaddyRoutes()); } catch (err: any) { @@ -1338,7 +1338,7 @@ async function startServer() { } }); - app.post('/api/caddy/routes', requireAuth, async (req, res) => { + app.post('/api/caddy/routes', requireAuth, requireAdmin, async (req, res) => { try { if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const { hostname, upstream, tls, compress, redirect } = req.body as { @@ -1356,7 +1356,7 @@ async function startServer() { } }); - app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => { + app.put('/api/caddy/routes/:id', requireAuth, requireAdmin, async (req, res) => { try { if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); @@ -1374,7 +1374,7 @@ async function startServer() { } }); - app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => { + app.delete('/api/caddy/routes/:id', requireAuth, requireAdmin, (req, res) => { try { if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' }); const id = Number(req.params.id); diff --git a/src/App.tsx b/src/App.tsx index b7000e1..2c7d99c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -411,12 +411,12 @@ export default function App() { // Startup check not done yet if (!authChecked) { return ( -
+
-
- +
+
-

booting...

+

booting...

); @@ -433,16 +433,16 @@ export default function App() { // Loading data after login if (loading) { return ( -
+
-
- +
+
-

GhostGrid Virtualization

-

Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...

-
- +

GhostGrid Virtualization

+

Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...

+
+ SQLITE DATABASE HYDRATION ONGOING
@@ -452,7 +452,7 @@ export default function App() { } return ( -
+
@@ -538,6 +538,7 @@ export default function App() { bookings={bookings} labs={labs} devices={devices} + users={users} links={links} onCancelBooking={handleCancelBooking} onDeleteBooking={handleDeleteBooking} @@ -612,7 +613,7 @@ export default function App() { onAddLog={handleAddLogManually} /> )} - {activeTab === 'settings' && ( + {activeTab === 'settings' && currentUser.role.toLowerCase() === 'admin' && ( )}
diff --git a/src/components/BookingCalendar.tsx b/src/components/BookingCalendar.tsx index 89f3314..13d3ea4 100644 --- a/src/components/BookingCalendar.tsx +++ b/src/components/BookingCalendar.tsx @@ -237,10 +237,8 @@ export default function BookingCalendar({ }; const handleQuickBookDevice = (device: Device) => { - // Find or pick a lab that contains this device; fall back to device ID as labId marker - const hostLab = labs.find(l => l.deviceIds.includes(device.id)); onAddBooking({ - labId: hostLab?.id ?? `device:${device.id}`, + labId: `device:${device.id}`, userId: currentUser.id, startDateTime: toLocalISO(quickWindow.start), endDateTime: toLocalISO(quickWindow.end), @@ -258,23 +256,23 @@ export default function BookingCalendar({ {/* ── Quick Booking Modal ── */} {showQuickPanel && ( -
-
+
+
{/* Modal Header */} -
+
- -

Quick Booking

+ +

Quick Booking

-
{/* Duration Selector */}
-

Duration starting now:

+

Duration starting now:

{[1, 2, 4, 8].map(h => ( ))}
-

+

{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} >{' '} {new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

@@ -301,7 +299,7 @@ export default function BookingCalendar({
); @@ -389,32 +387,32 @@ export default function BookingCalendar({ )} {/* ── LEFT: Visual Schedule Grid ── */} -
+
-

- +

+ Bookings

-

Who has which box, and until when. mutex for hardware, basically.

+

Who has which box, and until when. mutex for hardware, basically.

{/* Day navigation */} -
+
-
+
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
@@ -422,32 +420,32 @@ export default function BookingCalendar({
{/* Matrix Grid */} -
+
{/* Header row */}
-
Device
+
Device
{TIME_SLOTS.map((slot, i) => ( -
- {slot.label} +
+ {slot.label}
))}
{/* Device rows */} -
+
{devices.map((device) => (
-

{device.hostname}

-

{device.type}

+

{device.hostname}

+

{device.type}

{TIME_SLOTS.map((slot, sIdx) => { const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end); @@ -456,8 +454,8 @@ export default function BookingCalendar({ if (!cur) { return ( -
-
+
+
); } @@ -472,13 +470,13 @@ export default function BookingCalendar({ : isLast ? 'rounded-r' : ''; const borderCls = isMe - ? `bg-emerald-500/30 border-emerald-500/60 hover:bg-emerald-500/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}` - : `bg-indigo-500/25 border-indigo-500/50 hover:bg-indigo-500/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`; + ? `bg-success/30 border-success/60 hover:bg-success/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}` + : `bg-primary/25 border-primary/50 hover:bg-primary/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`; return (
onSelectBookingDetails(cur)} @@ -492,7 +490,7 @@ export default function BookingCalendar({ ? initials(name) : name.split(' ')[0]; return ( - + {label} ); @@ -508,11 +506,11 @@ export default function BookingCalendar({
{/* Legend */} -
+
- My Booking - Colleague's Allocation - Available + My Booking + Colleague's Allocation + Available

Double-bookings get rejected at commit time. no race conditions on your watch.

@@ -522,13 +520,13 @@ export default function BookingCalendar({
{/* Quick Booking Trigger */} -
+
-

- +

+ Quick Booking

-

+

Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.

@@ -537,7 +535,7 @@ export default function BookingCalendar({ @@ -546,37 +544,37 @@ export default function BookingCalendar({ -
- {availableLabs.length} labs free - {availableDevices.length} devices free +
+ {availableLabs.length} labs free + {availableDevices.length} devices free
{/* Standard Booking Form */} -
-

- +
+

+ Reserve Slot

{/* Resource type toggle: whole lab topology or a single device */}
- +