Compare commits

..

1 Commits

Author SHA1 Message Date
cc96f5b6ce chore(release): merge dev into main 2026-06-10 16:39:27 +02:00
21 changed files with 1949 additions and 1291 deletions

1
.gitignore vendored
View File

@ -6,7 +6,6 @@ coverage/
*.log
.env*
!.env.example
CLAUDE.MD
# local SQLite database
ghostgrid.db

View File

@ -36,7 +36,7 @@
```
+-----------------------------------------------------------------------------+
| GHOSTGRID PLATFORM |
| GHOSTGRID PLATFORM |
+-----------------------------------------------------------------------------+
| +---------------------------------------------------------------------+ |
| | PRESENTATION LAYER | |
@ -49,7 +49,7 @@
| | authFetch > Bearer <JWT> |
| +---------------------------------------------------------------------+ |
| | 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 | | | | |
@ -382,12 +382,6 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
| +-- DELETE /{id} # Delete booking [auth]
|
+-- /events
| +-- GET / # SSE stream; token via ?token= query param [auth]
| | # Sends full snapshot on connect, then pushes
| | # bookings/devices/labs/logs/links/users-update
| | # events after every mutation or background job
|
+-- /logs
| +-- GET / # All logs, newest first [auth]
| +-- POST / # Manual log entry [auth]
@ -468,7 +462,6 @@ Step 2 for each device:
- on change: write a 'status' log
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
After each cycle: broadcastDevices() + broadcastLogs() > SSE push to all clients
```
### 6.2 Ansible Semaphore — Playbook Automation
@ -489,7 +482,6 @@ triggerSemaphoreTask(templateId, extraVars):
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
> store returned job id on booking; log success/failure
(a booking with no template id is marked triggered > not retried)
After each cycle: broadcastBookings() + broadcastLogs() > SSE push to all clients
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
GET /api/semaphore/templates (proxy for UI dropdowns)
@ -592,14 +584,12 @@ src/
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
+-- Effects:
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
| +-- Load data on login (one Promise.all; initial seed before SSE connects)
| +-- SSE connection to GET /api/events — receives full snapshot on (re)connect,
| | then live pushes for bookings/devices/labs/logs/links/users on any mutation
| | or background job; auth-error event triggers logout on token expiry
| +-- Load data on login
| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes)
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
handleAddLogManually — call API via authFetch, update local state
(SSE pushes the authoritative state to all tabs within ~1s)
handleAddLogManually — call API via authFetch, update local state,
most then refetch /api/logs
(* persisted to localStorage)
```

View File

@ -30,7 +30,7 @@ db.exec(`
status TEXT NOT NULL,
emergencySheet TEXT NOT NULL,
lastCheckedAt TEXT,
cmkUrl TEXT NOT NULL DEFAULT '',
checkMkUrl TEXT NOT NULL DEFAULT '',
cmkHostname TEXT NOT NULL DEFAULT ''
);
@ -56,10 +56,10 @@ db.exec(`
status TEXT NOT NULL,
notified INTEGER NOT NULL DEFAULT 0,
emailSent INTEGER NOT NULL DEFAULT 0,
semaphoreSetupTriggered INTEGER NOT NULL DEFAULT 0,
semaphoreTeardownTriggered INTEGER NOT NULL DEFAULT 0,
semaphoreSetupJobId TEXT NOT NULL DEFAULT '',
semaphoreTeardownJobId TEXT NOT NULL DEFAULT ''
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS logs (

View File

@ -8,21 +8,13 @@ interface Migration {
// Append only. Never reorder or remove entries — that would corrupt tracking.
// Each `up` function receives the open DB handle inside an already-open transaction.
const migrations: Migration[] = [
{
id: '0001_rename_device_checkMkUrl_to_cmkUrl',
up: (db) => {
db.exec(`ALTER TABLE devices RENAME COLUMN checkMkUrl TO cmkUrl`);
},
},
{
id: '0002_rename_booking_ansible_to_semaphore',
up: (db) => {
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleSetupTriggered TO semaphoreSetupTriggered`);
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleTeardownTriggered TO semaphoreTeardownTriggered`);
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleSetupJobId TO semaphoreSetupJobId`);
db.exec(`ALTER TABLE bookings RENAME COLUMN ansibleTeardownJobId TO semaphoreTeardownJobId`);
},
},
// Example:
// {
// id: '0001_bookings_add_color',
// up: (db) => {
// db.exec(`ALTER TABLE bookings ADD COLUMN color TEXT NOT NULL DEFAULT 'blue'`);
// },
// },
];
export function runMigrations(db: InstanceType<typeof Database>): void {

186
server.ts
View File

@ -48,54 +48,6 @@ function requireAuth(req: Request, res: Response, next: NextFunction) {
}
}
// ------------------------------------------------------------------
// SSE: real-time push infrastructure
// ------------------------------------------------------------------
const sseClients = new Set<Response>();
function broadcast(eventName: string, data: unknown): void {
if (sseClients.size === 0) return;
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
try { client.write(payload); }
catch { sseClients.delete(client); }
}
}
function getBookingsData(): Booking[] {
const rows = db.prepare('SELECT * FROM bookings').all() as any[];
return rows.map(r => ({
id: r.id, labId: r.labId, userId: r.userId,
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
notes: r.notes || '', status: r.status as Booking['status'],
notified: r.notified === 1, emailSent: r.emailSent === 1,
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
}));
}
function getLabsData(): LabTemplate[] {
const rows = db.prepare('SELECT * FROM labs').all() as any[];
return rows.map(r => ({
id: r.id, name: r.name, description: r.description,
contactPerson: r.contactPerson, location: r.location,
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
ownerId: r.ownerId ?? '',
}));
}
function broadcastBookings() { broadcast('bookings-update', getBookingsData()); }
function broadcastDevices() { broadcast('devices-update', db.prepare('SELECT * FROM devices').all()); }
function broadcastLabs() { broadcast('labs-update', getLabsData()); }
function broadcastLogs() { broadcast('logs-update', db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all()); }
function broadcastLinks() { broadcast('links-update', db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all()); }
function broadcastUsers() { broadcast('users-update', db.prepare('SELECT id, name, role, email FROM users').all()); }
function requireAdmin(req: Request, res: Response, next: NextFunction) {
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
if (!row || row.role.toLowerCase() !== 'admin') {
@ -319,9 +271,9 @@ async function startServer() {
res.json({
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
effectiveRedirectUri,
cmkEnabled: getSetting('checkmk_enabled') === 'true',
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
semaphoreEnabled: getSetting('semaphore_enabled') === 'true',
cmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
isProduction: IS_PRODUCTION,
});
});
@ -399,7 +351,7 @@ async function startServer() {
// -------------------------------------------------------------
// RESTFUL API: Settings (admin only)
// -------------------------------------------------------------
app.get('/api/settings', requireAuth, requireAdmin, (_req, res) => {
app.get('/api/settings', requireAuth, (_req, res) => {
try {
res.json(maskSettings(getAllSettings()));
} catch (err: any) {
@ -407,7 +359,7 @@ async function startServer() {
}
});
app.put('/api/settings', requireAuth, requireAdmin, (req, res) => {
app.put('/api/settings', requireAuth, (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',
@ -463,7 +415,6 @@ async function startServer() {
if (changes.length > 0) {
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
}
broadcastUsers();
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -481,7 +432,6 @@ async function startServer() {
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id);
const updated = db.prepare('SELECT id, name, role, email FROM users WHERE id = ?').get(id) as User;
addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId });
broadcastUsers();
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -497,7 +447,6 @@ async function startServer() {
const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n;
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
db.prepare('DELETE FROM users WHERE id = ?').run(id);
broadcastUsers();
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -534,7 +483,6 @@ async function startServer() {
{ deviceId: id, userId: req.user!.userId });
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
broadcastDevices();
res.status(201).json(device);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -557,7 +505,6 @@ async function startServer() {
{ deviceId: id, userId: req.user!.userId });
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
broadcastDevices();
res.json(device);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -588,8 +535,6 @@ async function startServer() {
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
{ userId: req.user!.userId });
broadcastDevices();
broadcastLabs();
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -635,7 +580,6 @@ async function startServer() {
{ userId: req.user!.userId });
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
broadcastLabs();
res.status(201).json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -667,7 +611,6 @@ async function startServer() {
{ userId: req.user!.userId });
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
broadcastLabs();
res.json({ id: r.id, name: r.name, description: r.description, contactPerson: r.contactPerson, location: r.location, deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology), semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '', scope: r.scope, ownerId: r.ownerId });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -695,9 +638,6 @@ async function startServer() {
`Withdrew the lab testing template "${lab.name || id}".`,
{ userId: req.user!.userId });
broadcastLabs();
broadcastBookings();
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -715,10 +655,10 @@ async function startServer() {
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
notes: r.notes || '', status: r.status as any,
notified: r.notified === 1, emailSent: r.emailSent === 1,
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
}));
res.json(bookings);
} catch (err: any) {
@ -726,44 +666,6 @@ async function startServer() {
}
});
app.get('/api/events', (req: Request, res: Response) => {
const token = req.query.token as string | undefined;
if (!token) { res.status(401).json({ error: 'Authentication required.' }); return; }
try {
jwt.verify(token, JWT_SECRET) as JwtPayload;
} catch {
res.set('Content-Type', 'text/event-stream');
res.flushHeaders();
res.write('event: auth-error\ndata: {"error":"token_expired"}\n\n');
res.end();
return;
}
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.flushHeaders();
res.write(`event: bookings-update\ndata: ${JSON.stringify(getBookingsData())}\n\n`);
res.write(`event: devices-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM devices').all())}\n\n`);
res.write(`event: labs-update\ndata: ${JSON.stringify(getLabsData())}\n\n`);
res.write(`event: logs-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM logs ORDER BY timestamp DESC').all())}\n\n`);
res.write(`event: links-update\ndata: ${JSON.stringify(db.prepare('SELECT * FROM links ORDER BY category ASC, title ASC').all())}\n\n`);
res.write(`event: users-update\ndata: ${JSON.stringify(db.prepare('SELECT id, name, role, email FROM users').all())}\n\n`);
sseClients.add(res);
const heartbeat = setInterval(() => {
try { res.write(': heartbeat\n\n'); }
catch { clearInterval(heartbeat); sseClients.delete(res); }
}, 30_000);
req.on('close', () => { clearInterval(heartbeat); sseClients.delete(res); });
});
app.post('/api/bookings', requireAuth, (req, res) => {
try {
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
@ -785,8 +687,6 @@ async function startServer() {
{ userId });
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
broadcastBookings();
broadcastLogs();
res.status(201).json({
booking: { id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime, endDateTime: r.endDateTime, notes: r.notes || '', status: r.status, notified: r.notified === 1, emailSent: r.emailSent === 1 },
alertGenerated: `Reservation successfully approved: Lab "${lab?.name || 'Scenario'}" is reserved for your exclusive usage during this window.`
@ -814,37 +714,35 @@ async function startServer() {
// Trigger teardown if booking had already started and teardown not yet triggered
const now = new Date();
if (new Date(booking.startDateTime) <= now && !booking.semaphoreTeardownTriggered) {
if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) {
const templateId = lab?.semaphoreTeardownTemplateId;
if (templateId) {
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
start_time: booking.startDateTime, end_time: booking.endDateTime,
});
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', booking.id);
}
}
}
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
broadcastBookings();
broadcastLogs();
res.json({
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
notified: r.notified === 1, emailSent: r.emailSent === 1,
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
ansibleSetupJobId: r.ansibleSetupJobId || '',
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
app.delete('/api/bookings/:id', requireAuth, requireAdmin, (req, res) => {
app.delete('/api/bookings/:id', requireAuth, (req, res) => {
try {
const id = req.params.id;
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
@ -857,8 +755,6 @@ async function startServer() {
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
{ userId: req.user!.userId });
broadcastBookings();
broadcastLogs();
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -887,7 +783,6 @@ async function startServer() {
const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
broadcastLogs();
res.status(201).json(log);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -919,7 +814,6 @@ async function startServer() {
.run(id, title, url, description || '', category || '', color || 'emerald', req.user!.userId, createdAt);
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
broadcastLinks();
res.status(201).json(link);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -937,7 +831,6 @@ async function startServer() {
.run(title, url, description || '', category || '', color || 'emerald', id);
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
broadcastLinks();
res.json(link);
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -951,7 +844,6 @@ async function startServer() {
if (!existing) return res.status(404).json({ error: 'Link not found.' });
db.prepare('DELETE FROM links WHERE id = ?').run(id);
broadcastLinks();
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
} catch (err: any) {
res.status(500).json({ error: err.message });
@ -961,7 +853,7 @@ async function startServer() {
// -------------------------------------------------------------
// DATABASE API
// -------------------------------------------------------------
app.get('/api/database/info', requireAuth, requireAdmin, (_req, res) => {
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'];
@ -980,7 +872,7 @@ async function startServer() {
}
});
app.get('/api/database/backup', requireAuth, requireAdmin, async (_req, res) => {
app.get('/api/database/backup', requireAuth, async (_req, res) => {
const tempPath = `${DB_FILE}.backup-${Date.now()}`;
try {
await db.backup(tempPath);
@ -994,7 +886,7 @@ async function startServer() {
}
});
app.post('/api/database/import', requireAuth, requireAdmin,
app.post('/api/database/import', requireAuth,
express.raw({ type: 'application/octet-stream', limit: '50mb' }),
(req, res) => {
const tempPath = `${DB_FILE}.import-${Date.now()}`;
@ -1050,7 +942,7 @@ async function startServer() {
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
// in Settings take effect on the next cycle without a server restart.
// -------------------------------------------------------------
function cmkHttpHint(status: number): string {
function checkmkHttpHint(status: number): string {
if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings > CheckMK)';
if (status === 403) return 'HTTP 403 Forbidden - automation user lacks permission in CheckMK';
if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong';
@ -1082,7 +974,7 @@ async function startServer() {
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
{ headers }
);
if (!cfgRes.ok) throw new Error(cmkHttpHint(cfgRes.status));
if (!cfgRes.ok) throw new Error(checkmkHttpHint(cfgRes.status));
const cfgData = await cfgRes.json();
ipToHostname = new Map<string, string>();
for (const host of cfgData?.value ?? []) {
@ -1121,7 +1013,7 @@ async function startServer() {
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`,
{ headers }
);
if (!hostRes.ok) throw new Error(cmkHttpHint(hostRes.status));
if (!hostRes.ok) throw new Error(checkmkHttpHint(hostRes.status));
const hostData = await hostRes.json();
const state: number = hostData?.extensions?.state ?? -1;
@ -1142,9 +1034,6 @@ async function startServer() {
addLog('system',
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
{ timestamp: now });
broadcastDevices();
broadcastLogs();
}
async function scheduleSync() {
@ -1154,7 +1043,7 @@ async function startServer() {
}
scheduleSync();
app.post('/api/checkmk/sync', requireAuth, requireAdmin, async (_req, res) => {
app.post('/api/checkmk/sync', requireAuth, async (_req, res) => {
try {
await syncCheckMkStatuses();
res.json({ ok: true });
@ -1217,20 +1106,20 @@ async function startServer() {
const setupPending = db.prepare(
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.startDateTime <= ? AND b.semaphoreSetupTriggered = 0 AND b.status != 'cancelled'`
WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of setupPending) {
const templateId = row.semaphoreSetupTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1 WHERE id = ?').run(row.id);
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1, semaphoreSetupJobId = ? WHERE id = ?')
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
@ -1238,25 +1127,22 @@ async function startServer() {
const teardownPending = db.prepare(
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
WHERE b.endDateTime <= ? AND b.semaphoreTeardownTriggered = 0 AND b.status != 'cancelled'`
WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'`
).all(now) as any[];
for (const row of teardownPending) {
const templateId = row.semaphoreTeardownTemplateId;
if (!templateId) {
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1 WHERE id = ?').run(row.id);
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id);
continue;
}
const jobId = await triggerSemaphoreTask(Number(templateId), {
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
start_time: row.startDateTime, end_time: row.endDateTime,
});
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', row.id);
}
broadcastBookings();
broadcastLogs();
}
async function scheduleSemaphoreCheck() {
@ -1266,7 +1152,7 @@ async function startServer() {
scheduleSemaphoreCheck();
// Proxy Semaphore template list so the UI can populate dropdowns
app.get('/api/semaphore/templates', requireAuth, requireAdmin, async (_req, res) => {
app.get('/api/semaphore/templates', requireAuth, async (_req, res) => {
const apiUrl = getSetting('semaphore_api_url');
const token = getSetting('semaphore_api_token');
const projectId = getSetting('semaphore_project_id');
@ -1304,10 +1190,10 @@ async function startServer() {
});
if (type === 'setup') {
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1, semaphoreSetupJobId = ? WHERE id = ?')
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
} else {
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
.run(jobId !== null ? String(jobId) : '', bookingId);
}
@ -1320,7 +1206,7 @@ async function startServer() {
// -------------------------------------------------------------
// CADDY API
// -------------------------------------------------------------
app.get('/api/caddy/status', requireAuth, requireAdmin, async (_req, res) => {
app.get('/api/caddy/status', requireAuth, 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 +1216,7 @@ async function startServer() {
}
});
app.get('/api/caddy/routes', requireAuth, requireAdmin, (_req, res) => {
app.get('/api/caddy/routes', requireAuth, (_req, res) => {
try {
res.json(getCaddyRoutes());
} catch (err: any) {
@ -1338,7 +1224,7 @@ async function startServer() {
}
});
app.post('/api/caddy/routes', requireAuth, requireAdmin, async (req, res) => {
app.post('/api/caddy/routes', requireAuth, 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 +1242,7 @@ async function startServer() {
}
});
app.put('/api/caddy/routes/:id', requireAuth, requireAdmin, async (req, res) => {
app.put('/api/caddy/routes/:id', requireAuth, 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 +1260,7 @@ async function startServer() {
}
});
app.delete('/api/caddy/routes/:id', requireAuth, requireAdmin, (req, res) => {
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
try {
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
const id = Number(req.params.id);

View File

@ -53,8 +53,8 @@ export default function App() {
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
const [cmkEnabled, setCmkEnabled] = useState(false);
const [cmkBaseUrl, setCmkBaseUrl] = useState('');
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
const [isProduction, setIsProduction] = useState(false);
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
@ -145,7 +145,7 @@ export default function App() {
if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json());
if (configRes.ok) { const cfg = await configRes.json(); setCmkEnabled(!!cfg.cmkEnabled); setCmkBaseUrl(cfg.cmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
} catch (err) {
console.error('[App] Failed to load data:', err);
} finally {
@ -155,53 +155,23 @@ export default function App() {
loadData();
}, [currentUser]);
// SSE connection: real-time push for all shared data.
// EventSource does not support Authorization headers, so the JWT is passed
// as a query parameter. The server sends a full snapshot on every (re)connect.
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
// status changes (online/offline) surface without a manual reload. The backend
// is the source of truth - it syncs each device's status from the CheckMK API.
useEffect(() => {
if (!currentUser) return;
const token = getToken();
if (!token) return;
const evtSource = new EventSource(`/api/events?token=${encodeURIComponent(token)}`);
evtSource.addEventListener('bookings-update', (e: MessageEvent) => {
try { setBookings(JSON.parse(e.data) as Booking[]); } catch {}
});
evtSource.addEventListener('devices-update', (e: MessageEvent) => {
try { setDevices(JSON.parse(e.data) as Device[]); } catch {}
});
evtSource.addEventListener('labs-update', (e: MessageEvent) => {
try { setLabs(JSON.parse(e.data) as LabTemplate[]); } catch {}
});
evtSource.addEventListener('logs-update', (e: MessageEvent) => {
try { setLogs(JSON.parse(e.data) as LogEntry[]); } catch {}
});
evtSource.addEventListener('links-update', (e: MessageEvent) => {
try { setLinks(JSON.parse(e.data) as QuickLink[]); } catch {}
});
evtSource.addEventListener('users-update', (e: MessageEvent) => {
try { setUsers(JSON.parse(e.data) as User[]); } catch {}
});
evtSource.addEventListener('auth-error', () => {
evtSource.close();
clearSession();
setCurrentUser(null);
});
evtSource.onerror = () => {
console.debug('[SSE] Connection error, retrying...');
const refreshDevices = async () => {
try {
const res = await authFetch('/api/devices');
if (res.ok) setDevices(await res.json());
} catch {
// transient network/server hiccup - keep last known state, retry next tick
}
};
return () => evtSource.close();
const id = setInterval(refreshDevices, 30_000);
return () => clearInterval(id);
}, [currentUser]);
// Keep the booking details modal in sync when SSE updates the bookings list.
useEffect(() => {
if (!selectedBookingForDetails) return;
const fresh = bookings.find(b => b.id === selectedBookingForDetails.id);
setSelectedBookingForDetails(fresh ?? null);
}, [bookings]);
// Upcoming-booking reminder - checks every 60s, fires once per booking
useEffect(() => {
if (!currentUser || bookings.length === 0) return;
@ -249,7 +219,10 @@ export default function App() {
});
if (res.ok) {
const data = await res.json();
setBookings(prev => [data.booking, ...prev]);
if (data.alertGenerated) setNotifications(prev => [data.alertGenerated, ...prev]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding booking:', err); }
};
@ -262,62 +235,111 @@ export default function App() {
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
});
if (res.ok) {
const updated = await res.json();
setBookings(prev => prev.map(b => b.id === bookingId ? updated : b));
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(updated);
const labName = booking ? (labs.find(l => l.id === booking.labId)?.name ?? 'Lab') : 'Lab';
setNotifications(prev => [`Booking cancelled: "${labName}" - slot has been released.`, ...prev]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error cancelling booking:', err); }
};
const handleDeleteBooking = async (bookingId: string) => {
try {
await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
const res = await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
if (res.ok) {
setBookings(prev => prev.filter(b => b.id !== bookingId));
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(null);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting booking:', err); }
};
// Device handlers
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
try {
await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
const res = await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
if (res.ok) {
const created = await res.json();
setDevices(prev => [...prev, created]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding device:', err); }
};
const handleUpdateDevice = async (updatedDev: Device) => {
try {
await authFetch(`/api/devices/${updatedDev.id}`, {
const res = await authFetch(`/api/devices/${updatedDev.id}`, {
method: 'PUT',
body: JSON.stringify({ ...updatedDev, operatorName: currentUser!.name }),
});
if (res.ok) {
const updated = await res.json();
setDevices(prev => prev.map(d => d.id === updatedDev.id ? updated : d));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error updating device:', err); }
};
const handleDeleteDevice = async (id: string) => {
try {
await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
const res = await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
if (res.ok) {
setDevices(prev => prev.filter(d => d.id !== id));
const labsRes = await authFetch('/api/labs');
if (labsRes.ok) setLabs(await labsRes.json());
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting device:', err); }
};
// Lab handlers
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
try {
await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
if (res.ok) {
const created = await res.json();
setLabs(prev => [...prev, created]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding lab:', err); }
};
const handleUpdateLab = async (updatedLab: LabTemplate) => {
try {
await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
const res = await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
if (res.ok) {
const data = await res.json();
setLabs(prev => prev.map(l => l.id === updatedLab.id ? data : l));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error updating lab:', err); }
};
const handleDeleteLab = async (id: string) => {
try {
await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
const res = await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
if (res.ok) {
setLabs(prev => prev.filter(l => l.id !== id));
setBookings(prev => prev.map(b => b.labId === id && b.status === 'upcoming' ? { ...b, status: 'cancelled' as const } : b));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting lab:', err); }
};
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
try {
await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
const res = await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
if (res.ok) { const log = await res.json(); setLogs(prev => [log, ...prev]); }
} catch (err) { console.error('[App] Error adding log:', err); }
};
@ -325,14 +347,18 @@ export default function App() {
const handleDeleteUser = async (id: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
if (res.ok) setUsers(prev => prev.filter(u => u.id !== id));
else { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
};
const handleUpdateUser = async (id: string, name: string, email: string) => {
try {
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
if (res.ok) {
const updated = await res.json();
setUsers(prev => prev.map(u => u.id === id ? updated : u));
} else { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
};
@ -341,8 +367,7 @@ export default function App() {
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
if (res.ok) {
const updated: User = await res.json();
// Update currentUser immediately if the acting user changed their own role,
// since currentUser is not driven by the users SSE event.
setUsers(prev => prev.map(u => u.id === id ? updated : u));
if (updated.id === currentUser?.id) setCurrentUser(updated);
} else { const d = await res.json(); throw new Error(d.error); }
} catch (err: any) { throw err; }
@ -351,19 +376,28 @@ export default function App() {
// Quick-link handlers (shared link dashboard)
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try {
await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
const res = await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
if (res.ok) {
const created = await res.json();
setLinks(prev => [...prev, created]);
}
} catch (err) { console.error('[App] Error adding link:', err); }
};
const handleUpdateLink = async (updated: QuickLink) => {
try {
await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
const res = await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
if (res.ok) {
const data = await res.json();
setLinks(prev => prev.map(l => l.id === updated.id ? data : l));
}
} catch (err) { console.error('[App] Error updating link:', err); }
};
const handleDeleteLink = async (id: string) => {
try {
await authFetch(`/api/links/${id}`, { method: 'DELETE' });
const res = await authFetch(`/api/links/${id}`, { method: 'DELETE' });
if (res.ok) setLinks(prev => prev.filter(l => l.id !== id));
} catch (err) { console.error('[App] Error deleting link:', err); }
};
@ -411,12 +445,12 @@ export default function App() {
// Startup check not done yet
if (!authChecked) {
return (
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
<div className="text-center space-y-4">
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
<GhostGridLogo className="w-16 h-16" />
<div className="p-4 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
<GhostGridLogo className="w-16 h-16 animate-pulse" />
</div>
<p className="text-xs text-fg-muted font-mono">booting...</p>
<p className="text-xs text-slate-400 font-mono">booting...</p>
</div>
</div>
);
@ -433,16 +467,16 @@ export default function App() {
// Loading data after login
if (loading) {
return (
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
<div className="text-center space-y-6 max-w-sm">
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
<GhostGridLogo className="w-20 h-20" />
<div className="p-4 bg-slate-950/80 border border-slate-850 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
<GhostGridLogo className="w-20 h-20 animate-pulse" />
</div>
<div className="space-y-2">
<h2 className="text-base font-bold tracking-tight text-fg">GhostGrid Virtualization</h2>
<p className="text-xs text-fg-muted leading-normal">Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...</p>
<div className="inline-flex items-center gap-1 bg-info-soft border border-info-line rounded-full px-2.5 py-0.5 text-[9px] font-mono text-info font-semibold mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-info animate-ping"></span>
<h2 className="text-base font-bold tracking-tight text-white">GhostGrid Virtualization</h2>
<p className="text-xs text-slate-400 leading-normal">Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...</p>
<div className="inline-flex items-center gap-1 bg-cyan-950/40 border border-cyan-900/50 rounded-full px-2.5 py-0.5 text-[9px] font-mono text-cyan-400 font-semibold mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-ping"></span>
SQLITE DATABASE HYDRATION ONGOING
</div>
</div>
@ -452,7 +486,7 @@ export default function App() {
}
return (
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col selection:bg-emerald-500/30 selection:text-emerald-300" id="main-root">
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col selection:bg-emerald-500/30 selection:text-emerald-300" id="main-root">
<Header
currentUser={currentUser}
@ -468,32 +502,32 @@ export default function App() {
<div className="flex-1 flex flex-col md:flex-row">
<aside
className={`w-full bg-header border-r border-line p-4 flex flex-col justify-between py-6 shrink-0 transition-all duration-200 ${navCollapsed ? 'md:w-20' : 'md:w-64'}`}
className={`w-full bg-[#0F172A] border-r border-[#1E293B] p-4 flex flex-col justify-between py-6 shrink-0 transition-all duration-200 ${navCollapsed ? 'md:w-20' : 'md:w-64'}`}
id="nav-sidebar"
>
<div className="space-y-5">
{/* Collapse toggle */}
<div className={`flex items-center px-1 ${navCollapsed ? 'md:justify-center justify-between' : 'justify-between'}`}>
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-fg-faint ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-slate-500 ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
<button
onClick={() => setNavCollapsed(c => !c)}
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
className="p-1.5 rounded-md text-fg-muted hover:text-success hover:bg-inner transition-all hover:cursor-pointer"
className="p-1.5 rounded-md text-slate-400 hover:text-emerald-400 hover:bg-slate-900 transition-all hover:cursor-pointer"
>
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className="space-y-4">
{navigationGroups.filter(g => g.label !== 'System' || currentUser.role.toLowerCase() === 'admin').map((group, gi) => (
{navigationGroups.map((group, gi) => (
<div key={gi} className="space-y-1">
{group.label && (
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-fg-faint px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-slate-600 px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
{group.label}
</span>
)}
{/* Thin divider stands in for the group label when collapsed */}
{group.label && navCollapsed && <div className="hidden md:block h-px bg-line mx-2" />}
{group.label && navCollapsed && <div className="hidden md:block h-px bg-slate-850 mx-2" />}
{group.items.map((item) => {
const isActive = activeTab === item.id;
return (
@ -506,8 +540,8 @@ export default function App() {
title={navCollapsed ? item.label : undefined}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-semibold font-sans tracking-wide transition-all ${navCollapsed ? 'md:justify-center' : ''} ${
isActive
? 'bg-success-soft border-l-2 border-success text-fg'
: 'text-fg-muted hover:text-fg hover:bg-inner'
? 'bg-gradient-to-r from-emerald-500/10 to-teal-500/5 border-l-4 border-emerald-500 text-white shadow-[inset_1px_0_10px_rgba(16,185,129,0.03)]'
: 'text-slate-400 hover:text-white hover:bg-slate-900'
}`}
>
{item.icon}
@ -520,15 +554,15 @@ export default function App() {
</nav>
</div>
<div className={`bg-inner p-4 border border-line rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
<h4 className="text-[10px] text-success font-mono font-bold">Overall Status</h4>
<div className="text-[11px] text-fg-muted leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-success font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-primary font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-fg font-semibold font-mono">{devices.filter(d => d.status === 'online').length}<span className="text-fg-faint">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-fg font-semibold font-mono">{labs.length}</span> configured</div>
<div className={`bg-slate-950 p-4 border border-slate-900 rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
<h4 className="text-[10px] text-emerald-400 font-mono font-bold">Overall Status</h4>
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-emerald-400 font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-indigo-400 font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
</div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full mt-2" /></div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
</aside>
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
@ -538,7 +572,6 @@ export default function App() {
bookings={bookings}
labs={labs}
devices={devices}
users={users}
links={links}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
@ -555,8 +588,7 @@ export default function App() {
labs={labs}
devices={devices}
currentUser={currentUser}
users={users}
cmkEnabled={cmkEnabled}
checkmkEnabled={checkmkEnabled}
onAddBooking={handleAddBooking}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
@ -566,8 +598,8 @@ export default function App() {
{activeTab === 'devices' && (
<DeviceInventory
devices={devices}
cmkEnabled={cmkEnabled}
cmkBaseUrl={cmkBaseUrl}
checkmkEnabled={checkmkEnabled}
checkmkBaseUrl={checkmkBaseUrl}
onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}
@ -613,7 +645,7 @@ export default function App() {
onAddLog={handleAddLogManually}
/>
)}
{activeTab === 'settings' && currentUser.role.toLowerCase() === 'admin' && (
{activeTab === 'settings' && (
<Settings currentUser={currentUser} />
)}
</main>

View File

@ -17,8 +17,7 @@ interface BookingCalendarProps {
labs: LabTemplate[];
devices: Device[];
currentUser: User;
users: User[];
cmkEnabled: boolean;
checkmkEnabled: boolean;
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
@ -69,19 +68,12 @@ const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:
// ── component ──────────────────────────────────────────────────────────────
function initials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
export default function BookingCalendar({
bookings,
labs,
devices,
currentUser,
users,
cmkEnabled,
checkmkEnabled,
onAddBooking,
onCancelBooking,
onDeleteBooking,
@ -237,8 +229,10 @@ 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: `device:${device.id}`,
labId: hostLab?.id ?? `device:${device.id}`,
userId: currentUser.id,
startDateTime: toLocalISO(quickWindow.start),
endDateTime: toLocalISO(quickWindow.end),
@ -256,23 +250,23 @@ export default function BookingCalendar({
{/* ── Quick Booking Modal ── */}
{showQuickPanel && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
<div className="w-full max-w-lg bg-card border border-line-strong rounded-2xl shadow-2xl overflow-hidden">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg bg-[#0F172A] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-line">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-success fill-success/30" />
<h3 className="font-bold text-sm text-fg font-sans">Quick Booking</h3>
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
<h3 className="font-bold text-sm text-white font-sans">Quick Booking</h3>
</div>
<button onClick={() => setShowQuickPanel(false)} className="text-fg-muted hover:text-fg transition-colors">
<button onClick={() => setShowQuickPanel(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Duration Selector */}
<div className="px-5 pt-4 space-y-1">
<p className="text-[11px] text-fg-muted font-sans">Duration starting now:</p>
<p className="text-[11px] text-slate-400 font-sans">Duration starting now:</p>
<div className="flex gap-2">
{[1, 2, 4, 8].map(h => (
<button
@ -281,14 +275,14 @@ export default function BookingCalendar({
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
quickDuration === h
? 'bg-emerald-600 border-emerald-500 text-white'
: 'bg-inner border-line text-fg-muted hover:border-success hover:text-success'
: 'bg-slate-900 border-slate-800 text-slate-300 hover:border-emerald-700 hover:text-emerald-400'
}`}
>
{h}h
</button>
))}
</div>
<p className="text-[10px] text-fg-faint font-mono">
<p className="text-[10px] text-slate-500 font-mono">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} &gt;{' '}
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
@ -299,7 +293,7 @@ export default function BookingCalendar({
<button
onClick={() => setQuickTab('labs')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
quickTab === 'labs' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
quickTab === 'labs' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
}`}
>
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
@ -307,7 +301,7 @@ export default function BookingCalendar({
<button
onClick={() => setQuickTab('devices')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
quickTab === 'devices' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
quickTab === 'devices' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
}`}
>
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
@ -318,19 +312,19 @@ export default function BookingCalendar({
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
{quickTab === 'labs' ? (
availableLabs.length === 0 ? (
<p className="text-xs text-fg-muted text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
) : (
availableLabs.map(lab => {
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
const offlineCount = labDevices.filter(d => !isOnline(d)).length;
return (
<div key={lab.id} className="flex items-center justify-between gap-3 p-3 bg-inner border border-line rounded-lg hover:border-success-line transition-all">
<div key={lab.id} className="flex items-center justify-between gap-3 p-3 bg-slate-900/60 border border-slate-800 rounded-lg hover:border-emerald-800/50 transition-all">
<div className="min-w-0">
<p className="text-xs font-bold text-fg truncate">{lab.name}</p>
<p className="text-[10px] text-fg-muted font-mono">{lab.location} · {labDevices.length} device{labDevices.length !== 1 ? 's' : ''}</p>
<p className="text-[9px] text-fg-faint truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
<p className="text-[10px] text-slate-400 font-mono">{lab.location} · {labDevices.length} device{labDevices.length !== 1 ? 's' : ''}</p>
<p className="text-[9px] text-slate-500 truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
{offlineCount > 0 && (
<p className="flex items-center gap-0.5 text-[9px] text-warning font-mono mt-0.5">
<p className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono mt-0.5">
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
</p>
)}
@ -352,20 +346,20 @@ export default function BookingCalendar({
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
return (
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
free ? 'bg-inner border-line hover:border-success-line' : 'bg-surface border-line opacity-60'
free ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
}`}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-success' : status === 'offline' ? 'bg-rose' : 'bg-fg-faint'}`} />
<p className="text-xs font-bold text-fg font-mono">{device.hostname}</p>
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-emerald-400' : status === 'offline' ? 'bg-rose-400' : 'bg-slate-500'}`} />
<p className="text-xs font-bold text-white font-mono">{device.hostname}</p>
{!online && free && (
<span className="flex items-center gap-0.5 text-[9px] text-warning font-mono" title="Not reachable in CheckMK">
<span className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono" title="Not reachable in CheckMK">
<AlertTriangle className="w-2.5 h-2.5" />{status}
</span>
)}
</div>
<p className="text-[10px] text-fg-muted font-mono">{device.type} · {device.ip}</p>
<p className="text-[9px] text-fg-faint">{device.location}</p>
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
<p className="text-[9px] text-slate-500">{device.location}</p>
</div>
{free ? (
<button
@ -375,7 +369,7 @@ export default function BookingCalendar({
Book
</button>
) : (
<span className="shrink-0 text-[10px] text-rose font-mono font-semibold">Busy</span>
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
)}
</div>
);
@ -387,32 +381,32 @@ export default function BookingCalendar({
)}
{/* ── LEFT: Visual Schedule Grid ── */}
<div className="lg:col-span-8 bg-card border border-line rounded-xl p-5 flex flex-col h-full font-sans" id="timeline-card">
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="timeline-card">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
<div>
<h2 className="text-base font-bold text-fg flex items-center gap-2">
<Calendar className="text-success w-5 h-5" />
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Calendar className="text-emerald-400 w-5 h-5" />
Bookings
</h2>
<p className="text-xs text-fg-muted">Who has which box, and until when. mutex for hardware, basically.</p>
<p className="text-xs text-slate-400">Who has which box, and until when. mutex for hardware, basically.</p>
</div>
{/* Day navigation */}
<div className="flex items-center gap-1 bg-inner p-1 rounded-lg border border-line shrink-0">
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-805 shrink-0">
<button
onClick={() => setDayOffset(dayOffset - 1)}
disabled={dayOffset <= -30}
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded disabled:opacity-30 transition-opacity"
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded disabled:opacity-30 transition-opacity"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<div className="text-xs font-semibold px-2.5 text-center text-fg min-w-[130px] font-mono select-none">
<div className="text-xs font-semibold px-2.5 text-center text-slate-200 min-w-[130px] font-mono select-none">
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
</div>
<button
onClick={() => setDayOffset(dayOffset + 1)}
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded transition-opacity"
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded transition-opacity"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
@ -420,32 +414,32 @@ export default function BookingCalendar({
</div>
{/* Matrix Grid */}
<div className="flex-1 overflow-x-auto rounded-lg border border-line p-1 bg-inner">
<div className="flex-1 overflow-x-auto rounded-lg border border-slate-850 p-1 bg-slate-950/40">
<div style={{ minWidth: '860px' }}>
{/* Header row */}
<div
className="border-b border-line pb-1"
className="border-b border-slate-800 pb-1"
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
>
<div className="text-left pl-3 text-[10px] text-fg-muted font-sans font-bold self-center">Device</div>
<div className="text-left pl-3 text-[10px] text-slate-400 font-sans font-bold self-center">Device</div>
{TIME_SLOTS.map((slot, i) => (
<div key={i} className="text-center py-1 border-l border-line">
<span className="text-[9px] font-mono text-fg-muted leading-none">{slot.label}</span>
<div key={i} className="text-center py-1 border-l border-slate-855">
<span className="text-[9px] font-mono text-slate-300 leading-none">{slot.label}</span>
</div>
))}
</div>
{/* Device rows */}
<div className="divide-y divide-line max-h-[460px] overflow-y-auto">
<div className="divide-y divide-slate-850 max-h-[460px] overflow-y-auto">
{devices.map((device) => (
<div
key={device.id}
className="items-center group hover:bg-card"
className="items-center group hover:bg-slate-900/35"
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
>
<div className="pl-3 py-2 text-left">
<p className="font-mono font-bold text-[11px] text-fg group-hover:text-success transition-colors leading-none truncate">{device.hostname}</p>
<p className="text-[9px] font-mono text-fg-faint mt-0.5 leading-none">{device.type}</p>
<p className="font-mono font-bold text-[11px] text-white group-hover:text-emerald-400 transition-colors leading-none truncate">{device.hostname}</p>
<p className="text-[9px] font-mono text-slate-500 mt-0.5 leading-none">{device.type}</p>
</div>
{TIME_SLOTS.map((slot, sIdx) => {
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
@ -454,8 +448,8 @@ export default function BookingCalendar({
if (!cur) {
return (
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-line">
<div className="w-full h-full rounded border border-dashed border-line hover:border-line-strong transition-all" />
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-slate-850">
<div className="w-full h-full rounded border border-dashed border-slate-800/40 hover:border-slate-700/60 transition-all" />
</div>
);
}
@ -470,31 +464,24 @@ export default function BookingCalendar({
: isLast ? 'rounded-r'
: '';
const borderCls = isMe
? `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' : ''}`;
? `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' : ''}`;
return (
<div
key={sIdx}
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-line`}
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-slate-850`}
>
<div
onClick={() => onSelectBookingDetails(cur)}
title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`}
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
>
{isFirst && (() => {
const booker = users.find(u => u.id === cur.userId);
const name = booker?.name ?? '';
const label = (isFirst && isLast)
? initials(name)
: name.split(' ')[0];
return (
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-fg">
{label}
</span>
);
})()}
{isFirst && (
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
{lab?.name ?? 'Device'}
</span>
)}
</div>
</div>
);
@ -506,11 +493,11 @@ export default function BookingCalendar({
</div>
{/* Legend */}
<div className="mt-4 pt-4 border-t border-line flex items-center justify-between text-[11px] font-sans text-fg-muted">
<div className="mt-4 pt-4 border-t border-slate-855 flex items-center justify-between text-[11px] font-sans text-slate-400">
<div className="flex gap-4">
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-success/30 border border-success/60" /> My Booking</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-primary/25 border border-primary/50" /> Colleague's Allocation</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded border border-dashed border-line" /> Available</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-emerald-500/30 border border-emerald-500/60" /> My Booking</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded bg-indigo-500/25 border border-indigo-500/50" /> Colleague's Allocation</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded border border-dashed border-slate-800/40" /> Available</span>
</div>
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
</div>
@ -520,13 +507,13 @@ export default function BookingCalendar({
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
{/* Quick Booking Trigger */}
<div className="bg-success-soft border border-success-line rounded-xl p-5 shadow-sm relative overflow-hidden">
<div className="bg-[#1D2535] border border-emerald-900/30 rounded-xl p-5 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
<h3 className="font-bold text-sm text-fg flex items-center gap-1.5 mb-1.5 font-sans">
<Zap className="w-4 h-4 text-success fill-success/30" />
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5 mb-1.5 font-sans">
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
Quick Booking
</h3>
<p className="text-[11px] text-fg-muted leading-relaxed font-sans mb-4">
<p className="text-[11px] text-slate-400 leading-relaxed font-sans mb-4">
Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.
</p>
@ -535,7 +522,7 @@ export default function BookingCalendar({
<button
key={h}
onClick={() => { setQuickDuration(h); setShowQuickPanel(true); }}
className="py-2.5 bg-card border border-line hover:border-success text-fg-muted hover:text-success font-sans font-semibold text-xs rounded-lg transition-all"
className="py-2.5 bg-slate-900 border border-slate-800 hover:border-emerald-500 hover:bg-slate-900 text-slate-200 hover:text-emerald-400 font-sans font-semibold text-xs rounded-lg transition-all"
>
{h}h
</button>
@ -544,37 +531,37 @@ export default function BookingCalendar({
<button
onClick={() => setShowQuickPanel(true)}
className="w-full py-2 bg-success/15 hover:bg-success/25 border border-success-line text-success font-semibold text-xs rounded-lg flex items-center justify-center gap-1.5 transition-all"
className="w-full py-2 bg-emerald-600/20 hover:bg-emerald-600/30 border border-emerald-800/50 text-emerald-400 font-semibold text-xs rounded-lg flex items-center justify-center gap-1.5 transition-all"
>
<Clock className="w-3.5 h-3.5" />
Show Available Now
</button>
<div className="mt-3 flex items-center gap-3 text-[10px] text-fg-faint font-mono">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-success" />{availableLabs.length} labs free</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-info" />{availableDevices.length} devices free</span>
<div className="mt-3 flex items-center gap-3 text-[10px] text-slate-500 font-mono">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />{availableLabs.length} labs free</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />{availableDevices.length} devices free</span>
</div>
</div>
{/* Standard Booking Form */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3">
<Calendar className="w-4.5 h-4.5 text-primary" />
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Reserve Slot
</h3>
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
{/* Resource type toggle: whole lab topology or a single device */}
<div>
<label className="block text-fg-muted font-semibold mb-1">Reserve</label>
<label className="block text-slate-300 font-semibold mb-1">Reserve</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setResourceType('lab')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'lab'
? 'bg-primary-soft border-primary text-primary'
: 'bg-inner border-line text-fg-muted hover:text-fg'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Layers className="w-3.5 h-3.5" /> Topology
@ -584,8 +571,8 @@ export default function BookingCalendar({
onClick={() => setResourceType('device')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'device'
? 'bg-primary-soft border-primary text-primary'
: 'bg-inner border-line text-fg-muted hover:text-fg'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Server className="w-3.5 h-3.5" /> Single Device
@ -595,11 +582,11 @@ export default function BookingCalendar({
{resourceType === 'lab' ? (
<div>
<label className="block text-fg-muted font-semibold mb-1">Topology</label>
<label className="block text-slate-300 font-semibold mb-1">Topology</label>
<select
value={selectedLabId}
onChange={(e) => setSelectedLabId(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
>
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
<optgroup label="Global Topologies">
@ -619,11 +606,11 @@ export default function BookingCalendar({
</div>
) : (
<div>
<label className="block text-fg-muted font-semibold mb-1">Device</label>
<label className="block text-slate-300 font-semibold mb-1">Device</label>
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono"
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
@ -636,7 +623,7 @@ export default function BookingCalendar({
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-fg-muted font-semibold mb-1">Start Date</label>
<label className="block text-slate-300 font-semibold mb-1">Start Date</label>
<input
type="date"
value={startDate}
@ -649,38 +636,38 @@ export default function BookingCalendar({
setDayOffset(Math.round((sel - today) / 86_400_000));
if (e.target.value > endDate) setEndDate(e.target.value);
}}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono text-xs"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">End Date</label>
<label className="block text-slate-300 font-semibold mb-1">End Date</label>
<input
type="date"
value={endDate}
min={startDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-fg-muted font-semibold mb-1">Start</label>
<label className="block text-slate-300 font-semibold mb-1">Start</label>
<select
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono"
>
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">End</label>
<label className="block text-slate-300 font-semibold mb-1">End</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono"
>
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
@ -688,13 +675,13 @@ export default function BookingCalendar({
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Notes / Objective</label>
<label className="block text-slate-300 font-semibold mb-1">Notes / Objective</label>
<textarea
required
rows={3}
value={bookingNotes}
onChange={(e) => setBookingNotes(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
/>
</div>
@ -705,16 +692,16 @@ export default function BookingCalendar({
if (conflict.hasConflict) {
return (
<div className="bg-rose-soft p-2.5 rounded border border-rose-line flex gap-2 text-rose text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose shrink-0" />
<div className="bg-rose-950/40 p-2.5 rounded border border-rose-900/60 flex gap-2 text-rose-300 text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<span>{conflict.message}</span>
</div>
);
}
if (cmkEnabled && offline.length > 0) {
if (checkmkEnabled && offline.length > 0) {
return (
<div className="bg-warning-soft p-2.5 rounded border border-warning-line flex gap-2 text-warning text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
<div className="bg-amber-950/40 p-2.5 rounded border border-amber-800/60 flex gap-2 text-amber-300 text-[11px] leading-normal">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>
Warning {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
</span>
@ -722,8 +709,8 @@ export default function BookingCalendar({
);
}
return (
<div className="bg-success-soft p-2.5 rounded border border-success-line flex gap-2 text-success text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
<div className="bg-emerald-950/20 p-2.5 rounded border border-emerald-900/40 flex gap-2 text-emerald-300 text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Timeframe is available.</span>
</div>
);
@ -737,7 +724,7 @@ export default function BookingCalendar({
<button
type="submit"
disabled={disabled}
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs transition-colors hover:cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-600"
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs transition-colors hover:cursor-pointer disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-emerald-600"
>
Confirm Reservation
</button>
@ -748,7 +735,7 @@ export default function BookingCalendar({
</div>
{/* ── Reservation Table ── */}
<div className="lg:col-span-12 bg-card border border-line rounded-xl p-5 sm:p-6 font-sans shadow-sm" id="reservation-registry-card">
<div className="lg:col-span-12 bg-[#1E293B] border border-slate-800 rounded-xl p-5 sm:p-6 font-sans shadow-sm" id="reservation-registry-card">
<button
type="button"
onClick={() => setShowReservations(s => !s)}
@ -756,26 +743,26 @@ export default function BookingCalendar({
aria-expanded={showReservations}
>
<div>
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-fg-muted transition-transform ${showReservations ? '' : '-rotate-90'}`} />
<Database className="w-4 h-4 text-success" />
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${showReservations ? '' : '-rotate-90'}`} />
<Database className="w-4 h-4 text-emerald-400" />
Reservations
</h3>
<p className="text-xs text-fg-muted pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
<p className="text-xs text-slate-400 pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
</div>
<span className="text-[10px] bg-inner px-2.5 py-1 rounded font-mono font-bold text-fg-muted border border-line">
<span className="text-[10px] bg-slate-950 px-2.5 py-1 rounded font-mono font-bold text-slate-300 border border-slate-800">
DATABASE SELECT: {bookings.length} RECORDS
</span>
</button>
{!showReservations ? null : bookings.length === 0 ? (
<p className="mt-4 text-fg-faint text-xs text-center py-6 italic border border-dashed border-line rounded-lg">
<p className="mt-4 text-slate-500 text-xs text-center py-6 italic border border-dashed border-slate-800 rounded-lg">
No active reservation structures currently exist inside the database.
</p>
) : (
<div className="mt-4 overflow-x-auto rounded-lg border border-line bg-inner">
<table className="w-full text-xs text-left text-fg-muted divide-y divide-line">
<thead className="bg-inner text-fg-muted font-mono text-[10px] uppercase tracking-wider">
<div className="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/10">
<table className="w-full text-xs text-left text-slate-300 divide-y divide-slate-800">
<thead className="bg-[#0f172a]/60 text-slate-400 font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Topology / Resource</th>
@ -785,7 +772,7 @@ export default function BookingCalendar({
<th className="px-4 py-3 text-right font-sans">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-line bg-card font-sans">
<tbody className="divide-y divide-slate-800 bg-slate-950/10 font-sans">
{bookings.map((b) => {
const lab = labs.find(l => l.id === b.labId);
const isDeviceBooking = b.labId?.startsWith('device:');
@ -795,35 +782,35 @@ export default function BookingCalendar({
const tStart = new Date(b.startDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const tEnd = new Date(b.endDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<tr key={b.id} className="hover:bg-inner transition">
<td className="px-4 py-3.5 font-mono font-bold text-success">#{b.id.slice(-8)}</td>
<tr key={b.id} className="hover:bg-slate-900/40 transition">
<td className="px-4 py-3.5 font-mono font-bold text-emerald-500/80">#{b.id.slice(-8)}</td>
<td className="px-4 py-3.5">
<span className="text-fg font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
<span className="text-[10px] text-fg-muted font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
<span className="text-slate-100 font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
<span className="text-[10px] text-slate-400 font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
</td>
<td className="px-4 py-3.5 font-mono">
<span className="block text-fg">{day}</span>
<span className="text-[10px] text-fg-muted">{tStart} - {tEnd}</span>
<span className="block text-slate-200">{day}</span>
<span className="text-[10px] text-slate-400">{tStart} - {tEnd}</span>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold border capitalize ${
b.status === 'active' ? 'bg-success-soft border-success-line text-success' :
b.status === 'upcoming' ? 'bg-primary-soft border-primary-line text-primary' :
b.status === 'completed' ? 'bg-inner border-line text-fg-muted' :
'bg-rose-soft border-rose-line text-rose font-bold'
b.status === 'active' ? 'bg-emerald-950/60 border-emerald-500/30 text-emerald-400' :
b.status === 'upcoming' ? 'bg-indigo-950/60 border-indigo-500/30 text-indigo-400' :
b.status === 'completed' ? 'bg-slate-900 border-slate-800 text-slate-400' :
'bg-rose-950/60 border-rose-500/20 text-rose-400 font-bold'
}`}>{b.status}</span>
</td>
<td className="px-4 py-3.5 text-fg-muted max-w-[150px] truncate">{b.notes || '-'}</td>
<td className="px-4 py-3.5 text-slate-400 max-w-[150px] truncate">{b.notes || '-'}</td>
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
<button
onClick={() => onSelectBookingDetails(b)}
className="px-2.5 py-1.5 bg-inner border border-line hover:border-line-strong text-info hover:opacity-80 rounded text-[11px] font-semibold cursor-pointer"
className="px-2.5 py-1.5 bg-slate-900 border border-slate-800 hover:border-slate-700 text-cyan-400 hover:text-cyan-300 rounded text-[11px] font-semibold cursor-pointer"
>
Details
</button>
<button
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
className="px-2.5 py-1.5 text-[11px] bg-rose-soft hover:opacity-80 text-rose rounded transition cursor-pointer"
className="px-2.5 py-1.5 text-[11px] bg-rose-950/20 hover:bg-rose-900/30 text-rose-400 hover:text-rose-300 rounded transition cursor-pointer"
>
Delete
</button>

View File

@ -47,10 +47,10 @@ export default function BookingDetailsModal({
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
const setupTriggered = booking.semaphoreSetupTriggered || localSetupTriggered;
const setupJobId = booking.semaphoreSetupJobId || localSetupJobId;
const teardownTriggered = booking.semaphoreTeardownTriggered || localTeardownTriggered;
const teardownJobId = booking.semaphoreTeardownJobId || localTeardownJobId;
const setupTriggered = booking.ansibleSetupTriggered || localSetupTriggered;
const setupJobId = booking.ansibleSetupJobId || localSetupJobId;
const teardownTriggered = booking.ansibleTeardownTriggered || localTeardownTriggered;
const teardownJobId = booking.ansibleTeardownJobId || localTeardownJobId;
async function manualTrigger(type: 'setup' | 'teardown') {
setTriggering(true);
@ -118,26 +118,26 @@ export default function BookingDetailsModal({
};
return (
<div className="fixed inset-0 bg-overlay backdrop-blur-sm z-50 flex items-center justify-center p-4" id="booking-details-modal">
<div className="bg-card border border-line w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4" id="booking-details-modal">
<div className="bg-[#1E293B] border border-slate-705 w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="bg-inner px-6 py-4 border-b border-line flex items-center justify-between font-sans">
<div className="bg-slate-900 px-6 py-4 border-b border-slate-800 flex items-center justify-between font-sans">
<div className="flex items-center gap-3">
<div className="p-1.5 bg-success-soft border border-success-line rounded-lg text-success">
<div className="p-1.5 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400">
<HardDrive className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<span>Reservation Details</span>
<span className="text-fg-faint font-mono font-normal">#{booking.id}</span>
<span className="text-slate-500 font-mono font-normal">#{booking.id}</span>
</h3>
<p className="text-[11px] text-fg-muted">Inspect allocation status and diagnostic automation APIs</p>
<p className="text-[11px] text-slate-400">Inspect allocation status and diagnostic automation APIs</p>
</div>
</div>
<button
onClick={onClose}
className="text-fg-muted hover:text-fg p-1 hover:bg-inner rounded transition-colors"
className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
@ -150,10 +150,10 @@ export default function BookingDetailsModal({
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* Left Box: Meta stats block */}
<div className="md:col-span-5 bg-inner rounded-xl p-4 border border-line space-y-4">
<div className="md:col-span-5 bg-slate-950/60 rounded-xl p-4 border border-slate-850 space-y-4">
<div>
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-fg-faint">Scheduled Blueprint</span>
<h4 className="text-base font-bold text-fg mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-slate-500">Scheduled Blueprint</span>
<h4 className="text-base font-bold text-white mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono capitalize mt-2 font-semibold border"
style={{
backgroundColor:
@ -180,35 +180,35 @@ export default function BookingDetailsModal({
{/* Time blocks */}
<div className="space-y-2.5 font-sans">
<div className="flex gap-2.5 text-xs text-fg-muted">
<Calendar className="w-4.5 h-4.5 text-success shrink-0 mt-0.5" />
<div className="flex gap-2.5 text-xs text-slate-300">
<Calendar className="w-4.5 h-4.5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Start Time</span>
<span className="font-mono text-fg">{startFormatted}</span>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Start Time</span>
<span className="font-mono text-slate-200">{startFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-fg-muted">
<Clock className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
<div className="flex gap-2.5 text-xs text-slate-300">
<Clock className="w-4.5 h-4.5 text-indigo-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Terminations On</span>
<span className="font-mono text-fg">{endFormatted}</span>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Terminations On</span>
<span className="font-mono text-slate-200">{endFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-fg-muted">
<UserIcon className="w-4.5 h-4.5 text-fg-faint shrink-0 mt-0.5" />
<div className="flex gap-2.5 text-xs text-slate-300">
<UserIcon className="w-4.5 h-4.5 text-slate-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Reserved Operator</span>
<span className="text-fg">{creator.name}</span>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Reserved Operator</span>
<span className="text-slate-200">{creator.name}</span>
</div>
</div>
</div>
{/* Operator Notes */}
<div className="pt-3 border-t border-line font-sans text-xs">
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
<p className="text-fg-muted leading-relaxed italic bg-inner border border-line p-2.5 rounded">
<div className="pt-3 border-t border-slate-850/80 font-sans text-xs">
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
<p className="text-slate-300 leading-relaxed italic bg-slate-900 border border-slate-850 p-2.5 rounded">
"{booking.notes || 'No objectives specified.'}"
</p>
</div>
@ -216,27 +216,27 @@ export default function BookingDetailsModal({
</div>
{/* Right Box: Allocated Device checklist */}
<div className="md:col-span-7 bg-inner border border-line rounded-xl p-4 flex flex-col justify-between">
<div className="md:col-span-7 bg-slate-900/35 border border-slate-800 rounded-xl p-4 flex flex-col justify-between">
<div>
<div className="flex justify-between items-center mb-3">
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-success">Allocated Nodes Pool ({mappedDevices.length})</span>
<span className="text-[10px] text-fg-faint font-mono">Location: {lab?.location}</span>
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-emerald-400">Allocated Nodes Pool ({mappedDevices.length})</span>
<span className="text-[10px] text-slate-500 font-mono">Location: {lab?.location}</span>
</div>
{mappedDevices.length === 0 ? (
<p className="text-xs text-fg-muted italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
<p className="text-xs text-slate-400 italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
{mappedDevices.map((device) => (
<div key={device.id} className="p-3 bg-card border border-line hover:border-line-strong rounded-lg flex items-center justify-between font-sans">
<div key={device.id} className="p-3 bg-slate-950/65 border border-slate-850 hover:border-slate-800 rounded-lg flex items-center justify-between font-sans">
<div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
<div>
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
<p className="text-[9px] text-fg-muted mt-1 font-mono leading-none">{device.type} {device.location}</p>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] text-slate-400 mt-1 font-mono leading-none">{device.type} {device.location}</p>
</div>
</div>
<span className="text-xs font-mono font-bold text-success">{device.ip}</span>
<span className="text-xs font-mono font-bold text-emerald-400">{device.ip}</span>
</div>
))}
</div>
@ -244,8 +244,8 @@ export default function BookingDetailsModal({
</div>
{/* Notice */}
<div className="bg-inner p-3 rounded-lg border border-line flex gap-2.5 text-[11px] leading-normal text-fg-muted mt-4 font-sans">
<HelpCircle className="w-4 h-4 text-success shrink-0 mt-0.5" />
<div className="bg-slate-900 p-3 rounded-lg border border-slate-800 flex gap-2.5 text-[11px] leading-normal text-slate-400 mt-4 font-sans">
<HelpCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<span>During the active slot, GhostGrid automatically binds these switches exclusively to your namespace and drops custom console-lines to open CLI pipelines.</span>
</div>
</div>
@ -254,27 +254,27 @@ export default function BookingDetailsModal({
{/* Ansible Semaphore automation status */}
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
<div className="border border-orange-line rounded-xl bg-orange-soft p-4 font-sans">
<div className="border border-orange-900/40 rounded-xl bg-orange-950/10 p-4 font-sans">
<div className="flex items-center gap-2 mb-3">
<Terminal className="w-4 h-4 text-orange" />
<span className="text-[10px] uppercase tracking-wider font-bold text-orange">Ansible Automation</span>
<Terminal className="w-4 h-4 text-orange-400" />
<span className="text-[10px] uppercase tracking-wider font-bold text-orange-400">Ansible Automation</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{lab.semaphoreSetupTemplateId && (
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Setup</p>
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Setup</p>
{setupTriggered ? (
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-fg-faint font-mono">Pending</span>
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('setup')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-soft hover:opacity-80 border border-orange-line text-orange text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
@ -283,20 +283,20 @@ export default function BookingDetailsModal({
</div>
)}
{lab.semaphoreTeardownTemplateId && (
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Teardown</p>
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Teardown</p>
{teardownTriggered ? (
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
<CheckCircle className="w-3.5 h-3.5" />
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
</div>
) : (
<div className="flex items-center gap-2">
<span className="text-xs text-fg-faint font-mono">Pending</span>
<span className="text-xs text-slate-500 font-mono">Pending</span>
<button
onClick={() => manualTrigger('teardown')}
disabled={triggering || booking.status === 'cancelled'}
className="flex items-center gap-1 px-2 py-1 bg-orange-soft hover:opacity-80 border border-orange-line text-orange text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
className="flex items-center gap-1 px-2 py-1 bg-orange-900/40 hover:bg-orange-800/40 border border-orange-800/50 text-orange-400 text-[10px] font-semibold rounded transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="w-2.5 h-2.5" /> Trigger now
</button>
@ -306,7 +306,7 @@ export default function BookingDetailsModal({
)}
</div>
{triggerStatus && (
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-danger' : 'text-success'}`}>
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{triggerStatus}
</p>
)}
@ -339,24 +339,22 @@ export default function BookingDetailsModal({
</div>
{/* Modal Footer */}
<div className="bg-inner px-6 py-4 border-t border-line flex justify-between items-center font-sans gap-3 flex-wrap">
<div className="bg-slate-900 px-6 py-4 border-t border-slate-800 flex justify-between items-center font-sans gap-3 flex-wrap">
<div className="flex gap-2">
{/* Delete button option — admin only */}
{currentUser.role.toLowerCase() === 'admin' && (
<button
onClick={() => {
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
onDelete(booking.id);
onClose();
}
}}
className="px-3 py-1.5 bg-rose-soft border border-rose-line text-rose hover:opacity-80 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Purge Entry (SQLite DELETE)</span>
</button>
)}
{/* Delete button option */}
<button
onClick={() => {
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
onDelete(booking.id);
onClose();
}
}}
className="px-3 py-1.5 bg-rose-950/40 border border-rose-900/60 text-rose-400 hover:bg-rose-900 hover:text-white rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Purge Entry (SQLite DELETE)</span>
</button>
{/* Cancel Status Toggle */}
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
@ -367,7 +365,7 @@ export default function BookingDetailsModal({
onClose();
}
}}
className="px-3 py-1.5 bg-warning-soft border border-warning-line text-warning hover:opacity-80 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
className="px-3 py-1.5 bg-amber-950/40 border border-amber-700/50 text-amber-400 hover:bg-amber-900/60 hover:border-amber-500 hover:text-amber-300 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
>
<Ban className="w-3.5 h-3.5" />
<span>Cancel Reservation</span>
@ -378,7 +376,7 @@ export default function BookingDetailsModal({
<button
onClick={onClose}
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg text-xs transition-colors hover:cursor-pointer"
className="px-4 py-1.5 bg-emerald-600 hover:bg-emerald-550 text-slate-950 hover:text-slate-1000 font-bold rounded-lg text-xs transition-colors hover:cursor-pointer"
>
Acknowledge Specs
</button>

View File

@ -15,7 +15,6 @@ interface DashboardProps {
bookings: Booking[];
labs: LabTemplate[];
devices: Device[];
users: User[];
links: QuickLink[];
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
@ -27,8 +26,8 @@ interface DashboardProps {
}
const LINK_ACCENT: Record<string, string> = {
emerald: 'text-success', cyan: 'text-info', indigo: 'text-primary',
amber: 'text-warning', rose: 'text-rose', violet: 'text-violet',
emerald: 'text-emerald-400', cyan: 'text-cyan-400', indigo: 'text-indigo-400',
amber: 'text-amber-400', rose: 'text-rose-400', violet: 'text-violet-400',
};
export default function Dashboard({
@ -36,7 +35,6 @@ export default function Dashboard({
bookings,
labs,
devices,
users,
links,
onCancelBooking,
onDeleteBooking,
@ -55,15 +53,14 @@ export default function Dashboard({
const ONE_HOUR_MS = 60 * 60 * 1000;
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
// "Active" = currently running across ALL users, plus a 1h grace window after
// the end so freshly-finished sessions linger briefly instead of jumping to "Expired".
const activeBookings = bookings.filter(b => {
if (b.status === 'cancelled') return false;
// "Active" = currently running, plus a 1h grace window after the end so
// freshly-finished sessions linger briefly instead of jumping to "Expired".
const activeBookings = personalBookings.filter(b => {
const start = new Date(b.startDateTime).getTime();
const end = new Date(b.endDateTime).getTime();
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
});
const upcomingBookings = bookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
const upcomingBookings = personalBookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
// Quick state checklist for the user to mark items as done as they test their lab!
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
@ -95,26 +92,26 @@ export default function Dashboard({
<div className="space-y-6" id="dashboard-cockpit-root">
{/* Banner */}
<div className="bg-card border border-line rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="bg-[#1E293B] border border-slate-800 rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-xl font-bold tracking-tight text-fg font-sans">
Welcome back, <span className="text-success">{currentUser.name}</span>
<h2 className="text-xl font-bold tracking-tight text-white font-sans">
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>
</h2>
<p className="text-xs text-fg-muted font-sans">
<p className="text-xs text-slate-400 font-sans">
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={onNavigateToCalendar}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-sans font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 hover:cursor-pointer"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 hover:cursor-pointer"
>
<Zap className="w-4 h-4" />
<Zap className="w-4 h-4 text-slate-950 fill-slate-950" />
Book Your Lab
</button>
<button
onClick={onNavigateToDevices}
className="px-4 py-2 bg-inner hover:bg-card text-fg border border-line hover:border-line-strong rounded-lg text-xs font-semibold transition-all flex items-center gap-1 hover:cursor-pointer"
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-800 hover:border-slate-700 rounded-lg text-xs font-semibold transition-all flex items-center gap-1 hover:cursor-pointer"
>
Browse Inventory
</button>
@ -128,25 +125,24 @@ export default function Dashboard({
<div className="lg:col-span-8 space-y-6">
{/* Active Sessions */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-4 font-sans justify-between">
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-4 font-sans justify-between">
<span className="flex items-center gap-2">
<Clock className="w-4 h-4 text-success" />
<Clock className="w-4 h-4 text-emerald-400" />
Active Reservations
</span>
<span className="w-2 h-2 rounded-full bg-success shrink-0" />
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
</h3>
{activeBookings.length === 0 ? (
<div className="text-center py-8 bg-inner rounded-lg border border-line font-sans">
<PlayCircle className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
<p className="text-xs text-fg-muted">No active sessions.</p>
<div className="text-center py-8 bg-slate-900/35 rounded-lg border border-slate-800 font-sans">
<PlayCircle className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No active sessions.</p>
</div>
) : (
<div className="space-y-4 font-sans">
{activeBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const booker = users.find(u => u.id === booking.userId);
const startDate = new Date(booking.startDateTime);
const endDate = new Date(booking.endDateTime);
const sameDay = startDate.toDateString() === endDate.toDateString();
@ -157,48 +153,43 @@ export default function Dashboard({
? endDate.toLocaleTimeString('en-US', timeFmt)
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
return (
<div key={booking.id} className="p-4 bg-inner border border-success-line rounded-xl relative overflow-hidden">
<div className="absolute top-0 right-0 bottom-0 w-1 bg-success" />
<div key={booking.id} className="p-4 bg-slate-950/60 border border-emerald-500/30 rounded-xl relative overflow-hidden">
<div className="absolute top-0 right-0 bottom-0 w-1 bg-emerald-500" />
<div className="flex justify-between items-start mb-2 gap-2">
<div>
<h4 className="text-sm font-bold text-fg font-sans">{lab?.name}</h4>
<span className="text-[10px] text-fg-muted flex items-center gap-1 font-sans mt-0.5">
<MapPin className="w-3.5 h-3.5 text-fg-faint" />
<h4 className="text-sm font-bold text-white font-sans">{lab?.name}</h4>
<span className="text-[10px] text-slate-400 flex items-center gap-1 font-sans mt-0.5">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
{lab?.location}
</span>
{booker && (
<span className="text-[10px] text-fg-faint font-sans mt-0.5 block">{booker.name}</span>
)}
</div>
{/* Countdown Pill */}
<span className="px-2.5 py-0.5 bg-success-soft border border-success-line text-success font-mono font-bold text-[10px] rounded-full">
<span className="px-2.5 py-0.5 bg-emerald-950 border border-emerald-900 text-emerald-400 font-mono font-bold text-[10px] rounded-full">
{getRemainingTimeText(booking.endDateTime)}
</span>
</div>
<div className="pt-3 border-t border-line flex justify-between items-center text-[10px]">
<span className="font-mono text-fg-muted">
<div className="pt-3 border-t border-slate-800/60 flex justify-between items-center text-[10px]">
<span className="font-mono text-slate-400">
{startF} {endF}
</span>
<div className="flex gap-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-3 py-1.5 bg-success-soft border border-success-line text-success hover:opacity-80 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
className="px-3 py-1.5 bg-emerald-950/80 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
>
Details
</button>
{booking.userId === currentUser.id && (
<button
onClick={() => {
if (confirm('Release this reservation early?')) {
onCancelBooking(booking.id);
}
}}
className="px-3 py-1.5 bg-rose-soft border border-rose-line text-rose hover:opacity-80 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
>
Release
</button>
)}
<button
onClick={() => {
if (confirm('Release this reservation early?')) {
onCancelBooking(booking.id);
}
}}
className="px-3 py-1.5 bg-rose-950/40 border border-rose-700/50 text-rose-400 hover:bg-rose-900/60 hover:border-rose-500 hover:text-rose-300 rounded-lg text-xs font-semibold transition-all hover:cursor-pointer"
>
Release
</button>
</div>
</div>
</div>
@ -209,73 +200,65 @@ export default function Dashboard({
</div>
{/* Upcoming Sessions */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
<Calendar className="w-4 h-4 text-fg-muted" />
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
<Calendar className="w-4 h-4 text-slate-400" />
Upcoming ({upcomingBookings.length})
</h3>
{upcomingBookings.length === 0 ? (
<p className="text-xs text-fg-muted py-4 text-center">No upcoming reservations.</p>
<p className="text-xs text-slate-400 py-4 text-center">No upcoming reservations.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{upcomingBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const booker = users.find(u => u.id === booking.userId);
const dayStr = new Date(booking.startDateTime).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
const startF = new Date(booking.startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
return (
<div key={booking.id} className="p-3 bg-inner border border-line hover:border-line-strong rounded-lg flex flex-col justify-between">
<div key={booking.id} className="p-3 bg-slate-900/30 border border-slate-800 hover:border-slate-700 rounded-lg flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-1">
<span className="font-mono font-bold text-[10px] text-primary bg-primary-soft border border-primary-line px-2 py-0.5 rounded">
<span className="font-mono font-bold text-[10px] text-indigo-400 bg-indigo-950/50 border border-indigo-900/50 px-2 py-0.5 rounded">
{dayStr}
</span>
<span className="text-[10px] font-mono text-fg-faint">
<span className="text-[10px] font-mono text-slate-500">
{startF} {endF}
</span>
</div>
<h4 className="text-xs font-bold text-fg mt-1 font-sans">{lab?.name}</h4>
{booker && (
<span className="text-[10px] text-fg-faint font-sans block mt-0.5">{booker.name}</span>
)}
<p className="text-[10px] text-fg-muted line-clamp-1 mt-0.5 leading-normal">
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
<p className="text-[10px] text-slate-400 line-clamp-1 mt-0.5 leading-normal">
{booking.notes}
</p>
</div>
<div className="pt-2 mt-2 border-t border-line flex justify-end gap-1.5">
<div className="pt-2 mt-2 border-t border-slate-800 flex justify-end gap-1.5">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 text-xs text-success hover:opacity-80 bg-success-soft border border-success-line rounded-lg font-semibold transition hover:cursor-pointer"
className="px-2.5 py-1 text-xs text-emerald-400 hover:text-emerald-300 bg-emerald-950/40 border border-emerald-900/30 rounded-lg font-semibold transition hover:cursor-pointer"
>
Details
</button>
{booking.userId === currentUser.id && (
<button
onClick={() => {
if (confirm('Cancel this upcoming reservation?')) {
onCancelBooking(booking.id);
}
}}
className="px-2.5 py-1 text-xs text-fg-muted hover:text-fg hover:bg-card rounded-lg border border-line hover:cursor-pointer transition"
>
Cancel
</button>
)}
{currentUser.role.toLowerCase() === 'admin' && (
<button
onClick={() => {
if (confirm('Permanently delete this reservation?')) {
onDeleteBooking(booking.id);
}
}}
className="px-2.5 py-1 text-xs text-rose hover:opacity-80 hover:bg-rose-soft rounded-lg border border-rose-line hover:cursor-pointer transition"
>
Purge
</button>
)}
<button
onClick={() => {
if (confirm('Cancel this upcoming reservation?')) {
onCancelBooking(booking.id);
}
}}
className="px-2.5 py-1 text-xs text-slate-400 hover:text-slate-200 hover:bg-slate-800 rounded-lg border border-slate-700/50 hover:cursor-pointer transition"
>
Cancel
</button>
<button
onClick={() => {
if (confirm('Permanently delete this reservation?')) {
onDeleteBooking(booking.id);
}
}}
className="px-2.5 py-1 text-xs text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 rounded-lg border border-rose-900/30 hover:cursor-pointer transition"
>
Purge
</button>
</div>
</div>
);
@ -290,9 +273,9 @@ export default function Dashboard({
<div className="lg:col-span-4 space-y-6">
{/* Lab Checklist */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
<ListTodo className="w-4 h-4 text-fg-muted" />
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5">
<ListTodo className="w-4 h-4 text-slate-400" />
Lab Checklist
</h3>
@ -301,15 +284,15 @@ export default function Dashboard({
<div
key={item.id}
onClick={() => toggleTodo(item.id)}
className="flex items-start gap-2.5 p-2 bg-inner hover:bg-card rounded-lg cursor-pointer transition-all border border-line"
className="flex items-start gap-2.5 p-2 bg-slate-900/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-800/60"
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {}}
className="mt-0.5 rounded border-line-strong text-success w-3.5 h-3.5 shrink-0"
className="mt-0.5 rounded border-slate-700 text-emerald-500 w-3.5 h-3.5 shrink-0"
/>
<span className={`text-xs leading-tight ${item.checked ? 'text-fg-faint line-through' : 'text-fg-muted'}`}>
<span className={`text-xs leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-300'}`}>
{item.text}
</span>
</div>
@ -318,27 +301,27 @@ export default function Dashboard({
</div>
{/* Quick Links */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5 justify-between">
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3.5 justify-between">
<span className="flex items-center gap-2">
<LinkIcon className="w-4 h-4 text-fg-muted" />
<LinkIcon className="w-4 h-4 text-slate-400" />
Quick Links
</span>
<button
onClick={onNavigateToLinks}
className="text-[10px] text-fg-muted hover:text-fg font-semibold flex items-center gap-0.5 hover:cursor-pointer"
className="text-[10px] text-slate-400 hover:text-slate-200 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
>
Manage <ArrowRight className="w-3 h-3" />
</button>
</h3>
{links.length === 0 ? (
<div className="text-center py-6 bg-inner rounded-lg border border-line">
<Globe className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
<p className="text-xs text-fg-muted">No shared links yet.</p>
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-800">
<Globe className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No shared links yet.</p>
<button
onClick={onNavigateToLinks}
className="text-xs text-fg-muted font-semibold underline mt-1.5 hover:text-fg hover:cursor-pointer"
className="text-xs text-slate-400 font-semibold underline mt-1.5 hover:text-slate-200 hover:cursor-pointer"
>
Add links
</button>
@ -355,23 +338,23 @@ export default function Dashboard({
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2.5 p-2 bg-inner hover:bg-card rounded-lg border border-line hover:border-line-strong transition-all"
className="group flex items-center gap-2.5 p-2 bg-slate-900/40 hover:bg-slate-900 rounded-lg border border-slate-800/60 hover:border-slate-700 transition-all"
>
<span className={`w-7 h-7 rounded-md bg-surface border border-line flex items-center justify-center shrink-0 ${accent}`}>
<span className={`w-7 h-7 rounded-md bg-slate-950 border border-slate-800 flex items-center justify-center shrink-0 ${accent}`}>
<Globe className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-xs font-semibold text-fg group-hover:text-fg truncate">{link.title}</span>
<span className="block text-xs font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
<span className={`block text-[10px] font-mono truncate ${accent}`}>{host}</span>
</span>
<ExternalLink className="w-3.5 h-3.5 text-fg-faint group-hover:text-fg-muted shrink-0" />
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 shrink-0" />
</a>
);
})}
{links.length > 6 && (
<button
onClick={onNavigateToLinks}
className="w-full text-center text-[10px] text-fg-faint hover:text-fg-muted pt-1.5 font-semibold hover:cursor-pointer"
className="w-full text-center text-[10px] text-slate-500 hover:text-slate-300 pt-1.5 font-semibold hover:cursor-pointer"
>
+{links.length - 6} more links
</button>

View File

@ -15,8 +15,8 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
interface DeviceInventoryProps {
devices: Device[];
cmkEnabled: boolean;
cmkBaseUrl: string;
checkmkEnabled: boolean;
checkmkBaseUrl: string;
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
@ -24,8 +24,8 @@ interface DeviceInventoryProps {
export default function DeviceInventory({
devices,
cmkEnabled,
cmkBaseUrl,
checkmkEnabled,
checkmkBaseUrl,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
@ -65,14 +65,14 @@ export default function DeviceInventory({
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
const cmkHostUrl = (d: Device) =>
cmkEnabled && cmkBaseUrl && d.cmkHostname
? `${cmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
: null;
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
if (s === 'online') return { label: 'online', badge: 'bg-success-soft border-success-line text-success', dot: 'bg-success' };
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-soft border-rose-line text-rose', dot: 'bg-rose' };
return { label: 'unknown', badge: 'bg-inner border-line text-fg-muted', dot: 'bg-fg-faint' };
if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
};
// Filtered devices list
@ -160,31 +160,31 @@ export default function DeviceInventory({
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
const renderEmergencySheetHtml = (text: string) => {
if (!text) return <p className="text-fg-muted italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
const lines = text.split('\n');
return lines.map((line, idx) => {
// Headers
if (line.startsWith('### ')) {
return <h4 key={idx} className="text-sm font-bold text-fg mt-4 mb-2 border-b border-line pb-1 font-sans">{line.replace('### ', '')}</h4>;
return <h4 key={idx} className="text-sm font-bold text-slate-100 mt-4 mb-2 border-b border-slate-800 pb-1 font-sans">{line.replace('### ', '')}</h4>;
}
if (line.startsWith('#### ')) {
return <h5 key={idx} className="text-xs font-bold text-success mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
}
if (line.startsWith('**') && line.endsWith('**')) {
return <p key={idx} className="text-xs font-semibold text-fg mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
}
// Bullet lists
if (line.startsWith('* ') || line.startsWith('- ')) {
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
<span className="text-success"></span>
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-500"></span>
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
</div>;
}
// Numeric lists
if (/^\d+\s*\.\s/.test(line)) {
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
<span className="text-success font-bold">{line.match(/^\d+/)?.[0]}.</span>
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
</div>;
}
@ -195,20 +195,20 @@ export default function DeviceInventory({
if (line.trim() === '```bash' || line.trim() === '```') {
return null;
}
// Inline formatting fallback
if (line.includes('**')) {
return (
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('**').map((tok, ti) => {
return ti % 2 === 1 ? <strong key={ti} className="text-fg">{tok}</strong> : tok;
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
})}
</p>
);
}
if (line.includes('`')) {
return (
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('`').map((tok, ti) => {
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
})}
@ -216,7 +216,7 @@ export default function DeviceInventory({
);
}
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-fg-muted my-0.5 font-sans">{line}</p>;
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-slate-300 my-0.5 font-sans">{line}</p>;
});
};
@ -224,40 +224,40 @@ export default function DeviceInventory({
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
{/* LEFT COLUMN: Device List & Controls */}
<div className="lg:col-span-7 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
<div className="lg:col-span-7 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
{/* Title */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-fg flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-success" />
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-emerald-400" />
Inventory
</h2>
<p className="text-xs text-fg-muted font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
<p className="text-xs text-slate-400 font-sans">Every switch, firewall and AP we own. the source of truth, until someone forgets to update it.</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
id="btn-add-device"
>
<Plus className="w-4 h-4 stroke-[3]" />
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
Add Device
</button>
</div>
</div>
{/* Filter Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-800 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-fg-faint">
<span className="absolute left-3 top-2.5 text-slate-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-success transition-colors placeholder:text-fg-faint"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-500 transition-colors placeholder:text-slate-500"
/>
</div>
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
@ -267,8 +267,8 @@ export default function DeviceInventory({
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === type
? 'bg-success-soft border border-success text-success'
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : type}
@ -280,7 +280,7 @@ export default function DeviceInventory({
{/* Device Listing Card Table */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
{filteredDevices.length === 0 ? (
<div className="text-center py-12 text-fg-faint text-xs font-sans">
<div className="text-center py-12 text-slate-500 text-xs font-sans">
grep came back empty. no boxes match that filter.
</div>
) : (
@ -292,29 +292,29 @@ export default function DeviceInventory({
onClick={() => setSelectedDeviceId(device.id)}
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
isSelected
? 'bg-card border-success'
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex items-start gap-3.5">
{/* Device Icon Circle */}
<div className={`p-2 rounded-lg border text-base ${
device.type === 'Firewall' ? 'bg-rose-soft border-rose-line text-rose' :
device.type === 'Access-Point' ? 'bg-warning-soft border-warning-line text-warning' :
device.type === 'Controller' ? 'bg-info-soft border-info-line text-info' :
'bg-success-soft border-success-line text-success'
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
'bg-teal-950/20 border-teal-900/60 text-teal-400'
}`}>
<Server className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-fg text-sm">{device.hostname}</span>
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-inner border border-line text-fg-muted">{device.type}</span>
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-slate-800 border border-slate-700 text-slate-400">{device.type}</span>
</div>
<div className="flex flex-col gap-0.5 mt-1 font-sans">
<span className="text-xs font-mono text-success">{device.ip}</span>
<span className="text-[10px] text-fg-muted flex items-center gap-1">
<MapPin className="w-3 h-3 text-fg-faint" />
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{device.location}
</span>
</div>
@ -324,7 +324,7 @@ export default function DeviceInventory({
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Status Badge only when CheckMK is enabled */}
{cmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
@ -334,13 +334,13 @@ export default function DeviceInventory({
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-line pl-3">
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{cmkHostUrl(device) && (
<a
href={cmkHostUrl(device)!}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-info transition-colors"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open host in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
@ -348,7 +348,7 @@ export default function DeviceInventory({
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-primary transition-colors"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit specifications"
>
<Edit2 className="w-3.5 h-3.5" />
@ -359,7 +359,7 @@ export default function DeviceInventory({
onDeleteDevice(device.id);
}
}}
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-rose transition-colors"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
title="Delete device"
>
<Trash className="w-3.5 h-3.5" />
@ -379,34 +379,34 @@ export default function DeviceInventory({
{selectedDevice ? (
<>
{/* Header Spec Block */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm">
<span className="text-[10px] font-mono uppercase bg-inner border border-line px-2.5 py-0.5 rounded text-warning font-semibold">
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<span className="text-[10px] font-mono uppercase bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded text-amber-400 font-semibold">
SPECS ID: {selectedDevice.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-fg mt-2 font-mono flex items-center justify-between">
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
<span>{selectedDevice.hostname}</span>
<span className="text-xs font-sans text-fg-muted font-normal">Active Link State</span>
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
</h3>
<p className="text-xs text-fg-muted font-mono mt-0.5 bg-surface p-2.5 rounded border border-line mt-2 leading-relaxed">
Hostname: <span className="text-fg">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-success font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-fg">{selectedDevice.location}</span><br />
Node Class: <span className="text-fg">{selectedDevice.type}</span>
<p className="text-xs text-slate-400 font-mono mt-0.5 bg-slate-950 p-2.5 rounded border border-slate-850 mt-2 leading-relaxed">
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
</p>
<div className="mt-4 font-sans">
<h4 className="text-xs font-semibold text-fg-muted">Description & Technical Notes:</h4>
<div className="mt-1 bg-inner rounded p-2.5 border border-line text-xs text-fg-muted leading-relaxed">
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
<div className="mt-1 bg-slate-900/50 rounded p-2.5 border border-slate-800/80 text-xs text-slate-300 leading-relaxed">
{selectedDevice.notes || 'No description notes registered.'}
</div>
</div>
{/* CheckMK Monitoring Panel only when CheckMK is enabled */}
{cmkEnabled && (
<div className="mt-4 pt-4 border-t border-line space-y-2.5">
{checkmkEnabled && (
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-fg-muted font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-info" />
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
CheckMK Monitoring
</span>
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
@ -421,14 +421,14 @@ export default function DeviceInventory({
href={cmkHostUrl(selectedDevice)!}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-inner border border-line text-fg hover:text-info hover:border-info rounded text-xs transition-colors font-mono"
className="flex items-center justify-center gap-1.5 px-3 py-1.5 bg-slate-900 border border-slate-700 text-slate-200 hover:text-cyan-400 hover:border-cyan-500 rounded text-xs transition-colors font-mono"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
)}
{selectedDevice.lastCheckedAt && (
<p className="text-[10px] text-fg-faint font-mono">
<p className="text-[10px] text-slate-500 font-mono">
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
</p>
)}
@ -437,33 +437,33 @@ export default function DeviceInventory({
</div>
{/* Emergency rescue guidelines sheet */}
<div className="bg-warning-soft border border-warning-line rounded-xl p-5 shadow-sm overflow-hidden relative">
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
<div className="flex items-center justify-between border-b border-warning-line pb-3 mb-4">
<div className="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-warning" />
<h3 className="font-bold text-sm text-fg font-sans">
<BookOpen className="w-5 h-5 text-amber-500" />
<h3 className="font-bold text-sm text-slate-100 font-sans">
Emergency Sheet & Disaster Recovery
</h3>
</div>
<span className="text-[9px] font-mono font-bold bg-warning-soft text-warning px-2 py-0.5 rounded border border-warning-line">
<span className="text-[9px] font-mono font-bold bg-amber-950 text-amber-300 px-2 py-0.5 rounded border border-amber-900/50">
RESCUE SHEET
</span>
</div>
{/* Markdown Content box */}
<div className="max-h-[350px] overflow-y-auto bg-surface p-4 rounded-lg border border-line leading-relaxed font-sans scrollbar-thin">
<div className="max-h-[350px] overflow-y-auto bg-slate-950/80 p-4 rounded-lg border border-slate-900 leading-relaxed font-sans scrollbar-thin">
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
</div>
<div className="mt-4 flex items-center gap-2 text-[10px] text-fg-muted bg-inner p-2.5 rounded border border-line">
<Info className="w-4 h-4 text-warning shrink-0" />
<div className="mt-4 flex items-center gap-2 text-[10px] text-slate-400 bg-slate-900/60 p-2.5 rounded border border-slate-800">
<Info className="w-4 h-4 text-amber-400 shrink-0" />
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
</div>
</div>
</>
) : (
<div className="bg-card border border-line rounded-xl p-10 text-center text-fg-faint text-xs font-sans">
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
Pick a box from the list to see its specs and break-glass playbook.
</div>
)}
@ -471,16 +471,16 @@ Pick a box from the list to see its specs and break-glass playbook.
{/* FORM MODAL: Add / Edit Equipment */}
{isEditing && (
<div className="fixed inset-0 bg-overlay backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-line-strong w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-inner px-5 py-4 border-b border-line flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
<Server className="w-4 h-4 text-success" />
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-400" />
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-fg-muted hover:text-fg"
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
@ -489,40 +489,40 @@ Pick a box from the list to see its specs and break-glass playbook.
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-fg-muted font-semibold mb-1">Hostname</label>
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">IP Address</label>
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
<input
type="text"
required
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Device Class</label>
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
<select
value={isCustomType ? '__custom__' : formData.type}
onChange={(e) => {
@ -534,7 +534,7 @@ Pick a box from the list to see its specs and break-glass playbook.
setFormData({ ...formData, type: e.target.value as DeviceType });
}
}}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
>
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
<option value="Firewall">Firewall / Security Appliance</option>
@ -549,46 +549,46 @@ Pick a box from the list to see its specs and break-glass playbook.
autoFocus
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
className="w-full mt-2 bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full mt-2 bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
)}
</div>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Technical Notes / Patching Mappings</label>
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
<textarea
rows={2}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea
rows={6}
value={formData.emergencySheet}
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono text-[11px] leading-tight"
className="w-full bg-slate-950 text-slate-250 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono text-[11px] leading-tight"
placeholder="### EMERGENCY DETAILS..."
/>
</div>
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs flex items-center gap-1.5"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5" />
<Save className="w-3.5 h-3.5 text-slate-950" />
Save
</button>
</div>

View File

@ -31,17 +31,17 @@ export default function Header({
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
return (
<header className="sticky top-0 z-50 bg-header border-b border-line text-fg backdrop-blur-md bg-opacity-95 px-6 py-4 flex items-center justify-between" id="app-header">
<header className="sticky top-0 z-50 bg-[#0F172A] border-b border-[#1E293B] text-slate-100 backdrop-blur-md bg-opacity-95 px-6 py-4 flex items-center justify-between" id="app-header">
{/* Brand Logo & Title */}
<div className="flex items-center gap-3">
<div className="p-1 bg-inner border border-line rounded-xl flex items-center justify-center text-fg shrink-0 hover:border-line-strong transition-all duration-300" id="brand-logo">
<GhostGridLogo className="w-10 h-10" />
<div className="p-1 bg-slate-950/90 border border-slate-800 rounded-xl shadow-[0_0_15px_rgba(6,182,212,0.15)] flex items-center justify-center text-white shrink-0 hover:border-cyan-550/50 transition-all duration-300" id="brand-logo">
<GhostGridLogo className="w-10 h-10 animate-pulse" />
</div>
<div>
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-fg">
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-white">
GhostGrid
</h1>
<p className="text-[9px] font-mono text-info tracking-widest uppercase">it's not dns. there's no way it's dns. it was dns.</p>
<p className="text-[9px] font-mono text-cyan-400 tracking-widest uppercase">it's not dns. there's no way it's dns. it was dns.</p>
<div className="flex items-center gap-1 mt-1">
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
@ -57,15 +57,15 @@ export default function Header({
{/* Theme Toggle */}
<button
onClick={onThemeToggle}
className="p-2.5 rounded-lg border border-line bg-inner text-fg-muted hover:bg-card hover:text-fg transition-all flex items-center justify-center cursor-pointer"
className="p-2.5 rounded-lg border border-slate-800 bg-slate-900 text-slate-300 hover:bg-slate-800/80 hover:text-white transition-all flex items-center justify-center cursor-pointer"
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
>
{theme === 'dark' ? <Sun className="w-5 h-5 text-warning" /> : <Moon className="w-5 h-4.5 text-primary" />}
{theme === 'dark' ? <Sun className="w-5 h-5 text-amber-400" /> : <Moon className="w-5 h-4.5 text-indigo-450" />}
</button>
{/* System Indicator */}
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-inner rounded-full border border-line text-xs font-mono text-fg-muted">
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-success' : 'bg-warning'}`} />
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-slate-800/60 rounded-full border border-slate-700/50 text-xs font-mono text-slate-300">
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-emerald-500' : 'bg-amber-400'}`} />
<span>System: {isProduction ? 'Production' : 'Development'}</span>
</div>
@ -74,36 +74,36 @@ export default function Header({
<button
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
className={`p-2.5 rounded-lg border transition-all relative ${
showMailInbox ? 'bg-card border-success text-success' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
showMailInbox ? 'bg-slate-800 border-emerald-500 text-emerald-400' : 'bg-slate-900 border-slate-800 text-slate-300 hover:bg-slate-800/80 hover:text-white'
}`}
title="E-Mail Inbox (Booking Confirmations)"
>
<Mail className="w-5 h-5" />
{userBookings.length > 0 && (
<span className="absolute -top-1 -right-1 bg-success text-white font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
<span className="absolute -top-1 -right-1 bg-emerald-500 text-slate-950 font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{userBookings.length}
</span>
)}
</button>
{showMailInbox && (
<div className="absolute right-0 mt-3 w-96 bg-card border border-line-strong rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
<div className="absolute right-0 mt-3 w-96 bg-[#1E293B] border border-slate-700 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-fg flex items-center gap-2">
<Mail className="w-4 h-4 text-success" />
<h3 className="font-semibold text-sm text-white flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-400" />
Mail Inbox: {currentUser.email}
</h3>
<p className="text-[10px] text-fg-muted font-sans">Automatic booking confirmations & dynamic alerts</p>
<p className="text-[10px] text-slate-400 font-sans">Automatic booking confirmations & dynamic alerts</p>
</div>
<button onClick={() => setShowMailInbox(false)} className="text-fg-muted hover:text-fg text-xs font-sans">Close</button>
<button onClick={() => setShowMailInbox(false)} className="text-slate-400 hover:text-white text-xs font-sans">Close</button>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-line p-2 space-y-1">
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-800 p-2 space-y-1">
{userBookings.length === 0 ? (
<div className="text-center py-8 text-fg-muted text-sm font-sans">
<Mail className="w-8 h-8 text-fg-faint mx-auto mb-2 opacity-50" />
<div className="text-center py-8 text-slate-400 text-sm font-sans">
<Mail className="w-8 h-8 text-slate-600 mx-auto mb-2 opacity-50" />
No emails in inbox.
<p className="text-xs text-fg-faint mt-1">Book a lab to receive automated SMTP confirmations.</p>
<p className="text-xs text-slate-500 mt-1">Book a lab to receive automated SMTP confirmations.</p>
</div>
) : (
userBookings.map((booking) => {
@ -111,22 +111,22 @@ export default function Header({
const formattedStart = new Date(booking.startDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
const formattedEnd = new Date(booking.endDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
return (
<div key={booking.id} className="p-3 bg-inner rounded-lg hover:bg-card transition-colors">
<div key={booking.id} className="p-3 bg-slate-900/40 rounded-lg hover:bg-slate-900/80 transition-colors">
<div className="flex justify-between items-start mb-1 gap-1">
<span className="text-[11px] font-mono text-success font-semibold bg-success-soft px-2 py-0.5 rounded border border-success-line">SMTP INCOMING</span>
<span className="text-[10px] font-mono text-fg-muted">Just now</span>
<span className="text-[11px] font-mono text-emerald-400 font-semibold bg-emerald-950/80 px-2 py-0.5 rounded border border-emerald-900/50">SMTP INCOMING</span>
<span className="text-[10px] font-mono text-slate-400">Just now</span>
</div>
<h4 className="text-xs font-semibold text-fg font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
<div className="mt-2 text-[11px] text-fg-muted leading-relaxed space-y-1.5 font-sans border-l-2 border-success pl-2">
<h4 className="text-xs font-semibold text-white font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
<div className="mt-2 text-[11px] text-slate-300 leading-relaxed space-y-1.5 font-sans border-l-2 border-emerald-500/50 pl-2">
<p>Hello <strong>{currentUser.name}</strong>,</p>
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
<div className="bg-surface p-1.5 rounded font-mono text-[9px] text-fg-muted border border-line">
<div className="bg-slate-850 p-1.5 rounded font-mono text-[9px] text-slate-300 border border-slate-700/50">
<strong>Lab Location:</strong> {lab?.location}<br />
<strong>Start Time:</strong> {formattedStart}<br />
<strong>End Time:</strong> {formattedEnd}<br />
<strong>Notes:</strong> {booking.notes || 'None'}
</div>
<p className="text-[10px] text-fg-faint italic">GhostGrid Automation Mailbot</p>
<p className="text-[10px] text-slate-500 italic">GhostGrid Automation Mailbot</p>
</div>
</div>
);
@ -142,39 +142,39 @@ export default function Header({
<button
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
className={`p-2.5 rounded-lg border transition-all relative ${
showBellDropdown ? 'bg-card border-warning text-warning' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
showBellDropdown ? 'bg-slate-800 border-amber-500 text-amber-400' : 'bg-slate-905 border-slate-800 text-slate-300 hover:bg-slate-800/80 hover:text-white'
}`}
title="Interface & System Alerts"
>
<Bell className="w-5 h-5" />
{notifications.length > 0 && (
<span className="absolute -top-1 -right-1 bg-warning text-white font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
<span className="absolute -top-1 -right-1 bg-amber-500 text-slate-950 font-sans text-[10px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
{notifications.length}
</span>
)}
</button>
{showBellDropdown && (
<div className="absolute right-0 mt-3 w-80 bg-card border border-line-strong rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
<div className="absolute right-0 mt-3 w-80 bg-[#1E293B] border border-slate-700 rounded-xl shadow-2xl overflow-hidden z-50 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-fg flex items-center gap-2 font-sans">
<Bell className="w-4 h-4 text-warning" />
<h3 className="font-semibold text-sm text-white flex items-center gap-2 font-sans">
<Bell className="w-4 h-4 text-amber-400" />
Notifications ({notifications.length})
</h3>
<p className="text-[10px] text-fg-muted font-sans">Booking lifecycles & countdowns</p>
<p className="text-[10px] text-slate-400 font-sans">Booking lifecycles & countdowns</p>
</div>
{notifications.length > 0 && (
<button onClick={onClearNotifications} className="text-warning hover:opacity-80 text-xs font-semibold font-sans">Clear All</button>
<button onClick={onClearNotifications} className="text-amber-400 hover:text-amber-300 text-xs font-semibold font-sans">Clear All</button>
)}
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-line p-2">
<div className="max-h-[300px] overflow-y-auto divide-y divide-slate-800 p-2">
{notifications.length === 0 ? (
<div className="text-center py-6 text-fg-muted text-xs font-sans">No active system alerts.</div>
<div className="text-center py-6 text-slate-400 text-xs font-sans">No active system alerts.</div>
) : (
notifications.map((notif, index) => (
<div key={index} className="p-2.5 text-xs text-fg flex gap-2 hover:bg-inner rounded transition-colors mb-1 font-sans">
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
<div key={index} className="p-2.5 text-xs text-slate-200 flex gap-2 hover:bg-slate-905/40 rounded transition-colors mb-1 font-sans">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<p>{notif}</p>
</div>
))
@ -185,15 +185,15 @@ export default function Header({
</div>
{/* User Info + Logout */}
<div className="flex items-center gap-2 pl-2 pr-1 py-1.5 bg-inner border border-line rounded-lg text-fg">
<div className="flex items-center gap-2 pl-2 pr-1 py-1.5 bg-slate-900 border border-slate-800 rounded-lg text-slate-200">
<div className="hidden sm:block">
<div className="text-xs font-semibold leading-3 text-fg max-w-[120px] truncate">{currentUser.name}</div>
<div className="text-[9px] text-info font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
<div className="text-xs font-semibold leading-3 text-white max-w-[120px] truncate">{currentUser.name}</div>
<div className="text-[9px] text-cyan-400 font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
</div>
<button
onClick={onLogout}
title="Sign out"
className="ml-1 p-1.5 rounded-md text-fg-muted hover:text-danger hover:bg-danger-soft transition-all"
className="ml-1 p-1.5 rounded-md text-slate-400 hover:text-red-400 hover:bg-red-950/40 transition-all"
>
<LogOut className="w-4 h-4" />
</button>

View File

@ -178,18 +178,18 @@ export default function LabTemplates({
onClick={() => setSelectedLab(lab)}
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
isSelected
? 'bg-card border-success'
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
? 'bg-slate-900 border-emerald-500'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex justify-between items-start">
<h3 className="font-bold text-sm text-fg">{lab.name}</h3>
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
{editable && (
<>
<button
onClick={() => handleOpenEdit(lab)}
className="text-fg-muted hover:text-primary p-0.5"
className="text-slate-400 hover:text-indigo-400 p-0.5"
title="Edit template configuration"
>
<Edit3 className="w-3.5 h-3.5" />
@ -200,7 +200,7 @@ export default function LabTemplates({
onDeleteLab(lab.id);
}
}}
className="text-fg-muted hover:text-rose p-0.5"
className="text-slate-400 hover:text-rose-400 p-0.5"
title="Delete template"
>
<Trash className="w-3.5 h-3.5" />
@ -210,37 +210,37 @@ export default function LabTemplates({
</div>
</div>
<p className="text-xs text-fg-muted mt-1 line-clamp-2 leading-relaxed">
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
{lab.description}
</p>
<div className="mt-3 pt-3 border-t border-line grid grid-cols-2 gap-1 text-[10px] text-fg-muted">
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
<div className="flex items-center gap-1">
<UserIcon className="w-3.5 h-3.5 text-fg-faint shrink-0" />
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.contactPerson}</span>
</div>
<div className="flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-fg-faint shrink-0" />
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
<span className="truncate">{lab.location}</span>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between">
<div className="flex items-center gap-1.5">
<span className="text-[10px] font-mono text-success bg-success-soft px-2 py-0.5 rounded border border-success-line">
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
{lab.deviceIds.length} connected devices
</span>
{lab.scope === 'personal' ? (
<span className="text-[10px] font-mono text-primary bg-primary-soft px-2 py-0.5 rounded border border-primary-line flex items-center gap-1">
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
<Lock className="w-2.5 h-2.5" /> Personal
</span>
) : (
<span className="text-[10px] font-mono text-fg-muted bg-inner px-2 py-0.5 rounded border border-line flex items-center gap-1">
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
<Globe className="w-2.5 h-2.5" /> Global
</span>
)}
</div>
<ChevronRight className={`w-4 h-4 text-fg-faint transition-transform ${isSelected ? 'translate-x-1 text-success' : ''}`} />
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
</div>
</div>
);
@ -250,21 +250,21 @@ export default function LabTemplates({
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
{/* LEFT COLUMN: Lab List */}
<div className="lg:col-span-4 bg-card border border-line rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
<div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-fg flex items-center gap-2">
<Layers className="w-5 h-5 text-success" />
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
Topology
</h2>
<p className="text-xs text-fg-muted">Predefined architectural scenarios & wiring profiles.</p>
<p className="text-xs text-slate-400">Predefined architectural scenarios & wiring profiles.</p>
</div>
<button
onClick={handleOpenAdd}
className="p-1.5 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg text-xs flex items-center gap-1.5 transition-colors"
className="p-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded-lg text-xs flex items-center gap-1.5 transition-colors"
title="Create new lab template"
>
<Plus className="w-4 h-4" />
<Plus className="w-4 h-4 text-slate-950" />
New
</button>
</div>
@ -273,24 +273,24 @@ export default function LabTemplates({
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{myPersonalLabs.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-primary px-1">My Topologies</p>
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
{myPersonalLabs.map(renderLabCard)}
</>
)}
{globalLabs.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-fg-faint px-1 mt-2">Global Topologies</p>
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
{globalLabs.map(renderLabCard)}
</>
)}
{othersPersonal.length > 0 && (
<>
<p className="text-[10px] font-mono uppercase tracking-widest text-fg-faint px-1 mt-2">Others' Personal</p>
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
{othersPersonal.map(renderLabCard)}
</>
)}
{labs.length === 0 && (
<p className="text-xs text-fg-faint text-center py-8">No topology templates yet.</p>
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
)}
</div>
</div>
@ -300,33 +300,33 @@ export default function LabTemplates({
{selectedLab ? (
<>
{/* Template Card Meta */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
<div>
<span className="text-[9px] font-mono uppercase tracking-widest text-fg-muted bg-inner border border-line px-2.5 py-0.5 rounded">
<span className="text-[9px] font-mono uppercase tracking-widest text-slate-400 bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded">
TEMPLATE ID: {selectedLab.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-fg mt-1.5">{selectedLab.name}</h3>
<h3 className="text-lg font-bold text-white mt-1.5">{selectedLab.name}</h3>
</div>
<div className="flex items-center gap-2.5">
<div className="bg-inner p-2 rounded-lg border border-line text-[10px] flex items-center gap-1">
<UserIcon className="w-3.5 h-3.5 text-fg-muted" />
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-fg-muted leading-none">Primary Contact</p>
<p className="text-fg font-semibold mt-0.5">{selectedLab.contactPerson}</p>
<p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
</div>
</div>
<div className="bg-inner p-2 rounded-lg border border-line text-[10px] flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-fg-muted" />
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-fg-muted leading-none">Testing Location</p>
<p className="text-fg font-semibold mt-0.5">{selectedLab.location}</p>
<p className="text-slate-400 leading-none">Testing Location</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.location}</p>
</div>
</div>
</div>
</div>
<p className="text-xs text-fg-muted leading-relaxed bg-inner p-3 rounded-lg border border-line">
<p className="text-xs text-slate-300 leading-relaxed bg-slate-900/30 p-3 rounded-lg border border-slate-800">
{selectedLab.description}
</p>
</div>
@ -339,28 +339,28 @@ export default function LabTemplates({
/>
{/* Sub-Devices components list */}
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
<h4 className="text-xs font-bold text-fg-muted uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{labDevices.map((device) => (
<div
<div
key={device.id}
onClick={() => onOpenDeviceDetails(device)}
className="p-3 bg-inner border border-line hover:border-line-strong hover:bg-card transition-colors rounded-lg cursor-pointer flex justify-between items-center"
className="p-3 bg-slate-900/40 border border-slate-800 hover:border-slate-700 hover:bg-slate-900 transition-colors rounded-lg cursor-pointer flex justify-between items-center"
>
<div className="flex items-center gap-2.5 font-sans">
<div className={`p-1.5 rounded text-primary bg-primary-soft border border-primary-line`}>
<div className={`p-1.5 rounded text-indigo-400 bg-indigo-950/30 border border-indigo-900/50`}>
<Server className="w-4 h-4" />
</div>
<div>
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
<p className="text-[9px] font-mono text-success mt-1">{device.ip}</p>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] font-mono text-emerald-400 mt-1">{device.ip}</p>
</div>
</div>
<div className="flex items-center gap-2 font-mono">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
<span className="text-[10px] text-fg-muted capitalize">{device.status}</span>
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<span className="text-[10px] text-slate-400 capitalize">{device.status}</span>
</div>
</div>
))}
@ -368,7 +368,7 @@ export default function LabTemplates({
</div>
</>
) : (
<div className="bg-card border border-line rounded-xl p-16 text-center text-fg-faint text-xs font-sans">
<div className="bg-slate-900 border border-slate-800 rounded-xl p-16 text-center text-slate-500 text-xs font-sans">
Select a lab scenario template from the left directory column to inspect active port topology connections.
</div>
)}
@ -376,17 +376,17 @@ export default function LabTemplates({
{/* FORM MODAL: Create or Edit Lab Template */}
{isEditing && (
<div className="fixed inset-0 bg-overlay backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-card border border-line-strong w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-inner px-5 py-4 border-b border-line flex items-center justify-between font-sans overflow-x-auto">
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
<Layers className="w-5 h-5 text-success" />
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans overflow-x-auto">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-fg-muted hover:text-fg"
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
@ -396,23 +396,23 @@ export default function LabTemplates({
{/* Name & Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-fg-muted font-semibold mb-1">Topology Name</label>
<label className="block text-slate-300 font-semibold mb-1">Topology Name</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
</div>
@ -420,38 +420,38 @@ export default function LabTemplates({
{/* Description & Contact person */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-fg-muted font-semibold mb-1">Caretaker / Owner</label>
<label className="block text-slate-300 font-semibold mb-1">Caretaker / Owner</label>
<input
type="text"
required
value={formData.contactPerson}
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Description</label>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<input
type="text"
required
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
/>
</div>
</div>
{/* Scope toggle */}
<div className="border-t border-line pt-3">
<label className="block text-fg-muted font-semibold mb-1.5">Visibility</label>
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setFormData({ ...formData, scope: 'global' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'global'
? 'bg-card border-line-strong text-fg'
: 'bg-inner border-line text-fg-muted hover:text-fg'
? 'bg-slate-800 border-slate-500 text-slate-200'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Globe className="w-3.5 h-3.5" /> Global — visible to all
@ -461,8 +461,8 @@ export default function LabTemplates({
onClick={() => setFormData({ ...formData, scope: 'personal' })}
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
formData.scope === 'personal'
? 'bg-primary-soft border-primary text-primary'
: 'bg-inner border-line text-fg-muted hover:text-fg'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Lock className="w-3.5 h-3.5" /> Personal — only you
@ -471,10 +471,10 @@ export default function LabTemplates({
</div>
{/* Hardware checklist */}
<div className="border-t border-line pt-3">
<label className="block text-fg-muted font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
<p className="text-[10px] text-fg-muted mb-2">Select the devices from the active hardware inventory associated with this testing group.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-inner p-3 rounded-lg border border-line">
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
<p className="text-[10px] text-slate-400 mb-2">Select the devices from the active hardware inventory associated with this testing group.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-slate-950/60 p-3 rounded-lg border border-slate-855">
{devices.map((dev) => {
const isChecked = formData.deviceIds.includes(dev.id);
return (
@ -483,16 +483,16 @@ export default function LabTemplates({
key={dev.id}
onClick={() => handleToggleDevice(dev.id)}
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
isChecked
? 'bg-success-soft border-success text-fg'
: 'bg-card border-line hover:border-line-strong text-fg-muted'
isChecked
? 'bg-emerald-900/20 border-emerald-500 text-slate-100'
: 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300'
}`}
>
<div className="truncate pr-1">
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
<p className="text-[9px] font-mono text-fg-muted mt-1">{dev.ip}</p>
<p className="text-[9px] font-mono text-slate-400 mt-1">{dev.ip}</p>
</div>
{isChecked && <Check className="w-3.5 h-3.5 text-success shrink-0" />}
{isChecked && <Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
</button>
);
})}
@ -500,18 +500,18 @@ export default function LabTemplates({
</div>
{/* Physical/Logical topology builder link creator */}
<div className="border-t border-line pt-3">
<label className="block text-fg-muted font-bold mb-1.5">2. Define Ports & Link Connections</label>
<p className="text-[10px] text-fg-muted mb-2">Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).</p>
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">2. Define Ports & Link Connections</label>
<p className="text-[10px] text-slate-400 mb-2">Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).</p>
{/* Connection Inputs */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2.5 p-2 bg-inner border border-line rounded-lg items-end mb-3">
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2.5 p-2 bg-slate-1000 border border-slate-800 rounded-lg items-end mb-3">
<div>
<label className="block text-[10px] text-fg-muted mb-1">Source Node</label>
<label className="block text-[10px] text-slate-400 mb-1">Source Node</label>
<select
value={linkFrom}
onChange={(e) => setLinkFrom(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
@ -521,11 +521,11 @@ export default function LabTemplates({
</select>
</div>
<div>
<label className="block text-[10px] text-fg-muted mb-1">Target Node</label>
<label className="block text-[10px] text-slate-400 mb-1">Target Node</label>
<select
value={linkTo}
onChange={(e) => setLinkTo(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
@ -535,10 +535,10 @@ export default function LabTemplates({
</select>
</div>
<div>
<label className="block text-[10px] text-fg-muted mb-1">Link Identifier Description (Label)</label>
<label className="block text-[10px] text-slate-400 mb-1">Link Identifier Description (Label)</label>
<input
type="text"
className="w-full bg-field text-fg border border-line-strong p-1 rounded font-mono text-[11px]"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
value={linkType}
onChange={(e) => setLinkType(e.target.value)}
/>
@ -561,8 +561,8 @@ export default function LabTemplates({
const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
const isEditingThis = editingLinkIdx === idx;
return (
<div key={idx} className="flex items-center gap-2 bg-inner px-3 py-1.5 rounded border border-line font-mono text-[10px] hover:border-line-strong">
<span className="text-fg-muted shrink-0"><strong>{fromDev}</strong> ────</span>
<div key={idx} className="flex items-center gap-2 bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700">
<span className="text-slate-300 shrink-0"><strong>{fromDev}</strong> ────</span>
{isEditingThis ? (
<input
autoFocus
@ -583,17 +583,17 @@ export default function LabTemplates({
}
if (e.key === 'Escape') setEditingLinkIdx(null);
}}
className="flex-1 min-w-0 bg-field text-fg border border-primary rounded px-1.5 py-0.5 focus:outline-none"
className="flex-1 min-w-0 bg-slate-800 text-slate-100 border border-indigo-500 rounded px-1.5 py-0.5 focus:outline-none"
/>
) : (
<span className="flex-1 min-w-0 text-primary truncate">{link.type}</span>
<span className="flex-1 min-w-0 text-indigo-300 truncate">{link.type}</span>
)}
<span className="text-fg-muted shrink-0"> <strong>{toDev}</strong></span>
<span className="text-slate-300 shrink-0"> <strong>{toDev}</strong></span>
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
<button
type="button"
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
className="text-fg-muted hover:text-primary transition-colors"
className="text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit label"
>
<Pencil className="w-3 h-3" />
@ -601,7 +601,7 @@ export default function LabTemplates({
<button
type="button"
onClick={() => handleRemoveLink(idx)}
className="text-rose hover:opacity-80 font-bold"
className="text-rose-500 hover:text-rose-400 font-bold"
>
<X className="w-3 h-3" />
</button>
@ -611,53 +611,53 @@ export default function LabTemplates({
})}
</div>
) : (
<p className="text-[10px] text-fg-faint italic">No interface connections formulated yet.</p>
<p className="text-[10px] text-slate-500 italic">No interface connections formulated yet.</p>
)}
</div>
{/* Ansible Semaphore Automation */}
{semaphoreEnabled && <div className="border-t border-line pt-3">
<label className="block text-fg-muted font-bold mb-1.5 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-orange" />
{semaphoreEnabled && <div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5 text-orange-400" />
3. Ansible Automation (optional)
</label>
<p className="text-[10px] text-fg-muted mb-2">Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.</p>
<p className="text-[10px] text-slate-400 mb-2">Semaphore task template IDs to trigger at booking start and end. Leave empty to skip automation for this lab.</p>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-[10px] text-fg-muted mb-1">Setup Template ID</label>
<label className="block text-[10px] text-slate-400 mb-1">Setup Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreSetupTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
/>
</div>
<div>
<label className="block text-[10px] text-fg-muted mb-1">Teardown Template ID</label>
<label className="block text-[10px] text-slate-400 mb-1">Teardown Template ID</label>
<input
type="text"
inputMode="numeric"
value={formData.semaphoreTeardownTemplateId}
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 font-mono text-xs focus:outline-none focus:border-orange-500/60"
/>
</div>
</div>
</div>}
{/* Form submit handlers */}
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs animate-none"
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs animate-none"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs animate-none"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
>
Save
</button>

View File

@ -20,12 +20,12 @@ interface LinkDashboardProps {
// Accent palette - keys are stored in the DB so they survive reloads.
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
emerald: { ring: 'hover:border-success', text: 'text-success', bg: 'bg-success-soft', dot: 'bg-success', bar: 'bg-success' },
cyan: { ring: 'hover:border-info', text: 'text-info', bg: 'bg-info-soft', dot: 'bg-info', bar: 'bg-info' },
indigo: { ring: 'hover:border-primary', text: 'text-primary', bg: 'bg-primary-soft', dot: 'bg-primary', bar: 'bg-primary' },
amber: { ring: 'hover:border-warning', text: 'text-warning', bg: 'bg-warning-soft', dot: 'bg-warning', bar: 'bg-warning' },
rose: { ring: 'hover:border-rose', text: 'text-rose', bg: 'bg-rose-soft', dot: 'bg-rose', bar: 'bg-rose' },
violet: { ring: 'hover:border-violet', text: 'text-violet', bg: 'bg-violet-soft', dot: 'bg-violet', bar: 'bg-violet' },
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
};
const ACCENT_KEYS = Object.keys(ACCENTS);
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
@ -138,23 +138,23 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
<div className="space-y-6 font-sans" id="link-dashboard-root">
{/* Header banner */}
<div className="bg-card border border-line rounded-2xl p-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 text-fg-faint pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
LINKS
</div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
<div className="space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
<LinkIcon className="w-6 h-6 text-success" />
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<LinkIcon className="w-6 h-6 text-emerald-400" />
Tooling & Quick Links
</h2>
<p className="text-xs text-fg-muted max-w-xl leading-relaxed">
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
The team's bookmark bar that doesn't live in one person's browser. CheckMK, Semaphore, Grafana, that one wiki page nobody can ever find again. Stored in SQLite, shared with everyone.
</p>
</div>
<button
onClick={openAdd}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
id="btn-add-link">
<Plus className="w-4 h-4" />
Add Link
@ -163,20 +163,20 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
</div>
{/* Toolbar: search + category filter */}
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-card border border-line rounded-xl">
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-[#1E293B] border border-slate-800 rounded-xl">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-fg-faint"><Search className="w-4 h-4" /></span>
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none focus:border-emerald-600"
/>
</div>
<div className="flex gap-1 flex-wrap shrink-0">
<button
onClick={() => setActiveCategory('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? 'bg-success-soft border border-success text-success' : 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'}`}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400' : 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'}`}
>
All
</button>
@ -184,7 +184,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
<button
key={cat}
onClick={() => setActiveCategory(cat)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === cat ? 'bg-success-soft border border-success text-success' : 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'}`}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === cat ? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400' : 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'}`}
>
{cat}
</button>
@ -194,30 +194,30 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
{/* Empty state */}
{links.length === 0 ? (
<div className="text-center py-16 bg-card border border-dashed border-line-strong rounded-2xl">
<Globe className="w-10 h-10 text-fg-faint mx-auto mb-3 opacity-60" />
<h3 className="text-sm font-bold text-fg">404: links not found</h3>
<p className="text-xs text-fg-muted mt-1 max-w-sm mx-auto">
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
Drop in the daily-driver tools - monitoring, automation, ticketing - so nobody has to ask "what's the URL for CheckMK again?" for the 40th time.
</p>
<button
onClick={openAdd}
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
>
<Plus className="w-4 h-4" /> Add your first link
</button>
</div>
) : filtered.length === 0 ? (
<p className="text-center py-16 text-fg-faint text-xs">No links match your search.</p>
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
) : (
<div className="space-y-8">
{grouped.map(([category, items]) => (
<section key={category}>
<div className="flex items-center gap-2 mb-3">
<FolderOpen className="w-4 h-4 text-fg-faint" />
<h3 className="text-xs font-bold uppercase tracking-wider text-fg-muted font-mono">{category}</h3>
<span className="text-[10px] text-fg-faint font-mono">({items.length})</span>
<div className="flex-1 h-px bg-line ml-2" />
<FolderOpen className="w-4 h-4 text-slate-500" />
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
<div className="flex-1 h-px bg-slate-850 ml-2" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
@ -226,12 +226,12 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
return (
<div
key={link.id}
className={`group relative bg-card border border-line rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
className={`group relative bg-[#1E293B] border border-slate-800 rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
>
<span className={`absolute top-0 left-0 bottom-0 w-1 rounded-l-xl ${a.bar} opacity-70`} />
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-line flex items-center justify-center shrink-0 overflow-hidden`}>
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-slate-800 flex items-center justify-center shrink-0 overflow-hidden`}>
<Globe className={`w-5 h-5 ${a.text}`} />
</div>
@ -240,7 +240,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-bold text-fg hover:underline flex items-center gap-1.5 truncate"
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
title={link.title}
>
<span className="truncate">{link.title}</span>
@ -261,13 +261,13 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
if (e.key === 'Escape') { setEditingDescId(null); }
}}
className="w-full mt-3 bg-field text-fg text-[11px] border border-success rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
className="w-full mt-3 bg-slate-950 text-slate-200 text-[11px] border border-emerald-600 rounded px-2 py-1 resize-none focus:outline-none leading-relaxed"
/>
) : (
<p
onClick={() => startDescEdit(link)}
title="Click to edit description"
className={`text-[11px] leading-relaxed mt-3 line-clamp-2 cursor-text ${link.description ? 'text-fg-muted hover:text-fg' : 'text-fg-faint italic hover:text-fg-muted'} transition-colors`}
className={`text-[11px] leading-relaxed mt-3 line-clamp-2 cursor-text ${link.description ? 'text-slate-400 hover:text-slate-200' : 'text-slate-600 italic hover:text-slate-400'} transition-colors`}
>
{link.description || 'Add a description…'}
</p>
@ -278,7 +278,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
<button
onClick={() => openEdit(link)}
title="Edit link"
className="p-1.5 rounded-md bg-card border border-line text-fg-muted hover:text-success hover:border-success transition-all hover:cursor-pointer"
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-emerald-400 hover:border-emerald-600 transition-all hover:cursor-pointer"
>
<Pencil className="w-3.5 h-3.5" />
</button>
@ -287,7 +287,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
}}
title="Delete link"
className="p-1.5 rounded-md bg-card border border-line text-fg-muted hover:text-rose hover:border-rose transition-all hover:cursor-pointer"
className="p-1.5 rounded-md bg-slate-900/90 border border-slate-800 text-slate-400 hover:text-rose-400 hover:border-rose-600 transition-all hover:cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@ -303,47 +303,47 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
{/* Add / Edit modal */}
{showForm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-overlay backdrop-blur-sm p-4 animate-in fade-in duration-150" onClick={closeForm}>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-in fade-in duration-150" onClick={closeForm}>
<div
className="w-full max-w-md bg-card border border-line-strong rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
className="w-full max-w-md bg-[#1E293B] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-inner px-5 py-3.5 border-b border-line flex items-center justify-between">
<h3 className="font-bold text-sm text-fg flex items-center gap-2">
<Star className="w-4 h-4 text-success" />
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
<h3 className="font-bold text-sm text-white flex items-center gap-2">
<Star className="w-4 h-4 text-emerald-400" />
{editingId ? 'Edit Link' : 'New Quick Link'}
</h3>
<button onClick={closeForm} className="text-fg-muted hover:text-fg"><X className="w-4 h-4" /></button>
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
<div>
<label className="block text-fg-muted font-semibold mb-1">Title *</label>
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
<input
required autoFocus
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">URL *</label>
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
<input
required
value={draft.url}
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Category</label>
<label className="block text-slate-300 font-semibold mb-1">Category</label>
<input
list="link-categories"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
<datalist id="link-categories">
{categories.map(c => <option key={c} value={c} />)}
@ -351,24 +351,24 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Description</label>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<textarea
rows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success resize-none"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
/>
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1.5">Accent</label>
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
<div className="flex gap-2">
{ACCENT_KEYS.map(key => (
<button
type="button"
key={key}
onClick={() => setDraft({ ...draft, color: key })}
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-card ring-fg scale-110' : 'opacity-70 hover:opacity-100'}`}
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-[#1E293B] ring-white scale-110' : 'opacity-70 hover:opacity-100'}`}
title={key}
/>
))}
@ -376,10 +376,10 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
</div>
<div className="flex gap-2 pt-1">
<button type="button" onClick={closeForm} className="flex-1 py-2 bg-inner border border-line text-fg-muted hover:text-fg rounded font-semibold transition-colors hover:cursor-pointer">
<button type="button" onClick={closeForm} className="flex-1 py-2 bg-slate-900 border border-slate-800 text-slate-300 hover:text-white rounded font-semibold transition-colors hover:cursor-pointer">
Cancel
</button>
<button type="submit" className="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded flex items-center justify-center gap-1.5 transition-colors hover:cursor-pointer">
<button type="submit" className="flex-1 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded flex items-center justify-center gap-1.5 transition-colors hover:cursor-pointer">
<Save className="w-3.5 h-3.5" />
{editingId ? 'Save Changes' : 'Add Link'}
</button>

View File

@ -66,14 +66,14 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
const getLogTypeBadge = (type: string) => {
switch (type) {
case 'maintenance':
return 'bg-warning-soft border border-warning-line text-warning';
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
case 'booking':
return 'bg-success-soft border border-success-line text-success';
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
case 'status':
return 'bg-info-soft border border-info-line text-info';
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
case 'system':
default:
return 'bg-inner border border-line text-fg-muted';
return 'bg-slate-900 border border-slate-800 text-slate-350';
}
};
@ -90,36 +90,36 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
{/* LEFT COLUMN: Chronological Log List */}
<div className="lg:col-span-8 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-fg flex items-center gap-2">
<History className="w-5 h-5 text-success" />
<h2 className="text-base font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-emerald-400" />
Audit Log & Maintenance Journal
</h2>
<p className="text-xs text-fg-muted">Append-only history of who touched what. git blame, but for the lab.</p>
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
</div>
<button
onClick={() => setShowAddLog(!showAddLog)}
className="px-3 py-1.5 bg-inner border border-line text-fg hover:text-success hover:border-success rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
className="px-3 py-1.5 bg-slate-900 border border-slate-800 text-slate-200 hover:text-emerald-400 hover:border-emerald-500 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
id="btn-toggle-add-log"
>
<Plus className="w-4 h-4 text-success" />
<Plus className="w-4 h-4 text-emerald-400" />
File Maintenance Report
</button>
</div>
{/* Search and Filters toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-850 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-fg-faint">
<span className="absolute left-3 top-2.5 text-slate-550">
<Search className="w-4 h-4" />
</span>
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
className="w-full bg-slate-950 text-slate-101 border border-slate-800 rounded-lg pl-9 pr-4 py-1.5 text-xs focus:outline-none"
/>
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
@ -135,8 +135,8 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
onClick={() => setTypeFilter(key)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === key
? 'bg-success-soft border border-success text-success'
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{label}
@ -148,7 +148,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
{/* Audit Log Sheet */}
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
{filteredLogs.length === 0 ? (
<p className="text-center py-16 text-fg-faint text-xs">
<p className="text-center py-16 text-slate-500 text-xs">
No audit records match the selected filtering rules.
</p>
) : (
@ -161,29 +161,29 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
});
return (
<div key={log.id} className="p-3 bg-inner border border-line rounded-xl hover:border-line-strong hover:bg-card transition-all flex items-start gap-3.5">
<div key={log.id} className="p-3 bg-slate-900/40 border border-slate-855 rounded-xl hover:border-slate-800 hover:bg-slate-900/70 transition-all flex items-start gap-3.5">
<div className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded font-sans scale-90 text-center block w-full ${getLogTypeBadge(log.type)}`}>
{getLogTypeLabel(log.type)}
</span>
<span className="text-[9px] font-mono text-fg-faint leading-none">
<span className="text-[9px] font-mono text-slate-500 leading-none">
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex-1">
<p className="text-xs text-fg leading-relaxed font-sans">{log.message}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-fg-faint pt-1.5 border-t border-line">
<p className="text-xs text-slate-200 leading-relaxed font-sans">{log.message}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-slate-500 pt-1.5 border-t border-slate-900/40">
<span>Calendar Time: {timestampFormatted}</span>
{user && (
<span className="flex items-center gap-1 text-fg-muted">
<UserIcon className="w-3 h-3 text-fg-faint" />
<span className="flex items-center gap-1 text-slate-400">
<UserIcon className="w-3 h-3 text-slate-500" />
Operator: {user.name}
</span>
)}
{dev && (
<span className="flex items-center gap-1 text-success font-semibold">
<Server className="w-3 h-3 text-fg-faint" />
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
<Server className="w-3 h-3 text-slate-500" />
Node: {dev.hostname}
</span>
)}
@ -199,15 +199,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
<div className="lg:col-span-4" id="logbook-forms-side">
{showAddLog ? (
<div className="bg-card border border-line rounded-xl p-5 shadow-sm space-y-4 animate-in fade-in slide-in-from-right-3 duration-200 font-sans">
<div className="flex items-center justify-between pb-2 border-b border-line">
<h3 className="font-bold text-sm text-fg flex items-center gap-1.5">
<Hammer className="w-4 h-4 text-warning" />
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm space-y-4 animate-in fade-in slide-in-from-right-3 duration-200 font-sans">
<div className="flex items-center justify-between pb-2 border-b border-slate-800">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
<Hammer className="w-4 h-4 text-amber-500" />
Journal Maintenance Work
</h3>
<button
onClick={() => setShowAddLog(false)}
className="text-fg-muted hover:text-fg"
className="text-slate-400 hover:text-white"
>
Cancel
</button>
@ -215,11 +215,11 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
<div>
<label className="block text-fg-muted font-semibold mb-1">Target Network Host (Optional)</label>
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
<select
value={targetDeviceId}
onChange={(e) => setTargetDeviceId(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none"
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
>
<option value="">-- Complete Lab Cluster / General Event --</option>
{devices.map((d) => (
@ -231,24 +231,24 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
</div>
<div>
<label className="block text-fg-muted font-semibold mb-1">Documented Actions / Findings</label>
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
<textarea
required
rows={4}
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none"
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
/>
</div>
<div className="bg-inner border border-line p-2.5 rounded text-[11px] text-fg-muted leading-normal flex gap-2">
<Info className="w-4 h-4 text-success shrink-0 mt-0.5" />
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
<Info className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<span>This writes straight to the shared ledger - everyone on the team sees it, so spell it like the next on-call has to read it at 3am.</span>
</div>
<button
type="submit"
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
>
<Save className="w-3.5 h-3.5" />
Publish to Shared Log Book
@ -256,15 +256,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
</form>
</div>
) : (
<div className="bg-card border border-line rounded-xl p-5 shadow-sm text-xs text-fg-muted font-sans leading-relaxed">
<h3 className="font-bold text-fg mb-2 text-sm flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-success shrink-0" />
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 shadow-sm text-xs text-slate-400 font-sans leading-relaxed">
<h3 className="font-bold text-white mb-2 text-sm flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
Shared Audit & Fault Logging
</h3>
<p>
Labs drift. If you hit an STP block, a bridging loop or that one port that mysteriously err-disables itself, write down how you dug out. future-you (and the next on-call) will thank you.
</p>
<div className="mt-4 p-3 bg-warning-soft border border-warning-line rounded-lg text-warning font-mono text-[10px]">
<div className="mt-4 p-3 bg-amber-950/20 border border-amber-900/40 rounded-lg text-amber-300 font-mono text-[10px]">
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
</div>
</div>

View File

@ -52,21 +52,21 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
};
return (
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<div className="text-center space-y-3">
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<div className="flex items-center justify-center gap-1.5 mt-2">
<span className="text-[9px] text-fg-faint font-sans">A product by</span>
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirITSystems
@ -76,22 +76,22 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
</div>
{/* Login Card */}
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-fg">Sign in</h2>
<p className="text-xs text-fg-muted mt-1">Enter your credentials to access the platform.</p>
<h2 className="text-lg font-semibold text-white">Sign in</h2>
<p className="text-xs text-slate-400 mt-1">Enter your credentials to access the platform.</p>
</div>
{error && (
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2.5 text-xs text-danger">
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="email">
<label className="block text-xs font-semibold text-slate-300" htmlFor="email">
Email address
</label>
<input
@ -101,12 +101,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="password">
<label className="block text-xs font-semibold text-slate-300" htmlFor="password">
Password
</label>
<div className="relative">
@ -117,12 +117,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 pr-10 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
@ -133,7 +133,7 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-line-strong disabled:text-fg-faint text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg"
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg shadow-cyan-900/30"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
@ -147,14 +147,14 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
{azureEnabled && (
<>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-line" />
<span className="text-[10px] font-mono text-fg-faint uppercase tracking-widest">or</span>
<div className="flex-1 h-px bg-line" />
<div className="flex-1 h-px bg-slate-800" />
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest">or</span>
<div className="flex-1 h-px bg-slate-800" />
</div>
<button
type="button"
onClick={() => { window.location.href = '/api/auth/azure'; }}
className="w-full flex items-center justify-center gap-3 bg-inner hover:bg-card border border-line hover:border-line-strong text-fg font-semibold text-sm py-2.5 rounded-lg transition-all"
className="w-full flex items-center justify-center gap-3 bg-slate-800 hover:bg-slate-700 border border-slate-700 hover:border-slate-600 text-white font-semibold text-sm py-2.5 rounded-lg transition-all"
>
{/* Microsoft M logo */}
<svg width="18" height="18" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
@ -168,11 +168,11 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
</>
)}
<p className="text-center text-xs text-fg-muted">
<p className="text-center text-xs text-slate-400">
No account yet?{' '}
<button
onClick={onNavigateToRegister}
className="text-info hover:opacity-80 font-semibold transition-colors"
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Create one
</button>

View File

@ -56,21 +56,21 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
};
return (
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<div className="text-center space-y-3">
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<div className="flex items-center justify-center gap-1.5 mt-2">
<span className="text-[9px] text-fg-faint font-sans">A product by</span>
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirIT Systems
@ -80,22 +80,22 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
</div>
{/* Register Card */}
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-fg">Create account</h2>
<p className="text-xs text-fg-muted mt-1">Register to gain access to the platform.</p>
<h2 className="text-lg font-semibold text-white">Create account</h2>
<p className="text-xs text-slate-400 mt-1">Register to gain access to the platform.</p>
</div>
{error && (
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2.5 text-xs text-danger">
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-name">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-name">
Full name
</label>
<input
@ -105,12 +105,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
required
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-email">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-email">
Email address
</label>
<input
@ -120,12 +120,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
required
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-password">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-password">
Password
</label>
<div className="relative">
@ -136,19 +136,19 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
required
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 pr-10 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 pr-10 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{password.length > 0 && (
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-success' : 'text-warning'}`}>
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-emerald-400' : 'text-amber-400'}`}>
<CheckCircle2 className="w-3 h-3" />
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
</div>
@ -156,7 +156,7 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-confirm">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-confirm">
Confirm password
</label>
<input
@ -166,10 +166,10 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
required
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
className={`w-full bg-field border rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 transition-all ${
className={`w-full bg-slate-900 border rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 transition-all ${
confirmPassword.length > 0 && confirmPassword !== password
? 'border-danger focus:ring-danger/50'
: 'border-line-strong focus:ring-info/50 focus:border-info'
? 'border-red-700 focus:ring-red-500/50'
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
}`}
/>
</div>
@ -177,7 +177,7 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
<button
type="submit"
disabled={loading}
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-line-strong disabled:text-fg-faint text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg"
className="w-full flex items-center justify-center gap-2 bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white font-semibold text-sm py-2.5 rounded-lg transition-all shadow-lg shadow-cyan-900/30"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
@ -188,11 +188,11 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
</button>
</form>
<p className="text-center text-xs text-fg-muted">
<p className="text-center text-xs text-slate-400">
Already have an account?{' '}
<button
onClick={onNavigateToLogin}
className="text-info hover:opacity-80 font-semibold transition-colors"
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Sign in
</button>

View File

@ -50,17 +50,17 @@ interface SettingsProps {
}
function Label({ children }: { children: React.ReactNode }) {
return <label className="block text-[11px] font-semibold text-fg-muted uppercase tracking-wide mb-1.5">{children}</label>;
return <label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">{children}</label>;
}
function Hint({ children }: { children: React.ReactNode }) {
return <p className="mt-1 text-[10px] text-fg-faint font-mono leading-relaxed">{children}</p>;
return <p className="mt-1 text-[10px] text-slate-500 font-mono leading-relaxed">{children}</p>;
}
function ConfiguredBadge() {
return (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-success-soft border border-success-line text-success">
<span className="w-1 h-1 rounded-full bg-success inline-block" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 inline-block" />
CONFIGURED
</span>
);
@ -94,7 +94,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
return (
<div className="relative">
{icon && (
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-faint pointer-events-none">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
{icon}
</span>
)}
@ -103,7 +103,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={`w-full bg-field border border-line-strong rounded-lg py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all ${icon ? 'pl-9 pr-3' : 'px-3'} ${monospace ? 'font-mono text-xs' : ''}`}
className={`w-full bg-slate-900 border border-slate-700 rounded-lg py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all ${icon ? 'pl-9 pr-3' : 'px-3'} ${monospace ? 'font-mono text-xs' : ''}`}
/>
</div>
);
@ -117,19 +117,19 @@ function SecretInput({ value, onChange, show, onToggleShow }: {
}) {
return (
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-faint pointer-events-none">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
<KeyRound className="w-3.5 h-3.5" />
</span>
<input
type={show ? 'text' : 'password'}
value={value}
onChange={e => onChange(e.target.value)}
className="w-full bg-field border border-line-strong rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg pl-9 pr-10 py-2.5 text-xs font-mono text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
<button
type="button"
onClick={onToggleShow}
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-faint hover:text-fg-muted transition-colors"
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
tabIndex={-1}
>
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
@ -143,7 +143,7 @@ function SectionCard({ accentColor, children }: {
children: React.ReactNode;
}) {
return (
<div className="bg-card border border-line rounded-2xl overflow-hidden">
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden">
<div className={`h-0.5 w-full ${accentColor}`} />
<div className="p-6 space-y-5">
{children}
@ -171,13 +171,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
const [showAzureSecret, setShowAzureSecret] = useState(false);
const [cmkEnabled, setCmkEnabled] = useState(false);
const [cmkApiUrl, setCheckmkApiUrl] = useState('');
const [cmkApiUser, setCheckmkApiUser] = useState('');
const [cmkApiSecret, setCheckmkApiSecret] = useState('');
const [cmkSecretSet, setCheckmkSecretSet] = useState(false);
const [cmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
const [showCmkSecret, setShowCheckmkSecret] = useState(false);
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
const [checkmkApiUser, setCheckmkApiUser] = useState('');
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
const [syncing, setSyncing] = useState(false);
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
@ -251,7 +251,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
setAzureClientSecret('');
setAzureRedirectUri(data.azure_redirect_uri || '');
setAzureAllowedGroup(data.azure_allowed_group || '');
setCmkEnabled(data.checkmk_enabled === 'true');
setCheckmkEnabled(data.checkmk_enabled === 'true');
setCheckmkApiUrl(data.checkmk_api_url || '');
setCheckmkApiUser(data.checkmk_api_user || '');
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
@ -281,10 +281,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
azure_tenant_id: azureTenantId,
azure_redirect_uri: azureRedirectUri,
azure_allowed_group: azureAllowedGroup,
checkmk_enabled: cmkEnabled ? 'true' : 'false',
checkmk_api_url: cmkApiUrl,
checkmk_api_user: cmkApiUser,
checkmk_sync_interval_ms: cmkSyncInterval,
checkmk_enabled: checkmkEnabled ? 'true' : 'false',
checkmk_api_url: checkmkApiUrl,
checkmk_api_user: checkmkApiUser,
checkmk_sync_interval_ms: checkmkSyncInterval,
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
semaphore_api_url: semaphoreApiUrl,
semaphore_project_id: semaphoreProjectId,
@ -292,7 +292,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
caddy_admin_url: caddyAdminUrl,
};
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
if (cmkApiSecret) payload.checkmk_api_secret = cmkApiSecret;
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
try {
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
@ -520,7 +520,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<span className="w-5 h-5 border-2 border-line-strong border-t-info rounded-full animate-spin" />
<span className="w-5 h-5 border-2 border-slate-700 border-t-cyan-400 rounded-full animate-spin" />
</div>
);
}
@ -531,19 +531,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* Page header */}
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-1.5 text-[10px] font-mono text-fg-faint mb-3">
<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-fg-muted">SETTINGS</span>
<span className="text-slate-400">SETTINGS</span>
</div>
<h1 className="text-xl font-bold text-fg tracking-tight">Settings</h1>
<p className="text-xs text-fg-faint mt-0.5">Configure integrations and authentication providers.</p>
<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>
<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-line-strong disabled:text-fg-faint disabled:border disabled:border-line text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg"
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" />
@ -556,20 +556,20 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* Feedback banners */}
{error && (
<div className="flex items-start gap-2.5 bg-danger-soft border border-danger-line rounded-xl px-4 py-3 text-xs text-danger">
<AlertCircle className="w-4 h-4 shrink-0 text-danger mt-0.5" />
<div className="flex items-start gap-2.5 bg-red-950/40 border border-red-900/50 rounded-xl px-4 py-3 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400 mt-0.5" />
<span>{error}</span>
</div>
)}
{successMsg && (
<div className="flex items-center gap-2.5 bg-success-soft border border-success-line rounded-xl px-4 py-3 text-xs text-success">
<CheckCircle className="w-4 h-4 shrink-0 text-success" />
<div className="flex items-center gap-2.5 bg-emerald-950/40 border border-emerald-900/50 rounded-xl px-4 py-3 text-xs text-emerald-300">
<CheckCircle className="w-4 h-4 shrink-0 text-emerald-400" />
{successMsg}
</div>
)}
{/* 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-inner border border-line rounded-lg w-fit">
<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" /> },
@ -580,8 +580,8 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
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-info-soft border border-info-line text-info'
: 'border border-transparent text-fg-muted hover:text-fg hover:bg-inner'
? '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}
@ -598,37 +598,37 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-soft border border-blue-line rounded-xl">
<Shield className="w-4 h-4 text-blue" />
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
<Shield className="w-4 h-4 text-blue-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-fg">Microsoft Entra ID</h2>
<h2 className="text-sm font-semibold text-white">Microsoft Entra ID</h2>
{azureEnabled && azureSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-blue-soft border border-blue-line text-blue">
<span className="w-1 h-1 rounded-full bg-blue animate-pulse inline-block" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-blue-950/60 border border-blue-900/50 text-blue-400">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-fg-faint mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue' : 'text-fg-faint'}`}>
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
{azureEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setAzureEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${azureEnabled ? 'bg-blue-600' : 'bg-line-strong border border-line'}`}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${azureEnabled ? 'bg-blue-600 shadow-[0_0_10px_rgba(37,99,235,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${azureEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-line" />
<div className="h-px bg-slate-800/60" />
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="Tenant ID" hint="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
@ -675,19 +675,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* Redirect URI read-only */}
{effectiveRedirectUri && azureEnabled && (
<div className="flex items-start gap-3 bg-inner border border-line rounded-xl px-4 py-3">
<div className="flex items-start gap-3 bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-3">
<div className="flex-1 min-w-0">
<p className="text-[10px] font-semibold text-fg-muted uppercase tracking-wide mb-1">Required Redirect URI</p>
<p className="text-[11px] font-mono text-fg break-all">{effectiveRedirectUri}</p>
<p className="text-[10px] text-fg-faint mt-1">Register this in Azure Portal &gt; App registrations &gt; Authentication &gt; Redirect URIs</p>
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
<p className="text-[11px] font-mono text-slate-200 break-all">{effectiveRedirectUri}</p>
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal &gt; App registrations &gt; Authentication &gt; Redirect URIs</p>
</div>
<button
type="button"
onClick={copyRedirectUri}
className="shrink-0 p-1.5 rounded-lg text-fg-faint hover:text-fg hover:bg-inner transition-all"
className="shrink-0 p-1.5 rounded-lg text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-all"
title="Copy to clipboard"
>
{copied ? <CheckCircle className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
{copied ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
</button>
</div>
)}
@ -700,42 +700,42 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-success-soft border border-success-line rounded-xl">
<Activity className="w-4 h-4 text-success" />
<div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
<Activity className="w-4 h-4 text-emerald-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-fg">CheckMK</h2>
{cmkEnabled && cmkApiUrl && cmkSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-success-soft border border-success-line text-success">
<span className="w-1 h-1 rounded-full bg-success animate-pulse inline-block" />
<h2 className="text-sm font-semibold text-white">CheckMK</h2>
{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-emerald-950/60 border border-emerald-900/50 text-emerald-400">
<span className="w-1 h-1 rounded-full bg-emerald-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-fg-faint mt-0.5">Device status sync via CheckMK REST API</p>
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${cmkEnabled ? 'text-success' : 'text-fg-faint'}`}>
{cmkEnabled ? 'ENABLED' : 'DISABLED'}
<span className={`text-[10px] font-semibold font-mono ${checkmkEnabled ? 'text-emerald-400' : 'text-slate-600'}`}>
{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setCmkEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${cmkEnabled ? 'bg-emerald-600' : 'bg-line-strong border border-line'}`}
onClick={() => setCheckmkEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${checkmkEnabled ? 'bg-emerald-600 shadow-[0_0_10px_rgba(5,150,105,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${cmkEnabled ? 'left-5' : 'left-0.5'}`} />
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${checkmkEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-line" />
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!cmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<div className={`space-y-5 transition-opacity duration-200 ${!checkmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
<Input
value={cmkApiUrl}
value={checkmkApiUrl}
onChange={setCheckmkApiUrl}
monospace
icon={<Globe className="w-3.5 h-3.5" />}
@ -744,7 +744,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
<Input
value={cmkApiUser}
value={checkmkApiUser}
onChange={setCheckmkApiUser}
monospace
icon={<KeyRound className="w-3.5 h-3.5" />}
@ -753,30 +753,30 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<FieldRow
label="Automation Secret"
hint="Setup > Users > Automation user > Automation secret"
badge={cmkSecretSet ? <ConfiguredBadge /> : undefined}
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
>
<SecretInput
value={cmkApiSecret}
value={checkmkApiSecret}
onChange={setCheckmkApiSecret}
show={showCmkSecret}
show={showCheckmkSecret}
onToggleShow={() => setShowCheckmkSecret(v => !v)}
/>
</FieldRow>
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
<Input
value={cmkSyncInterval}
value={checkmkSyncInterval}
onChange={setCheckmkSyncInterval}
monospace
icon={<Clock className="w-3.5 h-3.5" />}
/>
</FieldRow>
</div>
{cmkApiUrl && cmkSecretSet && (
{checkmkApiUrl && checkmkSecretSet && (
<button
type="button"
onClick={runSync}
disabled={syncing}
className="flex items-center gap-2 bg-success-soft hover:opacity-80 border border-success-line text-success text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-2 bg-emerald-950/60 hover:bg-emerald-900/40 border border-emerald-900/50 text-emerald-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
{syncing ? 'Syncing…' : 'Run sync now'}
@ -789,37 +789,37 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SectionCard accentColor="bg-gradient-to-r from-orange-600 to-amber-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-soft border border-orange-line rounded-xl">
<Terminal className="w-4 h-4 text-orange" />
<div className="p-2 bg-orange-950/60 border border-orange-900/40 rounded-xl">
<Terminal className="w-4 h-4 text-orange-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-fg">Ansible Semaphore</h2>
<h2 className="text-sm font-semibold text-white">Ansible Semaphore</h2>
{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-orange-soft border border-orange-line text-orange">
<span className="w-1 h-1 rounded-full bg-orange animate-pulse inline-block" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-orange-950/60 border border-orange-900/50 text-orange-400">
<span className="w-1 h-1 rounded-full bg-orange-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-fg-faint mt-0.5">Trigger playbooks automatically at booking start and end</p>
<p className="text-[11px] text-slate-500 mt-0.5">Trigger playbooks automatically at booking start and end</p>
</div>
</div>
<div className="flex items-center gap-2.5">
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange' : 'text-fg-faint'}`}>
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange-400' : 'text-slate-600'}`}>
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setSemaphoreEnabled(v => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${semaphoreEnabled ? 'bg-orange-600' : 'bg-line-strong border border-line'}`}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${semaphoreEnabled ? 'bg-orange-600 shadow-[0_0_10px_rgba(234,88,12,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${semaphoreEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</div>
</div>
<div className="h-px bg-line" />
<div className="h-px bg-slate-800/60" />
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
<FieldRow label="API URL">
@ -858,13 +858,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
type="button"
onClick={testSemaphoreConnection}
disabled={semaphoreTesting}
className="flex items-center gap-2 bg-orange-soft hover:opacity-80 border border-orange-line text-orange text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
className="flex items-center gap-2 bg-orange-950/60 hover:bg-orange-900/40 border border-orange-900/50 text-orange-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
{semaphoreTesting ? 'Testing…' : 'Test connection'}
</button>
{semaphoreTestResult && (
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-danger' : 'text-success'}`}>
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
{semaphoreTestResult}
</p>
)}
@ -886,46 +886,46 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 bg-sky-soft border border-sky-line rounded-xl">
<Network className="w-4 h-4 text-sky" />
<div className="p-2 bg-sky-950/60 border border-sky-900/40 rounded-xl">
<Network className="w-4 h-4 text-sky-400" />
</div>
<div>
<div className="flex items-center gap-2">
<h2 className="text-sm font-semibold text-fg">Caddy Reverse Proxy</h2>
<h2 className="text-sm font-semibold text-white">Caddy Reverse Proxy</h2>
{caddyEnabled && caddyStatus === 'available' && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-sky-soft border border-sky-line text-sky">
<span className="w-1 h-1 rounded-full bg-sky animate-pulse inline-block" />
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-semibold bg-sky-950/60 border border-sky-900/50 text-sky-400">
<span className="w-1 h-1 rounded-full bg-sky-400 animate-pulse inline-block" />
ACTIVE
</span>
)}
</div>
<p className="text-[11px] text-fg-faint mt-0.5">Manage reverse proxy routes for internal services</p>
<p className="text-[11px] text-slate-500 mt-0.5">Manage reverse proxy routes for internal services</p>
</div>
</div>
<div className="flex items-center gap-2.5">
{caddyManaged ? (
<>
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky' : 'text-fg-faint'}`}>
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
</span>
<button
type="button"
onClick={() => setCaddyEnabled((v: boolean) => !v)}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600' : 'bg-line-strong border border-line'}`}
className={`relative w-10 h-5 rounded-full transition-all duration-200 focus:outline-none ${caddyEnabled ? 'bg-sky-600 shadow-[0_0_10px_rgba(2,132,199,0.4)]' : 'bg-slate-800 border border-slate-700'}`}
>
<span className={`absolute top-0.5 w-4 h-4 bg-white rounded-full shadow transition-all duration-200 ${caddyEnabled ? 'left-5' : 'left-0.5'}`} />
</button>
</>
) : (
<span className="text-[10px] font-semibold font-mono text-fg-faint">MANAGED BY PRODUCTION</span>
<span className="text-[10px] font-semibold font-mono text-slate-500">MANAGED BY PRODUCTION</span>
)}
</div>
</div>
<div className="h-px bg-line" />
<div className="h-px bg-slate-800/60" />
{!caddyManaged && (
<p className="text-[11px] font-mono text-fg-faint leading-relaxed">
<p className="text-[11px] font-mono text-slate-500 leading-relaxed">
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
</p>
)}
@ -943,20 +943,20 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
{caddyStatus === 'unavailable' && (
<p className="text-[11px] font-mono text-warning mb-2">
<p className="text-[11px] font-mono text-amber-400 mb-2">
Caddy Admin API not reachable - routes will be applied when Caddy starts.
</p>
)}
{caddyRoutes.length === 0 && (
<p className="text-[11px] font-mono text-fg-faint mb-2">
<p className="text-[11px] font-mono text-slate-500 mb-2">
No proxy routes configured yet.
</p>
)}
{/* Custom routes */}
{caddyRoutes.map((r: CaddyRoute) => (
<div key={r.id} className="bg-inner border border-line 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">
{editingRouteId === r.id ? (
<div className="space-y-2">
<div className="flex items-end gap-2">
@ -969,25 +969,25 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input value={editUpstream} onChange={setEditUpstream} monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">TLS</span>
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
<button type="button" onClick={() => setEditTls(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editTls ? 'bg-sky-600' : 'bg-line-strong border border-line'}`}>
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'}`}>
<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-fg-muted uppercase tracking-wide">GZ</span>
<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-line-strong border border-line'}`}>
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-soft hover:opacity-80 border border-sky-line text-sky text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
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-fg-faint hover:text-fg-muted hover:bg-inner transition-all shrink-0">
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>
@ -996,20 +996,20 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
) : (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
<span className="text-[11px] font-mono text-fg truncate">{r.hostname}</span>
<span className="text-fg-faint text-[11px]">&gt;</span>
<span className="text-[11px] font-mono text-fg-muted truncate">{r.upstream}</span>
{r.tls ? <span className="text-[9px] font-semibold text-sky font-mono">TLS</span> : null}
{r.compress ? <span className="text-[9px] font-semibold text-fg-faint font-mono">GZ</span> : null}
{r.redirect ? <span className="text-[9px] font-mono text-fg-faint truncate">&#8627; {r.redirect}</span> : null}
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
<span className="text-slate-600 text-[11px]">&gt;</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}
{r.redirect ? <span className="text-[9px] font-mono text-slate-500 truncate">&#8627; {r.redirect}</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-fg-faint hover:text-sky hover:bg-sky-soft transition-all" title="Edit route">
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-fg-faint hover:text-danger hover:bg-danger-soft transition-all" title="Remove route">
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>
@ -1030,21 +1030,21 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
<Input value={newUpstream} onChange={setNewUpstream} monospace />
</div>
<div className="flex flex-col items-center gap-1 pb-0.5">
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">TLS</span>
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
<button
type="button"
onClick={() => setNewTls(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newTls ? 'bg-sky-600' : 'bg-line-strong border border-line'}`}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newTls ? '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 ${newTls ? '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-fg-muted uppercase tracking-wide">GZ</span>
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
<button
type="button"
onClick={() => setNewCompress(v => !v)}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newCompress ? 'bg-sky-600' : 'bg-line-strong border border-line'}`}
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${newCompress ? '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 ${newCompress ? 'left-4' : 'left-0.5'}`} />
</button>
@ -1053,7 +1053,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
type="button"
onClick={handleAddRoute}
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
className="flex items-center gap-1.5 bg-sky-soft hover:opacity-80 border border-sky-line text-sky text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
className="flex items-center gap-1.5 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
>
<Plus className="w-3.5 h-3.5" />
{addingRoute ? 'Adding…' : 'Add'}
@ -1073,25 +1073,25 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* 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-soft border border-violet-line rounded-xl">
<HardDrive className="w-4 h-4 text-violet" />
<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-fg">Database</h2>
<p className="text-[11px] text-fg-faint mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
<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-fg font-mono leading-none">
<p className="text-xl font-bold text-white font-mono leading-none">
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
</p>
<p className="text-[10px] text-fg-faint font-mono mt-1">
<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-line" />
<div className="h-px bg-slate-800/60" />
{/* Proportional usage bar + table stats */}
{dbInfo ? (() => {
@ -1104,9 +1104,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
};
return (
<div className="space-y-3">
<div className="flex h-1.5 rounded-full overflow-hidden bg-line-strong gap-px">
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
{total === 0
? <div className="flex-1 bg-line-strong" />
? <div className="flex-1 bg-slate-700" />
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
<div
key={t}
@ -1119,10 +1119,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
<div className="grid grid-cols-4 gap-1.5">
{tableEntries.map(([t, n]) => (
<div key={t} className="bg-inner border border-line rounded-lg px-2 py-1.5">
<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-fg-faint uppercase tracking-wide truncate">{t}</p>
<p className="text-sm font-bold text-fg font-mono">{n}</p>
<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>
@ -1130,11 +1130,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
);
})() : (
<div className="h-16 flex items-center justify-center">
<span className="w-4 h-4 border-2 border-line-strong border-t-violet rounded-full animate-spin" />
<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-line" />
<div className="h-px bg-slate-800/60" />
{/* Actions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@ -1146,7 +1146,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
type="button"
onClick={handleBackup}
disabled={backingUp}
className="w-full flex items-center justify-center gap-2 bg-violet-soft hover:opacity-80 border border-violet-line text-violet text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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'}
@ -1157,13 +1157,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
{/* Import */}
<div className="space-y-2">
<Label>Import</Label>
<div className="flex items-start gap-2 bg-warning-soft border border-warning-line rounded-xl px-3 py-2">
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0 mt-0.5" />
<p className="text-[11px] text-warning leading-relaxed">
<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-inner border border-line-strong hover:border-line-strong rounded-lg px-3 py-2 text-xs text-fg-muted hover:text-fg transition-all">
<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
@ -1186,16 +1186,16 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
onChange={e => setImportConfirmed(e.target.checked)}
className="w-3.5 h-3.5 rounded accent-violet-500"
/>
<span className="text-[11px] text-fg-muted">I confirm this will overwrite all existing data</span>
<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-danger-soft hover:opacity-80 border border-danger-line text-danger text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
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-danger/30 border-t-danger rounded-full animate-spin" />
? <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'}
@ -1203,7 +1203,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
</div>
)}
{importResult && (
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-success' : 'text-danger'}`}>
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
{importResult.msg}
</p>
)}

View File

@ -100,60 +100,60 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
const getDeviceIcon = (type: string) => {
switch (type) {
case 'Firewall':
return <Shield className="w-5 h-5 text-rose" />;
return <Shield className="w-5 h-5 text-rose-400" />;
case 'Access-Point':
return <Wifi className="w-5 h-5 text-warning" />;
return <Wifi className="w-5 h-5 text-amber-400" />;
case 'Controller':
return <Cpu className="w-5 h-5 text-info" />;
return <Cpu className="w-5 h-5 text-cyan-400" />;
case 'Switch':
default:
return <Server className="w-5 h-5 text-success" />;
return <Server className="w-5 h-5 text-teal-400" />;
}
};
const getDeviceColorClass = (type: string) => {
switch (type) {
case 'Firewall':
return 'border-rose-line bg-rose-soft text-rose';
return 'border-rose-500/60 bg-rose-950/20 text-rose-200';
case 'Access-Point':
return 'border-warning-line bg-warning-soft text-warning';
return 'border-amber-500/60 bg-amber-950/20 text-amber-200';
case 'Controller':
return 'border-info-line bg-info-soft text-info';
return 'border-cyan-500/60 bg-cyan-950/20 text-cyan-200';
case 'Switch':
default:
return 'border-success-line bg-success-soft text-success';
return 'border-teal-500/60 bg-teal-950/20 text-teal-200';
}
};
return (
<div className="bg-card border border-line rounded-xl p-4 shadow-inner" id="topology-panel">
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-inner" id="topology-panel">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-fg flex items-center gap-2 font-sans">
<Activity className="w-4 h-4 text-success" />
<h3 className="text-sm font-semibold text-slate-100 flex items-center gap-2 font-sans">
<Activity className="w-4 h-4 text-emerald-400 animate-pulse" />
Interactive Topology Diagram (Physical & Logical Links)
</h3>
<p className="text-[11px] text-fg-muted font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
<p className="text-[11px] text-slate-400 font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
</div>
<div className="flex gap-2 text-[10px] font-mono">
<span className="flex items-center gap-1 text-success bg-success-soft px-2 py-0.5 rounded border border-success-line">
<span className="w-1.5 h-1.5 rounded-full bg-success"></span> Switch
<span className="flex items-center gap-1 text-teal-400 bg-teal-950/40 px-2 py-0.5 rounded border border-teal-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-500"></span> Switch
</span>
<span className="flex items-center gap-1 text-rose bg-rose-soft px-2 py-0.5 rounded border border-rose-line">
<span className="w-1.5 h-1.5 rounded-full bg-rose"></span> Firewall
<span className="flex items-center gap-1 text-rose-400 bg-rose-950/40 px-2 py-0.5 rounded border border-rose-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span> Firewall
</span>
<span className="flex items-center gap-1 text-warning bg-warning-soft px-2 py-0.5 rounded border border-warning-line">
<span className="w-1.5 h-1.5 rounded-full bg-warning"></span> AP
<span className="flex items-center gap-1 text-amber-400 bg-amber-950/40 px-2 py-0.5 rounded border border-amber-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span> AP
</span>
<span className="flex items-center gap-1 text-info bg-info-soft px-2 py-0.5 rounded border border-info-line">
<span className="w-1.5 h-1.5 rounded-full bg-info"></span> WLC
<span className="flex items-center gap-1 text-cyan-400 bg-cyan-950/40 px-2 py-0.5 rounded border border-cyan-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500"></span> WLC
</span>
</div>
</div>
<div className="relative overflow-auto border border-line rounded-lg bg-inner flex justify-center items-center">
<div className="relative overflow-auto border border-slate-800 rounded-lg bg-slate-950/60 flex justify-center items-center">
{devices.length === 0 ? (
<div className="py-20 text-center text-fg-faint text-xs font-sans">
<div className="py-20 text-center text-slate-500 text-xs font-sans">
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
</div>
) : (
@ -246,8 +246,8 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
key={`badge-${idx}`}
className={`absolute -translate-x-1/2 -translate-y-1/2 px-2 py-0.5 rounded text-[9px] font-mono transition-all z-20 shadow pointer-events-none ${
isHovered
? 'bg-success text-white scale-110 font-bold border border-success z-30'
: 'bg-inner text-fg-muted border border-line'
? 'bg-emerald-500 text-slate-950 scale-110 font-bold border border-emerald-400 z-30'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}
style={{ left: layout.apexX, top: layout.apexY }}
>
@ -265,25 +265,25 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
<button
key={device.id}
onClick={() => onSelectDevice && onSelectDevice(device)}
className={`absolute group -translate-x-1/2 -translate-y-1/2 p-3 border-2 rounded-xl flex flex-col items-center gap-1.5 transition-all text-center z-20 ${getDeviceColorClass(device.type)} hover:bg-card hover:scale-105 hover:border-success hover:shadow-lg`}
className={`absolute group -translate-x-1/2 -translate-y-1/2 p-3 border-2 rounded-xl flex flex-col items-center gap-1.5 transition-all text-center z-20 ${getDeviceColorClass(device.type)} hover:bg-slate-900/90 hover:scale-105 hover:border-emerald-500/80 hover:shadow-[0_0_15px_rgba(16,185,129,0.15)]`}
style={{ left: pos.x, top: pos.y }}
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
>
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-card ${
device.status === 'online' ? 'bg-success' :
device.status === 'offline' ? 'bg-rose' : 'bg-fg-faint'
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
device.status === 'online' ? 'bg-emerald-500' :
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} />
<div className="p-1.5 bg-inner rounded-lg border border-line">
<div className="p-1.5 bg-slate-950/60 rounded-lg border border-slate-800/40">
{getDeviceIcon(device.type)}
</div>
<div className="leading-none">
<p className="text-[11px] font-mono font-bold tracking-tight text-fg group-hover:text-success transition-colors">
<p className="text-[11px] font-mono font-bold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
{device.hostname}
</p>
<p className="text-[9px] font-mono text-fg-muted group-hover:text-fg mt-0.5">
<p className="text-[9px] font-mono text-slate-400 group-hover:text-slate-300 mt-0.5">
{device.ip}
</p>
</div>

View File

@ -60,53 +60,53 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
<div className="bg-card border border-line rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
<h3 className="text-sm font-semibold text-fg">Edit User</h3>
<button onClick={onClose} className="p-1 rounded-lg text-fg-muted hover:text-fg hover:bg-inner transition-all">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm">
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl shadow-2xl w-full max-w-md">
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-800">
<h3 className="text-sm font-semibold text-white">Edit User</h3>
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{error}
</div>
)}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Name</label>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Name</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Email address</label>
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Email address</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
className="w-full bg-field border border-line-strong rounded-lg px-3 py-2.5 text-sm text-fg placeholder-fg-faint focus:outline-none focus:ring-2 focus:ring-info/50 focus:border-info transition-all font-mono"
className="w-full bg-slate-900 border border-slate-700 rounded-lg px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-cyan-500/50 focus:border-cyan-500/50 transition-all font-mono"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-inner transition-all"
className="px-4 py-2 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 disabled:bg-line-strong disabled:text-fg-faint text-white transition-all"
className="flex items-center gap-2 px-4 py-2 rounded-lg text-xs font-semibold bg-cyan-600 hover:bg-cyan-500 disabled:bg-slate-700 disabled:text-slate-500 text-white transition-all"
>
{saving ? <span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-3.5 h-3.5" />}
{saving ? 'Saving…' : 'Save'}
@ -177,57 +177,57 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
<div className="space-y-6 font-sans" id="user-directory-root">
{/* Header banner */}
<div className="bg-card border border-line rounded-2xl p-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 text-fg-faint pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
<div className="bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
TEAM
</div>
<div className="relative space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
<Users className="w-6 h-6 text-success" />
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<Users className="w-6 h-6 text-emerald-400" />
Registered Operators
</h2>
<p className="text-xs text-fg-muted max-w-xl leading-relaxed">
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
</p>
<div className="flex flex-wrap gap-2 pt-3">
<span className="inline-flex items-center gap-1.5 bg-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
<Users className="w-3.5 h-3.5 text-success" />
<strong className="text-fg font-mono">{users.length}</strong> registered
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Users className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{users.length}</strong> registered
</span>
<span className="inline-flex items-center gap-1.5 bg-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
<Calendar className="w-3.5 h-3.5 text-primary" />
<strong className="text-fg font-mono">{bookings.length}</strong> total bookings
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Calendar className="w-3.5 h-3.5 text-indigo-400" />
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
</span>
<span className="inline-flex items-center gap-1.5 bg-inner border border-line rounded-lg px-3 py-1.5 text-[11px] text-fg-muted">
<Activity className="w-3.5 h-3.5 text-success" />
<strong className="text-fg font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
</span>
</div>
</div>
</div>
{roleError && (
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
<div className="flex items-center gap-2 bg-red-950/50 border border-red-900/50 rounded-lg px-3 py-2 text-xs text-red-300">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
{roleError}
<button onClick={() => setRoleError(null)} className="ml-auto text-danger hover:text-fg"><X className="w-3 h-3" /></button>
<button onClick={() => setRoleError(null)} className="ml-auto text-red-400 hover:text-white"><X className="w-3 h-3" /></button>
</div>
)}
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-fg-faint"><Search className="w-4 h-4" /></span>
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-card text-fg border border-line rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-success"
className="w-full bg-[#1E293B] text-slate-100 border border-slate-800 rounded-xl pl-9 pr-4 py-2 text-xs focus:outline-none focus:border-emerald-600"
/>
</div>
{/* User grid */}
{filtered.length === 0 ? (
<p className="text-center py-16 text-fg-faint text-xs">No operators match your search.</p>
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map(user => {
@ -238,10 +238,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
return (
<div
key={user.id}
className={`relative bg-card border rounded-xl p-5 transition-all hover:shadow-lg ${isMe ? 'border-success shadow-lg' : 'border-line hover:border-line-strong'}`}
className={`relative bg-[#1E293B] border rounded-xl p-5 transition-all hover:shadow-lg ${isMe ? 'border-emerald-500/50 shadow-[0_0_20px_rgba(16,185,129,0.08)]' : 'border-slate-800 hover:border-slate-700'}`}
>
{isMe && (
<span className="absolute top-3 right-3 text-[9px] font-bold uppercase tracking-wider text-success bg-success-soft border border-success-line px-2 py-0.5 rounded-full">
<span className="absolute top-3 right-3 text-[9px] font-bold uppercase tracking-wider text-emerald-400 bg-emerald-950/60 border border-emerald-900/60 px-2 py-0.5 rounded-full">
You
</span>
)}
@ -251,10 +251,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
{initials(user.name)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-fg truncate">{user.name}</h3>
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
<a
href={`mailto:${user.email}`}
className="text-[11px] text-fg-muted hover:text-success truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
className="text-[11px] text-slate-400 hover:text-emerald-400 truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
>
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{user.email}</span>
@ -262,20 +262,20 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
</div>
</div>
<div className="mt-4 pt-3 border-t border-line flex items-center justify-between gap-2">
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
{user.role.toLowerCase() === 'admin'
? <span className="flex items-center gap-1 text-[10px] font-mono text-warning uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
: <span className="text-[10px] font-mono text-fg-faint uppercase tracking-wider">User</span>
? <span className="flex items-center gap-1 text-[10px] font-mono text-amber-400 uppercase tracking-wider"><ShieldCheck className="w-3 h-3" />Admin</span>
: <span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">User</span>
}
<div className="flex items-center gap-2">
<div className="flex items-center gap-3 text-[10px] font-mono text-fg-muted">
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
<span className="flex items-center gap-1" title="Total bookings">
<Calendar className="w-3 h-3 text-primary" />
<Calendar className="w-3 h-3 text-indigo-400" />
{total}
</span>
<span className="flex items-center gap-1" title="Active / upcoming bookings">
<Activity className="w-3 h-3 text-success" />
<Activity className="w-3 h-3 text-emerald-400" />
{active}
</span>
</div>
@ -286,11 +286,11 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
<button
onClick={() => handleToggleRole(user)}
disabled={togglingRoleId === user.id}
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-warning hover:text-fg-muted hover:bg-inner' : 'text-fg-faint hover:text-warning hover:bg-inner'}`}
className={`p-1.5 rounded-lg transition-all disabled:opacity-40 ${user.role.toLowerCase() === 'admin' ? 'text-amber-400 hover:text-slate-400 hover:bg-slate-900' : 'text-slate-500 hover:text-amber-400 hover:bg-slate-900'}`}
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
>
{togglingRoleId === user.id
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-warning rounded-full animate-spin inline-block" />
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-amber-400 rounded-full animate-spin inline-block" />
: user.role.toLowerCase() === 'admin'
? <ShieldCheck className="w-3.5 h-3.5" />
: <Shield className="w-3.5 h-3.5" />}
@ -298,7 +298,7 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
)}
<button
onClick={() => setEditingUser(user)}
className="p-1.5 rounded-lg text-fg-faint hover:text-info hover:bg-inner transition-all"
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
title="Edit name / email"
>
<Pencil className="w-3.5 h-3.5" />
@ -307,11 +307,11 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
<button
onClick={() => handleDelete(user.id)}
disabled={isDeleting}
className="p-1.5 rounded-lg text-fg-faint hover:text-rose hover:bg-inner transition-all disabled:opacity-40"
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-900 transition-all disabled:opacity-40"
title="Delete user"
>
{isDeleting
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-rose rounded-full animate-spin inline-block" />
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-rose-400 rounded-full animate-spin inline-block" />
: <Trash2 className="w-3.5 h-3.5" />}
</button>
)}

View File

@ -6,38 +6,6 @@
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
/* ── Semantic design tokens ────────────────────────────────────────
Wired with `inline` so each utility references the CSS var directly
(e.g. `.bg-card { background: var(--bg-card) }`). The values live in
:root / :root.light below, so theme switching is a pure var swap —
no per-utility light-mode overrides needed. */
@theme inline {
/* neutrals */
--color-surface: var(--bg);
--color-header: var(--bg-header);
--color-card: var(--bg-card);
--color-inner: var(--bg-inner);
--color-field: var(--bg-input);
--color-line: var(--border);
--color-line-strong: var(--border-muted);
--color-fg: var(--text);
--color-fg-muted: var(--text-muted);
--color-fg-faint: var(--text-faint);
--color-overlay: var(--overlay);
/* accents — triplet per hue: vivid (text/icon) / soft (chip bg) / line (chip border) */
--color-success: var(--success); --color-success-soft: var(--success-soft); --color-success-line: var(--success-line);
--color-info: var(--info); --color-info-soft: var(--info-soft); --color-info-line: var(--info-line);
--color-primary: var(--primary); --color-primary-soft: var(--primary-soft); --color-primary-line: var(--primary-line);
--color-warning: var(--warning); --color-warning-soft: var(--warning-soft); --color-warning-line: var(--warning-line);
--color-danger: var(--danger); --color-danger-soft: var(--danger-soft); --color-danger-line: var(--danger-line);
--color-rose: var(--rose); --color-rose-soft: var(--rose-soft); --color-rose-line: var(--rose-line);
--color-violet: var(--violet); --color-violet-soft: var(--violet-soft); --color-violet-line: var(--violet-line);
--color-sky: var(--sky); --color-sky-soft: var(--sky-soft); --color-sky-line: var(--sky-line);
--color-orange: var(--orange); --color-orange-soft: var(--orange-soft); --color-orange-line: var(--orange-line);
--color-blue: var(--blue); --color-blue-soft: var(--blue-soft); --color-blue-line: var(--blue-line);
}
/* ── AirIT brand tokens ────────────────────────────────────────── */
:root {
--airit-navy: #003A70;
@ -50,37 +18,21 @@
--airit-border: #D6DADF;
}
/* ── Theme values: DARK (default) ──────────────────────────────── */
/* ── CSS custom properties ─────────────────────────────────────── */
:root {
/* neutrals */
--bg: #0b0f19;
--bg-header: #0f172a;
--bg-card: #181f2b;
--bg-inner: #0c1119;
--bg-input: #0a0e16;
--border: #283142;
--border-muted:#3a4659;
--text: #e9eef5;
--bg-card: #1e293b;
--bg-inner: #090d16;
--bg-input: #020408;
--border: #1e293b;
--border-muted:#334155;
--text: #f1f5f9;
--text-muted: #94a3b8;
--text-faint: #64748b;
--overlay: rgba(2, 6, 12, 0.6);
/* accents — calmer, slightly desaturated; soft chips use alpha tints */
--success: #34d399; --success-soft: rgba(52,211,153,0.12); --success-line: rgba(52,211,153,0.26);
--info: #22d3ee; --info-soft: rgba(34,211,238,0.12); --info-line: rgba(34,211,238,0.26);
--primary: #818cf8; --primary-soft: rgba(129,140,248,0.14); --primary-line: rgba(129,140,248,0.28);
--warning: #fbbf24; --warning-soft: rgba(251,191,36,0.13); --warning-line: rgba(251,191,36,0.28);
--danger: #f87171; --danger-soft: rgba(248,113,113,0.13); --danger-line: rgba(248,113,113,0.30);
--rose: #fb7185; --rose-soft: rgba(251,113,133,0.13); --rose-line: rgba(251,113,133,0.30);
--violet: #a78bfa; --violet-soft: rgba(167,139,250,0.14); --violet-line: rgba(167,139,250,0.28);
--sky: #38bdf8; --sky-soft: rgba(56,189,248,0.13); --sky-line: rgba(56,189,248,0.28);
--orange: #fb923c; --orange-soft: rgba(251,146,60,0.13); --orange-line: rgba(251,146,60,0.28);
--blue: #60a5fa; --blue-soft: rgba(96,165,250,0.13); --blue-line: rgba(96,165,250,0.28);
--text-label: #cbd5e1;
}
/* ── Theme values: LIGHT ───────────────────────────────────────── */
:root.light {
/* neutrals */
--bg: #f1f5f9;
--bg-header: #ffffff;
--bg-card: #ffffff;
@ -90,40 +42,879 @@
--border-muted:#cbd5e1;
--text: #0f172a;
--text-muted: #475569;
--text-faint: #94a3b8;
--overlay: rgba(15, 23, 42, 0.45);
/* accents */
--success: #059669; --success-soft: #d1fae5; --success-line: #6ee7b7;
--info: #0891b2; --info-soft: #cffafe; --info-line: #67e8f9;
--primary: #4f46e5; --primary-soft: #e0e7ff; --primary-line: #a5b4fc;
--warning: #d97706; --warning-soft: #fef3c7; --warning-line: #fde68a;
--danger: #dc2626; --danger-soft: #fee2e2; --danger-line: #fca5a5;
--rose: #be123c; --rose-soft: #ffe4e6; --rose-line: #fca5a5;
--violet: #7c3aed; --violet-soft: #ede9fe; --violet-line: #c4b5fd;
--sky: #0284c7; --sky-soft: #e0f2fe; --sky-line: #7dd3fc;
--orange: #ea580c; --orange-soft: #fff7ed; --orange-line: #fdba74;
--blue: #1d4ed8; --blue-soft: #dbeafe; --blue-line: #93c5fd;
--text-label: #334155;
}
/* ── Base element styling ──────────────────────────────────────────
The component layer now consumes the semantic tokens above directly,
so the old ~260-rule `:root.light` utility-override block is gone.
Only genuinely global, theme-agnostic rules remain here. */
/* ── LIGHT MODE OVERRIDES ─────────────────────────────────────── */
/* AirIT badge - always white text on navy, regardless of theme */
/* Root / body */
:root.light body,
:root.light #main-root {
background-color: var(--bg) !important;
color: var(--text) !important;
}
/* ── Backgrounds: all dark hex variants > card/inner */
:root.light .bg-\[\#0B0F19\],
:root.light .bg-\[\#0b0f19\] {
background-color: var(--bg) !important;
}
:root.light .bg-\[\#0F172A\],
:root.light .bg-\[\#0f172a\] {
background-color: var(--bg-header) !important;
border-color: var(--border) !important;
}
:root.light .bg-\[\#1E293B\],
:root.light .bg-\[\#1e293b\] {
background-color: var(--bg-card) !important;
border-color: var(--border) !important;
}
/* BookingCalendar "Quick Booking" green-tinted card */
:root.light .bg-\[\#1D2535\],
:root.light .bg-\[\#1d2535\] {
background-color: #f0fdf4 !important;
border-color: #bbf7d0 !important;
}
/* ── Header & nav ─────────────────────────────────────────────── */
:root.light header,
:root.light #app-header {
background-color: var(--bg-header) !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
:root.light aside,
:root.light #nav-sidebar {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* ── Slate utility backgrounds ────────────────────────────────── */
:root.light .bg-slate-950,
:root.light .bg-slate-900 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
}
:root.light .bg-slate-800 {
background-color: #e2e8f0 !important;
}
/* opacity variants */
:root.light .bg-slate-950\/10,
:root.light .bg-slate-950\/20,
:root.light .bg-slate-950\/30,
:root.light .bg-slate-950\/40,
:root.light .bg-slate-950\/50,
:root.light .bg-slate-950\/60,
:root.light .bg-slate-950\/80 {
background-color: rgba(241, 245, 249, 0.85) !important;
}
:root.light .bg-slate-900\/10,
:root.light .bg-slate-900\/35,
:root.light .bg-slate-900\/40,
:root.light .bg-slate-900\/60,
:root.light .bg-slate-900\/80 {
background-color: #f8fafc !important;
}
:root.light .bg-slate-800\/50,
:root.light .bg-slate-800\/60,
:root.light .bg-slate-800\/80 {
background-color: #e9ecf0 !important;
}
/* ── Dashboard / UserDirectory banner gradients (dark hex only) ── */
/* Targets only the dark-themed banners, not coloured avatar gradients */
:root.light .bg-gradient-to-br.from-\[\#1E293B\],
:root.light .bg-gradient-to-br.from-\[\#1e293b\] {
background-image: linear-gradient(to bottom right, #ffffff, #f1f5f9) !important;
border-color: var(--border) !important;
}
/* ── Inputs, selects, textareas ───────────────────────────────── */
:root.light input,
:root.light select,
:root.light textarea {
background-color: var(--bg-input) !important;
border-color: var(--border-muted) !important;
color: var(--text) !important;
}
:root.light option {
background-color: #ffffff !important;
color: var(--text) !important;
}
:root.light input:focus,
:root.light select:focus,
:root.light textarea:focus {
border-color: #6366f1 !important;
}
/* ── Borders ──────────────────────────────────────────────────── */
:root.light .border-slate-900,
:root.light .border-slate-800,
:root.light .border-slate-850,
:root.light .border-slate-855,
:root.light .border-slate-700,
:root.light .border-\[\#1E293B\],
:root.light .border-\[\#1e293b\] {
border-color: var(--border) !important;
}
:root.light .border-red-900\/50,
:root.light .border-red-900\/40,
:root.light .border-red-900\/30 {
border-color: #fca5a5 !important;
}
:root.light .divide-slate-800 > *,
:root.light .divide-slate-850 > * {
border-color: var(--border) !important;
}
/* ── Text colours ─────────────────────────────────────────────── */
:root.light .text-white,
:root.light .text-white\/90,
:root.light .text-white\/80,
:root.light .text-white\/70,
:root.light .text-slate-100,
:root.light .text-slate-200 {
color: var(--text) !important;
}
:root.light .text-slate-300 {
color: #334155 !important;
}
:root.light .text-slate-400 {
color: #64748b !important;
}
:root.light .text-slate-500 {
color: #94a3b8 !important;
}
/* Accent colours - slightly darkened for readability on white */
:root.light .text-emerald-400 {
color: #059669 !important;
}
:root.light .text-cyan-400 {
color: #0891b2 !important;
}
:root.light .text-indigo-400 {
color: #4f46e5 !important;
}
:root.light .text-amber-400,
:root.light .text-amber-500 {
color: #d97706 !important;
}
:root.light .text-rose-400,
:root.light .text-rose-450 {
color: #be123c !important;
}
/* ── Accent / status badge backgrounds ───────────────────────── */
:root.light .bg-emerald-950\/60,
:root.light .bg-emerald-950\/50,
:root.light .bg-emerald-950\/40,
:root.light .bg-emerald-950\/20,
:root.light .bg-emerald-950\/80 {
background-color: #d1fae5 !important;
border-color: #6ee7b7 !important;
color: #065f46 !important;
}
:root.light .bg-indigo-950\/60,
:root.light .bg-indigo-950\/50,
:root.light .bg-indigo-950\/40 {
background-color: #e0e7ff !important;
border-color: #a5b4fc !important;
color: #3730a3 !important;
}
:root.light .bg-rose-950\/60,
:root.light .bg-rose-950\/40,
:root.light .bg-rose-950\/20 {
background-color: #ffe4e6 !important;
border-color: #fca5a5 !important;
color: #9f1239 !important;
}
:root.light .bg-rose-950\/30,
:root.light .hover\:bg-rose-950\/30:hover {
background-color: #ffe4e6 !important;
}
:root.light .bg-red-950\/60,
:root.light .bg-red-950\/50,
:root.light .bg-red-950\/40,
:root.light .bg-red-950\/20 {
background-color: #fee2e2 !important;
border-color: #fca5a5 !important;
color: #b91c1c !important;
}
:root.light .border-red-800\/60 {
border-color: #fca5a5 !important;
}
:root.light .text-red-300 {
color: #b91c1c !important;
}
:root.light .text-red-400 {
color: #dc2626 !important;
}
:root.light .bg-cyan-950\/40 {
background-color: #cffafe !important;
border-color: #67e8f9 !important;
color: #155e75 !important;
}
:root.light .bg-amber-950\/40,
:root.light .bg-amber-900\/30 {
background-color: #fef3c7 !important;
border-color: #fde68a !important;
color: #92400e !important;
}
/* ── Nav sidebar active item ──────────────────────────────────── */
:root.light #nav-sidebar button {
color: #475569 !important;
}
:root.light #nav-sidebar button:hover:not(.bg-gradient-to-r) {
background-color: rgba(0, 0, 0, 0.05) !important;
color: var(--text) !important;
}
/* ── Sidebar telemetry box ────────────────────────────────────── */
:root.light #nav-sidebar .bg-slate-950 {
background-color: #f1f5f9 !important;
border-color: var(--border-muted) !important;
}
/* ── Dropdown panels (mail, bell) ─────────────────────────────── */
:root.light .bg-\[\#1E293B\].rounded-xl,
:root.light .shadow-2xl.rounded-xl {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
}
/* ── Table internals ──────────────────────────────────────────── */
:root.light table {
color: var(--text) !important;
}
:root.light thead {
background-color: #f8fafc !important;
}
:root.light tbody tr:hover {
background-color: #f1f5f9 !important;
}
:root.light .bg-\[\#0f172a\]\/60,
:root.light tr.bg-\[\#0f172a\] {
background-color: #f1f5f9 !important;
}
/* ── Dashed empty states ──────────────────────────────────────── */
:root.light .border-dashed {
border-color: var(--border-muted) !important;
}
/* ── DeviceInventory ──────────────────────────────────────────── */
/* Emergency Sheet container (amber-tinted dark card) */
:root.light .bg-\[\#1D2432\],
:root.light .bg-\[\#1d2432\] {
background-color: #fffbeb !important;
border-color: #fde68a !important;
}
/* Emergency sheet markdown content area - light in light mode */
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80,
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 {
background-color: #ffffff !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80 *,
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 * {
color: var(--text) !important;
}
/* Keep emerald headings readable */
:root.light .bg-\[\#1D2432\] .bg-slate-950\/80 h5,
:root.light .bg-\[\#1d2432\] .bg-slate-950\/80 h5 {
color: #059669 !important;
}
/* Device type icon pill backgrounds */
:root.light .bg-rose-950\/20 { background-color: #fff1f2 !important; border-color: #fecdd3 !important; }
:root.light .bg-amber-950\/20 { background-color: #fffbeb !important; border-color: #fde68a !important; }
:root.light .bg-cyan-950\/20 { background-color: #ecfeff !important; border-color: #a5f3fc !important; }
:root.light .bg-teal-950\/20 { background-color: #f0fdfa !important; border-color: #99f6e4 !important; }
/* Filter toolbar type-filter buttons */
:root.light .bg-slate-850,
:root.light .hover\:bg-slate-850:hover {
background-color: #e2e8f0 !important;
}
/* Device card selected state */
:root.light #inventory-list-container .bg-slate-900.border-emerald-500\/80 {
background-color: #f0fdf4 !important;
border-color: #10b981 !important;
}
/* Device card unselected/hover */
:root.light #inventory-list-container .bg-slate-900\/40 {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
:root.light #inventory-list-container .hover\:bg-slate-900\/60:hover {
background-color: #f1f5f9 !important;
}
/* SPECS ID badge and code block inside right panel */
:root.light #inventory-details-container .bg-slate-950 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
:root.light #inventory-details-container .bg-slate-900\/50,
:root.light #inventory-details-container .bg-slate-900\/60 {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* Amber rescue badge */
:root.light .bg-amber-950 {
background-color: #fef3c7 !important;
border-color: #fde68a !important;
color: #92400e !important;
}
/* ── Dashboard "NET" watermark: invisible in light mode ───────── */
:root.light #dashboard-cockpit-root .text-slate-800 {
color: transparent !important;
}
/* ── Modal overlays ───────────────────────────────────────────── */
:root.light .fixed.inset-0 {
background-color: rgba(15, 23, 42, 0.45) !important;
}
:root.light .fixed.inset-0 > div,
:root.light .bg-\[\#0F172A\].rounded-2xl {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
}
/* ── Lab Template Modal internals ─────────────────────────────── */
/* Modal header bar */
:root.light .fixed.inset-0 .bg-slate-900.border-b {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* Modal form body */
:root.light .fixed.inset-0 form {
background-color: #ffffff !important;
}
/* Device-toggle buttons inside modal */
:root.light .fixed.inset-0 .bg-slate-900.border-slate-800 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
color: var(--text-label) !important;
}
:root.light .fixed.inset-0 .bg-slate-900.border-slate-850 {
background-color: #f1f5f9 !important;
}
/* Device grid area */
:root.light .bg-slate-950\/60 {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* Link builder row */
:root.light .bg-slate-1000,
:root.light .bg-slate-1000\/40 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
}
/* Existing link row badges */
:root.light .fixed.inset-0 .bg-slate-900\/40 {
background-color: #f8fafc !important;
}
/* border-slate-700 in modal context */
:root.light .border-slate-700 {
border-color: var(--border) !important;
}
/* ── Login / Register pages ───────────────────────────────────── */
:root.light .min-h-screen.bg-\[\#0B0F19\] {
background-color: var(--bg) !important;
}
:root.light .bg-slate-950\/80 {
background-color: rgba(255, 255, 255, 0.9) !important;
border-color: var(--border) !important;
}
/* ── Code / terminal blocks - always dark ─────────────────────── */
:root.light pre,
:root.light code,
:root.light .font-mono.bg-slate-950 {
background-color: #0d1117 !important;
color: #00f0ff !important;
border-color: #1f242c !important;
}
:root.light pre *,
:root.light code * {
color: inherit !important;
}
/* ── Settings page blue (Entra ID) tokens ─────────────────────── */
:root.light .bg-blue-950\/60,
:root.light .bg-blue-950\/50,
:root.light .bg-blue-950\/40 {
background-color: #dbeafe !important;
border-color: #93c5fd !important;
color: #1d4ed8 !important;
}
:root.light .border-blue-900\/50,
:root.light .border-blue-900\/40 {
border-color: #93c5fd !important;
}
:root.light .text-blue-400 {
color: #1d4ed8 !important;
}
/* ── AirIT badge - always white text on navy, regardless of theme ─ */
.airit-badge {
color: #ffffff;
background-color: var(--airit-navy);
color: #ffffff !important;
background-color: var(--airit-navy) !important;
}
/* Text selection - subtle brand tint in both themes */
::selection {
background-color: rgba(16, 185, 129, 0.25);
/* ── Text selection ───────────────────────────────────────────── */
:root.light ::selection {
background-color: rgba(5, 150, 105, 0.2) !important;
color: #047857 !important;
}
/* Date picker calendar icon - invert to dark so it reads on light inputs */
/* ── Reservation Details Modal (BookingDetailsModal) ──────────── */
/* Device node cards inside right panel */
:root.light #booking-details-modal .bg-slate-950\/65 {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* JSON REST Response panel GitHub Light style in light mode */
:root.light #booking-details-modal .font-mono.bg-slate-950 {
background-color: #f6f8fa !important;
color: #24292f !important;
border-color: #d0d7de !important;
}
/* The <pre> inside inherits the dark pre-rule; override explicitly */
:root.light #booking-details-modal .font-mono.bg-slate-950 pre {
background-color: transparent !important;
color: #24292f !important;
border-color: transparent !important;
}
/* Header bar inside the JSON panel */
:root.light #booking-details-modal .font-mono.bg-slate-950 .bg-slate-900 {
background-color: #eaeef2 !important;
border-color: #d0d7de !important;
}
/* Title label and icon in panel header */
:root.light #booking-details-modal .font-mono.bg-slate-950 .text-indigo-400 {
color: #6366f1 !important;
}
/* Copy button inside panel */
:root.light #booking-details-modal .font-mono.bg-slate-950 button {
background-color: #eaeef2 !important;
border-color: #d0d7de !important;
color: #57606a !important;
}
:root.light #booking-details-modal .font-mono.bg-slate-950 button:hover {
background-color: #d0d7de !important;
color: #24292f !important;
}
/* Ansible status card orange accent in light mode */
:root.light #booking-details-modal .bg-orange-950\/10 {
background-color: #fff7ed !important;
}
:root.light #booking-details-modal .border-orange-900\/40 {
border-color: #fdba74 !important;
}
:root.light .text-orange-400 {
color: #ea580c !important;
}
:root.light .bg-orange-950\/60,
:root.light .bg-orange-900\/40 {
background-color: #fed7aa !important;
border-color: #fb923c !important;
color: #9a3412 !important;
}
:root.light .border-orange-800\/50 {
border-color: #fb923c !important;
}
/* bg-slate-900/40 rows (notice box etc.) light in light mode */
:root.light #booking-details-modal .bg-slate-900\/40 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
/* ── Reserve Slot selects & date pickers (BookingCalendar) ───── */
:root.light #booking-actions-card select,
:root.light #booking-actions-card input[type="text"],
:root.light #booking-actions-card input[type="date"],
:root.light #booking-actions-card textarea {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
color: var(--text) !important;
}
/* Date picker calendar icon - invert to dark in light mode */
:root.light input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.6);
cursor: pointer;
}
/* ── Define Ports dropdowns & Link Builder (LabTemplates modal) ── */
/* Non-standard text/border classes used in the link builder */
:root.light .text-slate-250 {
color: var(--text) !important;
}
:root.light .border-slate-805 {
border-color: var(--border) !important;
}
/* Selects and inputs inside any fixed modal overlay */
:root.light .fixed.inset-0 select,
:root.light .fixed.inset-0 input[type="text"],
:root.light .fixed.inset-0 input[type="email"],
:root.light .fixed.inset-0 input[type="password"],
:root.light .fixed.inset-0 input[type="date"],
:root.light .fixed.inset-0 textarea {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
color: var(--text) !important;
}
/* Link row items inside modal */
:root.light .fixed.inset-0 .bg-slate-950.font-mono {
background-color: #f8fafc !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
/* "Add Link" button - keep readable white label on indigo in light mode
(the global :root.light .text-white override would otherwise darken it) */
:root.light #add-link-btn {
background-color: #4f46e5 !important;
color: #ffffff !important;
}
:root.light #add-link-btn:hover {
background-color: #6366f1 !important;
}
/* ─────────────────────────────────────────────────────────────────── */
/* EXTENDED LIGHT MODE OVERRIDES */
/* ─────────────────────────────────────────────────────────────────── */
/* ── Solid (no-opacity) color backgrounds ────────────────────────── */
/* These are used in Logbook type badges and Dashboard countdown pill */
:root.light .bg-emerald-950 {
background-color: #d1fae5 !important;
border-color: #6ee7b7 !important;
color: #065f46 !important;
}
:root.light .bg-cyan-950 {
background-color: #cffafe !important;
border-color: #67e8f9 !important;
color: #155e75 !important;
}
:root.light .bg-indigo-950 {
background-color: #e0e7ff !important;
border-color: #a5b4fc !important;
color: #3730a3 !important;
}
:root.light .bg-rose-950 {
background-color: #ffe4e6 !important;
border-color: #fca5a5 !important;
color: #9f1239 !important;
}
/* ── emerald-900 opacity variants (quick booking modal tabs) ──────── */
:root.light .bg-emerald-900\/50,
:root.light .bg-emerald-900\/40,
:root.light .bg-emerald-900\/30 {
background-color: #d1fae5 !important;
}
/* ── Violet accent (LinkDashboard) ────────────────────────────────── */
:root.light .bg-violet-950\/60,
:root.light .bg-violet-950\/40,
:root.light .bg-violet-950\/20 {
background-color: #ede9fe !important;
border-color: #c4b5fd !important;
color: #5b21b6 !important;
}
:root.light .text-violet-400 {
color: #7c3aed !important;
}
/* ── 300-level text near-invisible on white backgrounds ────────── */
:root.light .text-amber-300 {
color: #b45309 !important;
}
:root.light .text-emerald-300 {
color: #059669 !important;
}
:root.light .text-cyan-300 {
color: #0891b2 !important;
}
:root.light .text-rose-300 {
color: #be123c !important;
}
:root.light .text-indigo-300 {
color: #4338ca !important;
}
/* ── Missing border opacity variants ─────────────────────────────── */
/* slate-800 with opacity */
:root.light .border-slate-800\/50,
:root.light .border-slate-800\/40 {
border-color: var(--border) !important;
}
/* slate-700 with opacity */
:root.light .border-slate-700\/40,
:root.light .border-slate-700\/50,
:root.light .border-slate-700\/60 {
border-color: var(--border) !important;
}
/* slate-900 opacity (section dividers, row separators) */
:root.light .border-slate-900\/30,
:root.light .border-slate-900\/40,
:root.light .border-slate-900\/60 {
border-color: var(--border) !important;
}
/* emerald borders solid and opacity variants */
:root.light .border-emerald-900,
:root.light .border-emerald-900\/30,
:root.light .border-emerald-900\/40,
:root.light .border-emerald-900\/60 {
border-color: #6ee7b7 !important;
}
:root.light .border-emerald-800\/50,
:root.light .border-emerald-800\/60 {
border-color: #a7f3d0 !important;
}
/* cyan borders */
:root.light .border-cyan-900\/50,
:root.light .border-cyan-900\/60 {
border-color: #67e8f9 !important;
}
/* amber borders */
:root.light .border-amber-900\/40,
:root.light .border-amber-900\/60 {
border-color: #fde68a !important;
}
:root.light .border-amber-800\/50,
:root.light .border-amber-800\/60 {
border-color: #fde68a !important;
}
/* rose borders */
:root.light .border-rose-900\/30,
:root.light .border-rose-900\/50,
:root.light .border-rose-900\/60 {
border-color: #fca5a5 !important;
}
/* indigo borders */
:root.light .border-indigo-900,
:root.light .border-indigo-900\/40,
:root.light .border-indigo-900\/50 {
border-color: #a5b4fc !important;
}
/* violet borders */
:root.light .border-violet-900\/50,
:root.light .border-violet-950\/40 {
border-color: #c4b5fd !important;
}
/* ── Missing bg-slate-900 opacity variants ───────────────────────── */
:root.light .bg-slate-900\/30,
:root.light .bg-slate-900\/50,
:root.light .bg-slate-900\/70,
:root.light .bg-slate-900\/90 {
background-color: #f1f5f9 !important;
}
/* ── bg-slate-800 additional variant ────────────────────────────── */
:root.light .bg-slate-800\/40 {
background-color: #e2e8f0 !important;
}
/* ── Hover-state overrides for dark bg classes ───────────────────── */
/* Without these the hover flashes a dark background in light mode. */
:root.light .hover\:bg-slate-900:hover {
background-color: #f1f5f9 !important;
}
:root.light .hover\:bg-slate-900\/35:hover,
:root.light .hover\:bg-slate-900\/40:hover,
:root.light .hover\:bg-slate-900\/60:hover,
:root.light .hover\:bg-slate-900\/70:hover,
:root.light .hover\:bg-slate-900\/80:hover {
background-color: #f1f5f9 !important;
}
:root.light .hover\:bg-slate-800:hover {
background-color: #e2e8f0 !important;
}
:root.light .hover\:bg-slate-800\/80:hover {
background-color: #e2e8f0 !important;
}
:root.light .hover\:bg-slate-950\/30:hover,
:root.light .hover\:bg-slate-950\/40:hover {
background-color: #f8fafc !important;
}
/* Coloured hover states */
:root.light .hover\:bg-emerald-900\/40:hover,
:root.light .hover\:bg-emerald-900\/60:hover {
background-color: #a7f3d0 !important;
}
:root.light .hover\:bg-rose-900\/60:hover {
background-color: #fecdd3 !important;
}
:root.light .hover\:bg-red-950\/40:hover {
background-color: #fee2e2 !important;
}
/* ── Border-dashed empty slots (calendar grid) ───────────────────── */
:root.light .border-slate-800\/40 {
border-color: var(--border) !important;
}
:root.light .hover\:border-slate-700\/60:hover {
border-color: var(--border-muted) !important;
}
/* ── Hover text colors prevent near-white text on light backgrounds */
:root.light .hover\:text-white:hover,
:root.light .hover\:text-slate-100:hover,
:root.light .hover\:text-slate-200:hover {
color: var(--text) !important;
}
:root.light .group:hover .group-hover\:text-white {
color: var(--text) !important;
}
:root.light .group:hover .group-hover\:text-slate-300 {
color: var(--text-muted) !important;
}
/* ── Settings → Caddy section: sky accent ─────────────────────────── */
/* sky-* is used only by the Caddy card; map its dark tokens to light. */
:root.light .bg-sky-950\/60,
:root.light .bg-sky-950\/40,
:root.light .bg-sky-900\/40 {
background-color: #e0f2fe !important;
border-color: #7dd3fc !important;
color: #0369a1 !important;
}
:root.light .border-sky-900\/50,
:root.light .border-sky-900\/40 {
border-color: #7dd3fc !important;
}
:root.light .text-sky-400,
:root.light .text-sky-500 {
color: #0284c7 !important;
}
:root.light .bg-sky-950\/30,
:root.light .hover\:bg-sky-950\/30:hover {
background-color: #e0f2fe !important;
}
:root.light .hover\:bg-sky-900\/40:hover {
background-color: #bae6fd !important;
}
:root.light .hover\:text-sky-400:hover {
color: #0284c7 !important;
}
/* Delete-icon hover (red-950/30 is the only red opacity not yet mapped) */
:root.light .bg-red-950\/30,
:root.light .hover\:bg-red-950\/30:hover {
background-color: #fee2e2 !important;
}

View File

@ -50,10 +50,10 @@ export interface Booking {
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
notified: boolean;
emailSent?: boolean;
semaphoreSetupTriggered?: boolean;
semaphoreTeardownTriggered?: boolean;
semaphoreSetupJobId?: string;
semaphoreTeardownJobId?: string;
ansibleSetupTriggered?: boolean;
ansibleTeardownTriggered?: boolean;
ansibleSetupJobId?: string;
ansibleTeardownJobId?: string;
}
export interface LogEntry {