Compare commits
4 Commits
cc96f5b6ce
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f1d46e7f56 | |||
| 8e24487172 | |||
| e6e6c4d43a | |||
| 150557ce2c |
1
.gitignore
vendored
1
.gitignore
vendored
@ -6,6 +6,7 @@ coverage/
|
|||||||
*.log
|
*.log
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
CLAUDE.MD
|
||||||
|
|
||||||
# local SQLite database
|
# local SQLite database
|
||||||
ghostgrid.db
|
ghostgrid.db
|
||||||
|
|||||||
@ -382,6 +382,12 @@ All `/api/*` routes return JSON. Every route except the public auth/config endpo
|
|||||||
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
|
| +-- PUT /{id} # Update status; cancel>teardown trigger [auth]
|
||||||
| +-- DELETE /{id} # Delete booking [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
|
+-- /logs
|
||||||
| +-- GET / # All logs, newest first [auth]
|
| +-- GET / # All logs, newest first [auth]
|
||||||
| +-- POST / # Manual log entry [auth]
|
| +-- POST / # Manual log entry [auth]
|
||||||
@ -462,6 +468,7 @@ Step 2 for each device:
|
|||||||
- on change: write a 'status' log
|
- on change: write a 'status' log
|
||||||
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
Summary log per cycle: "<online> online, <offline> offline, <unknown> unknown"
|
||||||
HTTP hints: 401/403/404 mapped to actionable messages (checkmkHttpHint)
|
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
|
### 6.2 Ansible Semaphore — Playbook Automation
|
||||||
@ -482,6 +489,7 @@ triggerSemaphoreTask(templateId, extraVars):
|
|||||||
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
extraVars = { booking_id, lab_name, user_id, start_time, end_time }
|
||||||
> store returned job id on booking; log success/failure
|
> store returned job id on booking; log success/failure
|
||||||
(a booking with no template id is marked triggered > not retried)
|
(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' }
|
Manual: POST /api/semaphore/trigger/{bookingId} body { type: 'setup'|'teardown' }
|
||||||
GET /api/semaphore/templates (proxy for UI dropdowns)
|
GET /api/semaphore/templates (proxy for UI dropdowns)
|
||||||
@ -584,12 +592,14 @@ src/
|
|||||||
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
| selectedBookingForDetails, inventoryHighlightDevice, checkmk{Enabled,BaseUrl}
|
||||||
+-- Effects:
|
+-- Effects:
|
||||||
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
| +-- Startup token verify + OAuth ?token=/?auth_error= handling
|
||||||
| +-- Load data on login
|
| +-- Load data on login (one Promise.all; initial seed before SSE connects)
|
||||||
| +-- Poll GET /api/devices every 30s (surface CheckMK-driven status changes)
|
| +-- 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
|
||||||
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
|
| +-- Booking reminder check every 60s (fires once per upcoming booking ≤30min away)
|
||||||
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
+-- Handlers: handleAdd/Update/Delete* for bookings, devices, labs, links, users +
|
||||||
handleAddLogManually — call API via authFetch, update local state,
|
handleAddLogManually — call API via authFetch, update local state
|
||||||
most then refetch /api/logs
|
(SSE pushes the authoritative state to all tabs within ~1s)
|
||||||
|
|
||||||
(* persisted to localStorage)
|
(* persisted to localStorage)
|
||||||
```
|
```
|
||||||
|
|||||||
10
server-db.ts
10
server-db.ts
@ -30,7 +30,7 @@ db.exec(`
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
emergencySheet TEXT NOT NULL,
|
emergencySheet TEXT NOT NULL,
|
||||||
lastCheckedAt TEXT,
|
lastCheckedAt TEXT,
|
||||||
checkMkUrl TEXT NOT NULL DEFAULT '',
|
cmkUrl TEXT NOT NULL DEFAULT '',
|
||||||
cmkHostname TEXT NOT NULL DEFAULT ''
|
cmkHostname TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -56,10 +56,10 @@ db.exec(`
|
|||||||
status TEXT NOT NULL,
|
status TEXT NOT NULL,
|
||||||
notified INTEGER NOT NULL DEFAULT 0,
|
notified INTEGER NOT NULL DEFAULT 0,
|
||||||
emailSent INTEGER NOT NULL DEFAULT 0,
|
emailSent INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleSetupTriggered INTEGER NOT NULL DEFAULT 0,
|
semaphoreSetupTriggered INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleTeardownTriggered INTEGER NOT NULL DEFAULT 0,
|
semaphoreTeardownTriggered INTEGER NOT NULL DEFAULT 0,
|
||||||
ansibleSetupJobId TEXT NOT NULL DEFAULT '',
|
semaphoreSetupJobId TEXT NOT NULL DEFAULT '',
|
||||||
ansibleTeardownJobId TEXT NOT NULL DEFAULT ''
|
semaphoreTeardownJobId TEXT NOT NULL DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS logs (
|
CREATE TABLE IF NOT EXISTS logs (
|
||||||
|
|||||||
@ -8,13 +8,21 @@ interface Migration {
|
|||||||
// Append only. Never reorder or remove entries — that would corrupt tracking.
|
// Append only. Never reorder or remove entries — that would corrupt tracking.
|
||||||
// Each `up` function receives the open DB handle inside an already-open transaction.
|
// Each `up` function receives the open DB handle inside an already-open transaction.
|
||||||
const migrations: Migration[] = [
|
const migrations: Migration[] = [
|
||||||
// Example:
|
{
|
||||||
// {
|
id: '0001_rename_device_checkMkUrl_to_cmkUrl',
|
||||||
// id: '0001_bookings_add_color',
|
up: (db) => {
|
||||||
// up: (db) => {
|
db.exec(`ALTER TABLE devices RENAME COLUMN checkMkUrl TO cmkUrl`);
|
||||||
// db.exec(`ALTER TABLE bookings ADD COLUMN color TEXT NOT NULL DEFAULT 'blue'`);
|
},
|
||||||
// },
|
},
|
||||||
// },
|
{
|
||||||
|
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`);
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function runMigrations(db: InstanceType<typeof Database>): void {
|
export function runMigrations(db: InstanceType<typeof Database>): void {
|
||||||
|
|||||||
186
server.ts
186
server.ts
@ -48,6 +48,54 @@ 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) {
|
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;
|
const row = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as { role: string } | undefined;
|
||||||
if (!row || row.role.toLowerCase() !== 'admin') {
|
if (!row || row.role.toLowerCase() !== 'admin') {
|
||||||
@ -271,9 +319,9 @@ async function startServer() {
|
|||||||
res.json({
|
res.json({
|
||||||
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
azureEnabled: enabled && Boolean(clientId) && Boolean(tenantId) && Boolean(secret),
|
||||||
effectiveRedirectUri,
|
effectiveRedirectUri,
|
||||||
checkmkEnabled: getSetting('checkmk_enabled') === 'true',
|
cmkEnabled: getSetting('checkmk_enabled') === 'true',
|
||||||
semaphoreEnabled: getSetting('semaphore_enabled') === 'true',
|
semaphoreEnabled: getSetting('semaphore_enabled') === 'true',
|
||||||
checkmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
cmkBaseUrl: cmkApiUrl.replace(/\/api\/.*$/, ''),
|
||||||
isProduction: IS_PRODUCTION,
|
isProduction: IS_PRODUCTION,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -351,7 +399,7 @@ async function startServer() {
|
|||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// RESTFUL API: Settings (admin only)
|
// RESTFUL API: Settings (admin only)
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
app.get('/api/settings', requireAuth, (_req, res) => {
|
app.get('/api/settings', requireAuth, requireAdmin, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(maskSettings(getAllSettings()));
|
res.json(maskSettings(getAllSettings()));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -359,7 +407,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/settings', requireAuth, (req, res) => {
|
app.put('/api/settings', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const allowed = ['azure_enabled', 'azure_client_id', 'azure_tenant_id', 'azure_client_secret',
|
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',
|
'azure_redirect_uri', 'azure_allowed_group', 'checkmk_enabled', 'checkmk_api_url', 'checkmk_api_user',
|
||||||
@ -415,6 +463,7 @@ async function startServer() {
|
|||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
addLog('system', `User ${updated.email} updated: ${changes.join(', ')}.`, { userId: req.user!.userId });
|
||||||
}
|
}
|
||||||
|
broadcastUsers();
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -432,6 +481,7 @@ async function startServer() {
|
|||||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(safeRole, id);
|
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;
|
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 });
|
addLog('system', `User ${updated.email} role changed to ${safeRole}.`, { userId: req.user!.userId });
|
||||||
|
broadcastUsers();
|
||||||
res.json(updated);
|
res.json(updated);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -447,6 +497,7 @@ async function startServer() {
|
|||||||
const count = (db.prepare('SELECT COUNT(*) as n FROM users').get() as { n: number }).n;
|
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.' });
|
if (count <= 1) return res.status(400).json({ error: 'Cannot delete the last user.' });
|
||||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||||
|
broadcastUsers();
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -483,6 +534,7 @@ async function startServer() {
|
|||||||
{ deviceId: id, userId: req.user!.userId });
|
{ deviceId: id, userId: req.user!.userId });
|
||||||
|
|
||||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||||
|
broadcastDevices();
|
||||||
res.status(201).json(device);
|
res.status(201).json(device);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -505,6 +557,7 @@ async function startServer() {
|
|||||||
{ deviceId: id, userId: req.user!.userId });
|
{ deviceId: id, userId: req.user!.userId });
|
||||||
|
|
||||||
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
const device = db.prepare('SELECT * FROM devices WHERE id = ?').get(id) as Device;
|
||||||
|
broadcastDevices();
|
||||||
res.json(device);
|
res.json(device);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -535,6 +588,8 @@ async function startServer() {
|
|||||||
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
`Permanently removed the host device "${dev.hostname || id}" from the inventory records.`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastDevices();
|
||||||
|
broadcastLabs();
|
||||||
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
res.json({ success: true, message: 'Device deleted successfully and cleaned from topologies.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -580,6 +635,7 @@ async function startServer() {
|
|||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
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 });
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -611,6 +667,7 @@ async function startServer() {
|
|||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
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 });
|
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) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -638,6 +695,9 @@ async function startServer() {
|
|||||||
`Withdrew the lab testing template "${lab.name || id}".`,
|
`Withdrew the lab testing template "${lab.name || id}".`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastLabs();
|
||||||
|
broadcastBookings();
|
||||||
|
|
||||||
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
res.json({ success: true, message: 'Lab template deleted and future reservations cancelled.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -655,10 +715,10 @@ async function startServer() {
|
|||||||
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
|
startDateTime: r.startDateTime, endDateTime: r.endDateTime,
|
||||||
notes: r.notes || '', status: r.status as any,
|
notes: r.notes || '', status: r.status as any,
|
||||||
notified: r.notified === 1, emailSent: r.emailSent === 1,
|
notified: r.notified === 1, emailSent: r.emailSent === 1,
|
||||||
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
|
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
|
||||||
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
|
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
|
||||||
ansibleSetupJobId: r.ansibleSetupJobId || '',
|
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
|
||||||
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
|
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
|
||||||
}));
|
}));
|
||||||
res.json(bookings);
|
res.json(bookings);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -666,6 +726,44 @@ 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) => {
|
app.post('/api/bookings', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
const { labId, userId, startDateTime, endDateTime, notes, status, operatorName } = req.body;
|
||||||
@ -687,6 +785,8 @@ async function startServer() {
|
|||||||
{ userId });
|
{ userId });
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.status(201).json({
|
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 },
|
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.`
|
alertGenerated: `Reservation successfully approved: Lab "${lab?.name || 'Scenario'}" is reserved for your exclusive usage during this window.`
|
||||||
@ -714,35 +814,37 @@ async function startServer() {
|
|||||||
|
|
||||||
// Trigger teardown if booking had already started and teardown not yet triggered
|
// Trigger teardown if booking had already started and teardown not yet triggered
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (new Date(booking.startDateTime) <= now && !booking.ansibleTeardownTriggered) {
|
if (new Date(booking.startDateTime) <= now && !booking.semaphoreTeardownTriggered) {
|
||||||
const templateId = lab?.semaphoreTeardownTemplateId;
|
const templateId = lab?.semaphoreTeardownTemplateId;
|
||||||
if (templateId) {
|
if (templateId) {
|
||||||
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
||||||
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
|
booking_id: booking.id, lab_name: lab?.name || '', user_id: booking.userId,
|
||||||
start_time: booking.startDateTime, end_time: booking.endDateTime,
|
start_time: booking.startDateTime, end_time: booking.endDateTime,
|
||||||
});
|
});
|
||||||
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', booking.id);
|
.run(jobId !== null ? String(jobId) : '', booking.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
const r = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.json({
|
res.json({
|
||||||
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
|
id: r.id, labId: r.labId, userId: r.userId, startDateTime: r.startDateTime,
|
||||||
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
|
endDateTime: r.endDateTime, notes: r.notes || '', status: r.status,
|
||||||
notified: r.notified === 1, emailSent: r.emailSent === 1,
|
notified: r.notified === 1, emailSent: r.emailSent === 1,
|
||||||
ansibleSetupTriggered: r.ansibleSetupTriggered === 1,
|
semaphoreSetupTriggered: r.semaphoreSetupTriggered === 1,
|
||||||
ansibleTeardownTriggered: r.ansibleTeardownTriggered === 1,
|
semaphoreTeardownTriggered: r.semaphoreTeardownTriggered === 1,
|
||||||
ansibleSetupJobId: r.ansibleSetupJobId || '',
|
semaphoreSetupJobId: r.semaphoreSetupJobId || '',
|
||||||
ansibleTeardownJobId: r.ansibleTeardownJobId || '',
|
semaphoreTeardownJobId: r.semaphoreTeardownJobId || '',
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/bookings/:id', requireAuth, (req, res) => {
|
app.delete('/api/bookings/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
const booking = db.prepare('SELECT * FROM bookings WHERE id = ?').get(id) as any;
|
||||||
@ -755,6 +857,8 @@ async function startServer() {
|
|||||||
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
|
`Permanently deleted the reservation records for lab "${lab?.name || 'Unknown'}".`,
|
||||||
{ userId: req.user!.userId });
|
{ userId: req.user!.userId });
|
||||||
|
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
res.json({ success: true, message: 'Reservation and its logs have been resolved correctly.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -783,6 +887,7 @@ async function startServer() {
|
|||||||
const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
|
const id = addLog(type, message, { deviceId: deviceId || null, userId: userId || null });
|
||||||
|
|
||||||
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
const log = db.prepare('SELECT * FROM logs WHERE id = ?').get(id) as LogEntry;
|
||||||
|
broadcastLogs();
|
||||||
res.status(201).json(log);
|
res.status(201).json(log);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -814,6 +919,7 @@ async function startServer() {
|
|||||||
.run(id, title, url, description || '', category || '', color || 'emerald', req.user!.userId, createdAt);
|
.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;
|
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||||
|
broadcastLinks();
|
||||||
res.status(201).json(link);
|
res.status(201).json(link);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -831,6 +937,7 @@ async function startServer() {
|
|||||||
.run(title, url, description || '', category || '', color || 'emerald', id);
|
.run(title, url, description || '', category || '', color || 'emerald', id);
|
||||||
|
|
||||||
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(id) as QuickLink;
|
||||||
|
broadcastLinks();
|
||||||
res.json(link);
|
res.json(link);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -844,6 +951,7 @@ async function startServer() {
|
|||||||
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
if (!existing) return res.status(404).json({ error: 'Link not found.' });
|
||||||
|
|
||||||
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
db.prepare('DELETE FROM links WHERE id = ?').run(id);
|
||||||
|
broadcastLinks();
|
||||||
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
res.json({ success: true, message: 'Link removed from the shared dashboard.' });
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@ -853,7 +961,7 @@ async function startServer() {
|
|||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// DATABASE API
|
// DATABASE API
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
app.get('/api/database/info', requireAuth, (_req, res) => {
|
app.get('/api/database/info', requireAuth, requireAdmin, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const stats = fs.statSync(DB_FILE);
|
const stats = fs.statSync(DB_FILE);
|
||||||
const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy'];
|
const tables = ['users', 'devices', 'labs', 'bookings', 'logs', 'links', 'settings', 'caddy'];
|
||||||
@ -872,7 +980,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/database/backup', requireAuth, async (_req, res) => {
|
app.get('/api/database/backup', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
const tempPath = `${DB_FILE}.backup-${Date.now()}`;
|
const tempPath = `${DB_FILE}.backup-${Date.now()}`;
|
||||||
try {
|
try {
|
||||||
await db.backup(tempPath);
|
await db.backup(tempPath);
|
||||||
@ -886,7 +994,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/database/import', requireAuth,
|
app.post('/api/database/import', requireAuth, requireAdmin,
|
||||||
express.raw({ type: 'application/octet-stream', limit: '50mb' }),
|
express.raw({ type: 'application/octet-stream', limit: '50mb' }),
|
||||||
(req, res) => {
|
(req, res) => {
|
||||||
const tempPath = `${DB_FILE}.import-${Date.now()}`;
|
const tempPath = `${DB_FILE}.import-${Date.now()}`;
|
||||||
@ -942,7 +1050,7 @@ async function startServer() {
|
|||||||
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
|
// to 'unknown'. Runs on a self-scheduling setTimeout so that interval changes
|
||||||
// in Settings take effect on the next cycle without a server restart.
|
// in Settings take effect on the next cycle without a server restart.
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
function checkmkHttpHint(status: number): string {
|
function cmkHttpHint(status: number): string {
|
||||||
if (status === 401) return 'HTTP 401 Unauthorized - wrong automation user or secret (check Settings > CheckMK)';
|
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 === 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';
|
if (status === 404) return 'HTTP 404 Not Found - API URL incorrect or site name wrong';
|
||||||
@ -974,7 +1082,7 @@ async function startServer() {
|
|||||||
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
|
`${CHECKMK_API_URL}/domain-types/host_config/collections/all`,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
if (!cfgRes.ok) throw new Error(checkmkHttpHint(cfgRes.status));
|
if (!cfgRes.ok) throw new Error(cmkHttpHint(cfgRes.status));
|
||||||
const cfgData = await cfgRes.json();
|
const cfgData = await cfgRes.json();
|
||||||
ipToHostname = new Map<string, string>();
|
ipToHostname = new Map<string, string>();
|
||||||
for (const host of cfgData?.value ?? []) {
|
for (const host of cfgData?.value ?? []) {
|
||||||
@ -1013,7 +1121,7 @@ async function startServer() {
|
|||||||
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`,
|
`${CHECKMK_API_URL}/objects/host/${encodeURIComponent(cmkHost)}?columns=state&columns=hard_state&columns=has_been_checked`,
|
||||||
{ headers }
|
{ headers }
|
||||||
);
|
);
|
||||||
if (!hostRes.ok) throw new Error(checkmkHttpHint(hostRes.status));
|
if (!hostRes.ok) throw new Error(cmkHttpHint(hostRes.status));
|
||||||
const hostData = await hostRes.json();
|
const hostData = await hostRes.json();
|
||||||
|
|
||||||
const state: number = hostData?.extensions?.state ?? -1;
|
const state: number = hostData?.extensions?.state ?? -1;
|
||||||
@ -1034,6 +1142,9 @@ async function startServer() {
|
|||||||
addLog('system',
|
addLog('system',
|
||||||
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
`CheckMK sync completed — ${counts.online} online, ${counts.offline} offline, ${counts.unknown} unknown (${rows.length} devices total).`,
|
||||||
{ timestamp: now });
|
{ timestamp: now });
|
||||||
|
|
||||||
|
broadcastDevices();
|
||||||
|
broadcastLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleSync() {
|
async function scheduleSync() {
|
||||||
@ -1043,7 +1154,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
scheduleSync();
|
scheduleSync();
|
||||||
|
|
||||||
app.post('/api/checkmk/sync', requireAuth, async (_req, res) => {
|
app.post('/api/checkmk/sync', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
await syncCheckMkStatuses();
|
await syncCheckMkStatuses();
|
||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
@ -1106,20 +1217,20 @@ async function startServer() {
|
|||||||
const setupPending = db.prepare(
|
const setupPending = db.prepare(
|
||||||
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
|
`SELECT b.*, l.semaphoreSetupTemplateId, l.name AS labName
|
||||||
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
||||||
WHERE b.startDateTime <= ? AND b.ansibleSetupTriggered = 0 AND b.status != 'cancelled'`
|
WHERE b.startDateTime <= ? AND b.semaphoreSetupTriggered = 0 AND b.status != 'cancelled'`
|
||||||
).all(now) as any[];
|
).all(now) as any[];
|
||||||
|
|
||||||
for (const row of setupPending) {
|
for (const row of setupPending) {
|
||||||
const templateId = row.semaphoreSetupTemplateId;
|
const templateId = row.semaphoreSetupTemplateId;
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1 WHERE id = ?').run(row.id);
|
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1 WHERE id = ?').run(row.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
||||||
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
||||||
start_time: row.startDateTime, end_time: row.endDateTime,
|
start_time: row.startDateTime, end_time: row.endDateTime,
|
||||||
});
|
});
|
||||||
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1, semaphoreSetupJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', row.id);
|
.run(jobId !== null ? String(jobId) : '', row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1127,22 +1238,25 @@ async function startServer() {
|
|||||||
const teardownPending = db.prepare(
|
const teardownPending = db.prepare(
|
||||||
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
|
`SELECT b.*, l.semaphoreTeardownTemplateId, l.name AS labName
|
||||||
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
FROM bookings b LEFT JOIN labs l ON b.labId = l.id
|
||||||
WHERE b.endDateTime <= ? AND b.ansibleTeardownTriggered = 0 AND b.status != 'cancelled'`
|
WHERE b.endDateTime <= ? AND b.semaphoreTeardownTriggered = 0 AND b.status != 'cancelled'`
|
||||||
).all(now) as any[];
|
).all(now) as any[];
|
||||||
|
|
||||||
for (const row of teardownPending) {
|
for (const row of teardownPending) {
|
||||||
const templateId = row.semaphoreTeardownTemplateId;
|
const templateId = row.semaphoreTeardownTemplateId;
|
||||||
if (!templateId) {
|
if (!templateId) {
|
||||||
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1 WHERE id = ?').run(row.id);
|
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1 WHERE id = ?').run(row.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
const jobId = await triggerSemaphoreTask(Number(templateId), {
|
||||||
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
booking_id: row.id, lab_name: row.labName || '', user_id: row.userId,
|
||||||
start_time: row.startDateTime, end_time: row.endDateTime,
|
start_time: row.startDateTime, end_time: row.endDateTime,
|
||||||
});
|
});
|
||||||
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', row.id);
|
.run(jobId !== null ? String(jobId) : '', row.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
broadcastBookings();
|
||||||
|
broadcastLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function scheduleSemaphoreCheck() {
|
async function scheduleSemaphoreCheck() {
|
||||||
@ -1152,7 +1266,7 @@ async function startServer() {
|
|||||||
scheduleSemaphoreCheck();
|
scheduleSemaphoreCheck();
|
||||||
|
|
||||||
// Proxy Semaphore template list so the UI can populate dropdowns
|
// Proxy Semaphore template list so the UI can populate dropdowns
|
||||||
app.get('/api/semaphore/templates', requireAuth, async (_req, res) => {
|
app.get('/api/semaphore/templates', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
const apiUrl = getSetting('semaphore_api_url');
|
const apiUrl = getSetting('semaphore_api_url');
|
||||||
const token = getSetting('semaphore_api_token');
|
const token = getSetting('semaphore_api_token');
|
||||||
const projectId = getSetting('semaphore_project_id');
|
const projectId = getSetting('semaphore_project_id');
|
||||||
@ -1190,10 +1304,10 @@ async function startServer() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (type === 'setup') {
|
if (type === 'setup') {
|
||||||
db.prepare('UPDATE bookings SET ansibleSetupTriggered = 1, ansibleSetupJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreSetupTriggered = 1, semaphoreSetupJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', bookingId);
|
.run(jobId !== null ? String(jobId) : '', bookingId);
|
||||||
} else {
|
} else {
|
||||||
db.prepare('UPDATE bookings SET ansibleTeardownTriggered = 1, ansibleTeardownJobId = ? WHERE id = ?')
|
db.prepare('UPDATE bookings SET semaphoreTeardownTriggered = 1, semaphoreTeardownJobId = ? WHERE id = ?')
|
||||||
.run(jobId !== null ? String(jobId) : '', bookingId);
|
.run(jobId !== null ? String(jobId) : '', bookingId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1206,7 +1320,7 @@ async function startServer() {
|
|||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// CADDY API
|
// CADDY API
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
app.get('/api/caddy/status', requireAuth, async (_req, res) => {
|
app.get('/api/caddy/status', requireAuth, requireAdmin, async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
|
const adminUrl = getSetting('caddy_admin_url') || 'http://127.0.0.1:2019';
|
||||||
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
|
const r = await fetch(`${adminUrl}/config/`, { signal: AbortSignal.timeout(2000) });
|
||||||
@ -1216,7 +1330,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/caddy/routes', requireAuth, (_req, res) => {
|
app.get('/api/caddy/routes', requireAuth, requireAdmin, (_req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(getCaddyRoutes());
|
res.json(getCaddyRoutes());
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -1224,7 +1338,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/caddy/routes', requireAuth, async (req, res) => {
|
app.post('/api/caddy/routes', requireAuth, requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const { hostname, upstream, tls, compress, redirect } = req.body as {
|
const { hostname, upstream, tls, compress, redirect } = req.body as {
|
||||||
@ -1242,7 +1356,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/caddy/routes/:id', requireAuth, async (req, res) => {
|
app.put('/api/caddy/routes/:id', requireAuth, requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
@ -1260,7 +1374,7 @@ async function startServer() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/caddy/routes/:id', requireAuth, (req, res) => {
|
app.delete('/api/caddy/routes/:id', requireAuth, requireAdmin, (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
if (!IS_PRODUCTION) return res.status(403).json({ error: 'Caddy is managed by another instance.' });
|
||||||
const id = Number(req.params.id);
|
const id = Number(req.params.id);
|
||||||
|
|||||||
220
src/App.tsx
220
src/App.tsx
@ -53,8 +53,8 @@ export default function App() {
|
|||||||
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
|
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
|
||||||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
const [cmkEnabled, setCmkEnabled] = useState(false);
|
||||||
const [checkmkBaseUrl, setCheckmkBaseUrl] = useState('');
|
const [cmkBaseUrl, setCmkBaseUrl] = useState('');
|
||||||
const [isProduction, setIsProduction] = useState(false);
|
const [isProduction, setIsProduction] = useState(false);
|
||||||
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||||||
|
|
||||||
@ -145,7 +145,7 @@ export default function App() {
|
|||||||
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
if (bookingsRes.ok) setBookings(await bookingsRes.json());
|
||||||
if (logsRes.ok) setLogs(await logsRes.json());
|
if (logsRes.ok) setLogs(await logsRes.json());
|
||||||
if (linksRes.ok) setLinks(await linksRes.json());
|
if (linksRes.ok) setLinks(await linksRes.json());
|
||||||
if (configRes.ok) { const cfg = await configRes.json(); setCheckmkEnabled(!!cfg.checkmkEnabled); setCheckmkBaseUrl(cfg.checkmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
|
if (configRes.ok) { const cfg = await configRes.json(); setCmkEnabled(!!cfg.cmkEnabled); setCmkBaseUrl(cfg.cmkBaseUrl || ''); setIsProduction(!!cfg.isProduction); setSemaphoreEnabled(!!cfg.semaphoreEnabled); }
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[App] Failed to load data:', err);
|
console.error('[App] Failed to load data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@ -155,23 +155,53 @@ export default function App() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [currentUser]);
|
}, [currentUser]);
|
||||||
|
|
||||||
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
|
// SSE connection: real-time push for all shared data.
|
||||||
// status changes (online/offline) surface without a manual reload. The backend
|
// EventSource does not support Authorization headers, so the JWT is passed
|
||||||
// is the source of truth - it syncs each device's status from the CheckMK API.
|
// as a query parameter. The server sends a full snapshot on every (re)connect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
const refreshDevices = async () => {
|
const token = getToken();
|
||||||
try {
|
if (!token) return;
|
||||||
const res = await authFetch('/api/devices');
|
|
||||||
if (res.ok) setDevices(await res.json());
|
const evtSource = new EventSource(`/api/events?token=${encodeURIComponent(token)}`);
|
||||||
} catch {
|
|
||||||
// transient network/server hiccup - keep last known state, retry next tick
|
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 id = setInterval(refreshDevices, 30_000);
|
|
||||||
return () => clearInterval(id);
|
return () => evtSource.close();
|
||||||
}, [currentUser]);
|
}, [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
|
// Upcoming-booking reminder - checks every 60s, fires once per booking
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUser || bookings.length === 0) return;
|
if (!currentUser || bookings.length === 0) return;
|
||||||
@ -219,10 +249,7 @@ export default function App() {
|
|||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setBookings(prev => [data.booking, ...prev]);
|
|
||||||
if (data.alertGenerated) setNotifications(prev => [data.alertGenerated, ...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); }
|
} catch (err) { console.error('[App] Error adding booking:', err); }
|
||||||
};
|
};
|
||||||
@ -235,111 +262,62 @@ export default function App() {
|
|||||||
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
|
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
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';
|
const labName = booking ? (labs.find(l => l.id === booking.labId)?.name ?? 'Lab') : 'Lab';
|
||||||
setNotifications(prev => [`Booking cancelled: "${labName}" - slot has been released.`, ...prev]);
|
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); }
|
} catch (err) { console.error('[App] Error cancelling booking:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteBooking = async (bookingId: string) => {
|
const handleDeleteBooking = async (bookingId: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
|
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); }
|
} catch (err) { console.error('[App] Error deleting booking:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Device handlers
|
// Device handlers
|
||||||
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
|
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
|
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); }
|
} catch (err) { console.error('[App] Error adding device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateDevice = async (updatedDev: Device) => {
|
const handleUpdateDevice = async (updatedDev: Device) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/devices/${updatedDev.id}`, {
|
await authFetch(`/api/devices/${updatedDev.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ ...updatedDev, operatorName: currentUser!.name }),
|
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); }
|
} catch (err) { console.error('[App] Error updating device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteDevice = async (id: string) => {
|
const handleDeleteDevice = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
|
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); }
|
} catch (err) { console.error('[App] Error deleting device:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lab handlers
|
// Lab handlers
|
||||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
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); }
|
} catch (err) { console.error('[App] Error adding lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateLab = async (updatedLab: LabTemplate) => {
|
const handleUpdateLab = async (updatedLab: LabTemplate) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
|
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); }
|
} catch (err) { console.error('[App] Error updating lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLab = async (id: string) => {
|
const handleDeleteLab = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
|
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); }
|
} catch (err) { console.error('[App] Error deleting lab:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
|
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
|
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); }
|
} catch (err) { console.error('[App] Error adding log:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -347,18 +325,14 @@ export default function App() {
|
|||||||
const handleDeleteUser = async (id: string) => {
|
const handleDeleteUser = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
|
const res = await authFetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
if (res.ok) setUsers(prev => prev.filter(u => u.id !== id));
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
|
||||||
else { const d = await res.json(); throw new Error(d.error); }
|
|
||||||
} catch (err: any) { throw err; }
|
} catch (err: any) { throw err; }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateUser = async (id: string, name: string, email: string) => {
|
const handleUpdateUser = async (id: string, name: string, email: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
|
const res = await authFetch(`/api/users/${id}`, { method: 'PUT', body: JSON.stringify({ name, email }) });
|
||||||
if (res.ok) {
|
if (!res.ok) { const d = await res.json(); throw new Error(d.error); }
|
||||||
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; }
|
} catch (err: any) { throw err; }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -367,7 +341,8 @@ export default function App() {
|
|||||||
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
|
const res = await authFetch(`/api/users/${id}/role`, { method: 'PATCH', body: JSON.stringify({ role }) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const updated: User = await res.json();
|
const updated: User = await res.json();
|
||||||
setUsers(prev => prev.map(u => u.id === id ? updated : u));
|
// Update currentUser immediately if the acting user changed their own role,
|
||||||
|
// since currentUser is not driven by the users SSE event.
|
||||||
if (updated.id === currentUser?.id) setCurrentUser(updated);
|
if (updated.id === currentUser?.id) setCurrentUser(updated);
|
||||||
} else { const d = await res.json(); throw new Error(d.error); }
|
} else { const d = await res.json(); throw new Error(d.error); }
|
||||||
} catch (err: any) { throw err; }
|
} catch (err: any) { throw err; }
|
||||||
@ -376,28 +351,19 @@ export default function App() {
|
|||||||
// Quick-link handlers (shared link dashboard)
|
// Quick-link handlers (shared link dashboard)
|
||||||
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
|
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); }
|
} catch (err) { console.error('[App] Error adding link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateLink = async (updated: QuickLink) => {
|
const handleUpdateLink = async (updated: QuickLink) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
|
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); }
|
} catch (err) { console.error('[App] Error updating link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteLink = async (id: string) => {
|
const handleDeleteLink = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch(`/api/links/${id}`, { method: 'DELETE' });
|
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); }
|
} catch (err) { console.error('[App] Error deleting link:', err); }
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -445,12 +411,12 @@ export default function App() {
|
|||||||
// Startup check not done yet
|
// Startup check not done yet
|
||||||
if (!authChecked) {
|
if (!authChecked) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
|
||||||
<div className="text-center space-y-4">
|
<div className="text-center space-y-4">
|
||||||
<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">
|
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
|
||||||
<GhostGridLogo className="w-16 h-16 animate-pulse" />
|
<GhostGridLogo className="w-16 h-16" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 font-mono">booting...</p>
|
<p className="text-xs text-fg-muted font-mono">booting...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -467,16 +433,16 @@ export default function App() {
|
|||||||
// Loading data after login
|
// Loading data after login
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex flex-col items-center justify-center p-4">
|
||||||
<div className="text-center space-y-6 max-w-sm">
|
<div className="text-center space-y-6 max-w-sm">
|
||||||
<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">
|
<div className="p-4 bg-card border border-line rounded-2xl shadow-lg inline-flex">
|
||||||
<GhostGridLogo className="w-20 h-20 animate-pulse" />
|
<GhostGridLogo className="w-20 h-20" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h2 className="text-base font-bold tracking-tight text-white">GhostGrid Virtualization</h2>
|
<h2 className="text-base font-bold tracking-tight text-fg">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>
|
<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-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">
|
<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-cyan-400 animate-ping"></span>
|
<span className="w-1.5 h-1.5 rounded-full bg-info animate-ping"></span>
|
||||||
SQLITE DATABASE HYDRATION ONGOING
|
SQLITE DATABASE HYDRATION ONGOING
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -486,7 +452,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
|
|
||||||
<Header
|
<Header
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
@ -502,32 +468,32 @@ export default function App() {
|
|||||||
|
|
||||||
<div className="flex-1 flex flex-col md:flex-row">
|
<div className="flex-1 flex flex-col md:flex-row">
|
||||||
<aside
|
<aside
|
||||||
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'}`}
|
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'}`}
|
||||||
id="nav-sidebar"
|
id="nav-sidebar"
|
||||||
>
|
>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{/* Collapse toggle */}
|
{/* Collapse toggle */}
|
||||||
<div className={`flex items-center px-1 ${navCollapsed ? 'md:justify-center justify-between' : 'justify-between'}`}>
|
<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-slate-500 ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
|
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-fg-faint ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setNavCollapsed(c => !c)}
|
onClick={() => setNavCollapsed(c => !c)}
|
||||||
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
|
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
|
||||||
className="p-1.5 rounded-md text-slate-400 hover:text-emerald-400 hover:bg-slate-900 transition-all hover:cursor-pointer"
|
className="p-1.5 rounded-md text-fg-muted hover:text-success hover:bg-inner transition-all hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="space-y-4">
|
<nav className="space-y-4">
|
||||||
{navigationGroups.map((group, gi) => (
|
{navigationGroups.filter(g => g.label !== 'System' || currentUser.role.toLowerCase() === 'admin').map((group, gi) => (
|
||||||
<div key={gi} className="space-y-1">
|
<div key={gi} className="space-y-1">
|
||||||
{group.label && (
|
{group.label && (
|
||||||
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-slate-600 px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
|
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-fg-faint px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
|
||||||
{group.label}
|
{group.label}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Thin divider stands in for the group label when collapsed */}
|
{/* Thin divider stands in for the group label when collapsed */}
|
||||||
{group.label && navCollapsed && <div className="hidden md:block h-px bg-slate-850 mx-2" />}
|
{group.label && navCollapsed && <div className="hidden md:block h-px bg-line mx-2" />}
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const isActive = activeTab === item.id;
|
const isActive = activeTab === item.id;
|
||||||
return (
|
return (
|
||||||
@ -540,8 +506,8 @@ export default function App() {
|
|||||||
title={navCollapsed ? item.label : undefined}
|
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' : ''} ${
|
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
|
isActive
|
||||||
? '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)]'
|
? 'bg-success-soft border-l-2 border-success text-fg'
|
||||||
: 'text-slate-400 hover:text-white hover:bg-slate-900'
|
: 'text-fg-muted hover:text-fg hover:bg-inner'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
@ -554,15 +520,15 @@ export default function App() {
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`bg-slate-950 p-4 border border-slate-900 rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
|
<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-emerald-400 font-mono font-bold">Overall Status</h4>
|
<h4 className="text-[10px] text-success font-mono font-bold">Overall Status</h4>
|
||||||
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
|
<div className="text-[11px] text-fg-muted 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>Active: <span className="text-success 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>Upcoming: <span className="text-primary 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>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-white font-semibold font-mono">{labs.length}</span> configured</div>
|
<div>Labs: <span className="text-fg font-semibold font-mono">{labs.length}</span> configured</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
|
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full mt-2" /></div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
|
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
|
||||||
@ -572,6 +538,7 @@ export default function App() {
|
|||||||
bookings={bookings}
|
bookings={bookings}
|
||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
users={users}
|
||||||
links={links}
|
links={links}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
onDeleteBooking={handleDeleteBooking}
|
onDeleteBooking={handleDeleteBooking}
|
||||||
@ -588,7 +555,8 @@ export default function App() {
|
|||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
currentUser={currentUser}
|
currentUser={currentUser}
|
||||||
checkmkEnabled={checkmkEnabled}
|
users={users}
|
||||||
|
cmkEnabled={cmkEnabled}
|
||||||
onAddBooking={handleAddBooking}
|
onAddBooking={handleAddBooking}
|
||||||
onCancelBooking={handleCancelBooking}
|
onCancelBooking={handleCancelBooking}
|
||||||
onDeleteBooking={handleDeleteBooking}
|
onDeleteBooking={handleDeleteBooking}
|
||||||
@ -598,8 +566,8 @@ export default function App() {
|
|||||||
{activeTab === 'devices' && (
|
{activeTab === 'devices' && (
|
||||||
<DeviceInventory
|
<DeviceInventory
|
||||||
devices={devices}
|
devices={devices}
|
||||||
checkmkEnabled={checkmkEnabled}
|
cmkEnabled={cmkEnabled}
|
||||||
checkmkBaseUrl={checkmkBaseUrl}
|
cmkBaseUrl={cmkBaseUrl}
|
||||||
onAddDevice={handleAddDevice}
|
onAddDevice={handleAddDevice}
|
||||||
onUpdateDevice={handleUpdateDevice}
|
onUpdateDevice={handleUpdateDevice}
|
||||||
onDeleteDevice={handleDeleteDevice}
|
onDeleteDevice={handleDeleteDevice}
|
||||||
@ -645,7 +613,7 @@ export default function App() {
|
|||||||
onAddLog={handleAddLogManually}
|
onAddLog={handleAddLogManually}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'settings' && (
|
{activeTab === 'settings' && currentUser.role.toLowerCase() === 'admin' && (
|
||||||
<Settings currentUser={currentUser} />
|
<Settings currentUser={currentUser} />
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -17,7 +17,8 @@ interface BookingCalendarProps {
|
|||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
currentUser: User;
|
currentUser: User;
|
||||||
checkmkEnabled: boolean;
|
users: User[];
|
||||||
|
cmkEnabled: boolean;
|
||||||
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
|
||||||
onCancelBooking: (id: string) => void;
|
onCancelBooking: (id: string) => void;
|
||||||
onDeleteBooking: (id: string) => void;
|
onDeleteBooking: (id: string) => void;
|
||||||
@ -68,12 +69,19 @@ const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:
|
|||||||
|
|
||||||
// ── component ──────────────────────────────────────────────────────────────
|
// ── 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({
|
export default function BookingCalendar({
|
||||||
bookings,
|
bookings,
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
currentUser,
|
currentUser,
|
||||||
checkmkEnabled,
|
users,
|
||||||
|
cmkEnabled,
|
||||||
onAddBooking,
|
onAddBooking,
|
||||||
onCancelBooking,
|
onCancelBooking,
|
||||||
onDeleteBooking,
|
onDeleteBooking,
|
||||||
@ -229,10 +237,8 @@ export default function BookingCalendar({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleQuickBookDevice = (device: Device) => {
|
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({
|
onAddBooking({
|
||||||
labId: hostLab?.id ?? `device:${device.id}`,
|
labId: `device:${device.id}`,
|
||||||
userId: currentUser.id,
|
userId: currentUser.id,
|
||||||
startDateTime: toLocalISO(quickWindow.start),
|
startDateTime: toLocalISO(quickWindow.start),
|
||||||
endDateTime: toLocalISO(quickWindow.end),
|
endDateTime: toLocalISO(quickWindow.end),
|
||||||
@ -250,23 +256,23 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
{/* ── Quick Booking Modal ── */}
|
{/* ── Quick Booking Modal ── */}
|
||||||
{showQuickPanel && (
|
{showQuickPanel && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
<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-[#0F172A] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
|
<div className="w-full max-w-lg bg-card border border-line-strong rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
|
<div className="flex items-center justify-between px-5 py-4 border-b border-line">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
|
<Zap className="w-4 h-4 text-success fill-success/30" />
|
||||||
<h3 className="font-bold text-sm text-white font-sans">Quick Booking</h3>
|
<h3 className="font-bold text-sm text-fg font-sans">Quick Booking</h3>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowQuickPanel(false)} className="text-slate-400 hover:text-white transition-colors">
|
<button onClick={() => setShowQuickPanel(false)} className="text-fg-muted hover:text-fg transition-colors">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration Selector */}
|
{/* Duration Selector */}
|
||||||
<div className="px-5 pt-4 space-y-1">
|
<div className="px-5 pt-4 space-y-1">
|
||||||
<p className="text-[11px] text-slate-400 font-sans">Duration starting now:</p>
|
<p className="text-[11px] text-fg-muted font-sans">Duration starting now:</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[1, 2, 4, 8].map(h => (
|
{[1, 2, 4, 8].map(h => (
|
||||||
<button
|
<button
|
||||||
@ -275,14 +281,14 @@ export default function BookingCalendar({
|
|||||||
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
|
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
|
||||||
quickDuration === h
|
quickDuration === h
|
||||||
? 'bg-emerald-600 border-emerald-500 text-white'
|
? 'bg-emerald-600 border-emerald-500 text-white'
|
||||||
: 'bg-slate-900 border-slate-800 text-slate-300 hover:border-emerald-700 hover:text-emerald-400'
|
: 'bg-inner border-line text-fg-muted hover:border-success hover:text-success'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{h}h
|
{h}h
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 font-mono">
|
<p className="text-[10px] text-fg-faint font-mono">
|
||||||
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} >{' '}
|
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} >{' '}
|
||||||
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||||
</p>
|
</p>
|
||||||
@ -293,7 +299,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setQuickTab('labs')}
|
onClick={() => setQuickTab('labs')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||||
quickTab === 'labs' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
|
quickTab === 'labs' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
|
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
|
||||||
@ -301,7 +307,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setQuickTab('devices')}
|
onClick={() => setQuickTab('devices')}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
|
||||||
quickTab === 'devices' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
|
quickTab === 'devices' ? 'bg-success-soft text-success border border-success-line' : 'text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
|
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
|
||||||
@ -312,19 +318,19 @@ export default function BookingCalendar({
|
|||||||
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
|
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
|
||||||
{quickTab === 'labs' ? (
|
{quickTab === 'labs' ? (
|
||||||
availableLabs.length === 0 ? (
|
availableLabs.length === 0 ? (
|
||||||
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
|
<p className="text-xs text-fg-muted text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
|
||||||
) : (
|
) : (
|
||||||
availableLabs.map(lab => {
|
availableLabs.map(lab => {
|
||||||
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
|
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
|
||||||
const offlineCount = labDevices.filter(d => !isOnline(d)).length;
|
const offlineCount = labDevices.filter(d => !isOnline(d)).length;
|
||||||
return (
|
return (
|
||||||
<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 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 className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
|
<p className="text-xs font-bold text-fg 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-[10px] text-fg-muted 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>
|
<p className="text-[9px] text-fg-faint truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
|
||||||
{offlineCount > 0 && (
|
{offlineCount > 0 && (
|
||||||
<p className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono mt-0.5">
|
<p className="flex items-center gap-0.5 text-[9px] text-warning font-mono mt-0.5">
|
||||||
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
|
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -346,20 +352,20 @@ export default function BookingCalendar({
|
|||||||
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
|
||||||
return (
|
return (
|
||||||
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
|
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
|
||||||
free ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
|
free ? 'bg-inner border-line hover:border-success-line' : 'bg-surface border-line opacity-60'
|
||||||
}`}>
|
}`}>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-emerald-400' : status === 'offline' ? 'bg-rose-400' : 'bg-slate-500'}`} />
|
<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-white font-mono">{device.hostname}</p>
|
<p className="text-xs font-bold text-fg font-mono">{device.hostname}</p>
|
||||||
{!online && free && (
|
{!online && free && (
|
||||||
<span className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono" title="Not reachable in CheckMK">
|
<span className="flex items-center gap-0.5 text-[9px] text-warning font-mono" title="Not reachable in CheckMK">
|
||||||
<AlertTriangle className="w-2.5 h-2.5" />{status}
|
<AlertTriangle className="w-2.5 h-2.5" />{status}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
|
<p className="text-[10px] text-fg-muted font-mono">{device.type} · {device.ip}</p>
|
||||||
<p className="text-[9px] text-slate-500">{device.location}</p>
|
<p className="text-[9px] text-fg-faint">{device.location}</p>
|
||||||
</div>
|
</div>
|
||||||
{free ? (
|
{free ? (
|
||||||
<button
|
<button
|
||||||
@ -369,7 +375,7 @@ export default function BookingCalendar({
|
|||||||
Book
|
Book
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
|
<span className="shrink-0 text-[10px] text-rose font-mono font-semibold">Busy</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -381,32 +387,32 @@ export default function BookingCalendar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── LEFT: Visual Schedule Grid ── */}
|
{/* ── LEFT: Visual Schedule Grid ── */}
|
||||||
<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="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="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<Calendar className="text-emerald-400 w-5 h-5" />
|
<Calendar className="text-success w-5 h-5" />
|
||||||
Bookings
|
Bookings
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Who has which box, and until when. mutex for hardware, basically.</p>
|
<p className="text-xs text-fg-muted">Who has which box, and until when. mutex for hardware, basically.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Day navigation */}
|
{/* Day navigation */}
|
||||||
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-805 shrink-0">
|
<div className="flex items-center gap-1 bg-inner p-1 rounded-lg border border-line shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDayOffset(dayOffset - 1)}
|
onClick={() => setDayOffset(dayOffset - 1)}
|
||||||
disabled={dayOffset <= -30}
|
disabled={dayOffset <= -30}
|
||||||
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"
|
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded disabled:opacity-30 transition-opacity"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-3.5 h-3.5" />
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-xs font-semibold px-2.5 text-center text-slate-200 min-w-[130px] font-mono select-none">
|
<div className="text-xs font-semibold px-2.5 text-center text-fg min-w-[130px] font-mono select-none">
|
||||||
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
|
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDayOffset(dayOffset + 1)}
|
onClick={() => setDayOffset(dayOffset + 1)}
|
||||||
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded transition-opacity"
|
className="p-1 px-1.5 bg-card border border-line text-fg-muted hover:text-fg rounded transition-opacity"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -414,32 +420,32 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Matrix Grid */}
|
{/* Matrix Grid */}
|
||||||
<div className="flex-1 overflow-x-auto rounded-lg border border-slate-850 p-1 bg-slate-950/40">
|
<div className="flex-1 overflow-x-auto rounded-lg border border-line p-1 bg-inner">
|
||||||
<div style={{ minWidth: '860px' }}>
|
<div style={{ minWidth: '860px' }}>
|
||||||
{/* Header row */}
|
{/* Header row */}
|
||||||
<div
|
<div
|
||||||
className="border-b border-slate-800 pb-1"
|
className="border-b border-line pb-1"
|
||||||
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
||||||
>
|
>
|
||||||
<div className="text-left pl-3 text-[10px] text-slate-400 font-sans font-bold self-center">Device</div>
|
<div className="text-left pl-3 text-[10px] text-fg-muted font-sans font-bold self-center">Device</div>
|
||||||
{TIME_SLOTS.map((slot, i) => (
|
{TIME_SLOTS.map((slot, i) => (
|
||||||
<div key={i} className="text-center py-1 border-l border-slate-855">
|
<div key={i} className="text-center py-1 border-l border-line">
|
||||||
<span className="text-[9px] font-mono text-slate-300 leading-none">{slot.label}</span>
|
<span className="text-[9px] font-mono text-fg-muted leading-none">{slot.label}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Device rows */}
|
{/* Device rows */}
|
||||||
<div className="divide-y divide-slate-850 max-h-[460px] overflow-y-auto">
|
<div className="divide-y divide-line max-h-[460px] overflow-y-auto">
|
||||||
{devices.map((device) => (
|
{devices.map((device) => (
|
||||||
<div
|
<div
|
||||||
key={device.id}
|
key={device.id}
|
||||||
className="items-center group hover:bg-slate-900/35"
|
className="items-center group hover:bg-card"
|
||||||
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
|
||||||
>
|
>
|
||||||
<div className="pl-3 py-2 text-left">
|
<div className="pl-3 py-2 text-left">
|
||||||
<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="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-slate-500 mt-0.5 leading-none">{device.type}</p>
|
<p className="text-[9px] font-mono text-fg-faint mt-0.5 leading-none">{device.type}</p>
|
||||||
</div>
|
</div>
|
||||||
{TIME_SLOTS.map((slot, sIdx) => {
|
{TIME_SLOTS.map((slot, sIdx) => {
|
||||||
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
|
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
|
||||||
@ -448,8 +454,8 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
if (!cur) {
|
if (!cur) {
|
||||||
return (
|
return (
|
||||||
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-slate-850">
|
<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-slate-800/40 hover:border-slate-700/60 transition-all" />
|
<div className="w-full h-full rounded border border-dashed border-line hover:border-line-strong transition-all" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -464,24 +470,31 @@ export default function BookingCalendar({
|
|||||||
: isLast ? 'rounded-r'
|
: isLast ? 'rounded-r'
|
||||||
: '';
|
: '';
|
||||||
const borderCls = isMe
|
const borderCls = isMe
|
||||||
? `bg-emerald-500/30 border-emerald-500/60 hover:bg-emerald-500/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`
|
? `bg-success/30 border-success/60 hover:bg-success/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`
|
||||||
: `bg-indigo-500/25 border-indigo-500/50 hover:bg-indigo-500/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
|
: `bg-primary/25 border-primary/50 hover:bg-primary/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={sIdx}
|
key={sIdx}
|
||||||
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-slate-850`}
|
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-line`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => onSelectBookingDetails(cur)}
|
onClick={() => onSelectBookingDetails(cur)}
|
||||||
title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`}
|
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}`}
|
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
|
||||||
>
|
>
|
||||||
{isFirst && (
|
{isFirst && (() => {
|
||||||
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
|
const booker = users.find(u => u.id === cur.userId);
|
||||||
{lab?.name ?? 'Device'}
|
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>
|
</span>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -493,11 +506,11 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<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="mt-4 pt-4 border-t border-line flex items-center justify-between text-[11px] font-sans text-fg-muted">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<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-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-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 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-slate-800/40" /> Available</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>
|
||||||
</div>
|
</div>
|
||||||
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
|
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
|
||||||
</div>
|
</div>
|
||||||
@ -507,13 +520,13 @@ export default function BookingCalendar({
|
|||||||
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
|
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
|
||||||
|
|
||||||
{/* Quick Booking Trigger */}
|
{/* Quick Booking Trigger */}
|
||||||
<div className="bg-[#1D2535] border border-emerald-900/30 rounded-xl p-5 shadow-sm relative overflow-hidden">
|
<div className="bg-success-soft border border-success-line 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" />
|
<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-slate-100 flex items-center gap-1.5 mb-1.5 font-sans">
|
<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-emerald-400 fill-emerald-500/30" />
|
<Zap className="w-4 h-4 text-success fill-success/30" />
|
||||||
Quick Booking
|
Quick Booking
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed font-sans mb-4">
|
<p className="text-[11px] text-fg-muted 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.
|
Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@ -522,7 +535,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
key={h}
|
key={h}
|
||||||
onClick={() => { setQuickDuration(h); setShowQuickPanel(true); }}
|
onClick={() => { setQuickDuration(h); setShowQuickPanel(true); }}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{h}h
|
{h}h
|
||||||
</button>
|
</button>
|
||||||
@ -531,37 +544,37 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowQuickPanel(true)}
|
onClick={() => setShowQuickPanel(true)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Clock className="w-3.5 h-3.5" />
|
<Clock className="w-3.5 h-3.5" />
|
||||||
Show Available Now
|
Show Available Now
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="mt-3 flex items-center gap-3 text-[10px] text-slate-500 font-mono">
|
<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-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-success" />{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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Standard Booking Form */}
|
{/* Standard Booking Form */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line rounded-xl p-5 shadow-sm font-sans">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3">
|
||||||
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
|
<Calendar className="w-4.5 h-4.5 text-primary" />
|
||||||
Reserve Slot
|
Reserve Slot
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
|
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
|
||||||
{/* Resource type toggle: whole lab topology or a single device */}
|
{/* Resource type toggle: whole lab topology or a single device */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Reserve</label>
|
<label className="block text-fg-muted font-semibold mb-1">Reserve</label>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setResourceType('lab')}
|
onClick={() => setResourceType('lab')}
|
||||||
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
||||||
resourceType === 'lab'
|
resourceType === 'lab'
|
||||||
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
|
? 'bg-primary-soft border-primary text-primary'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Layers className="w-3.5 h-3.5" /> Topology
|
<Layers className="w-3.5 h-3.5" /> Topology
|
||||||
@ -571,8 +584,8 @@ export default function BookingCalendar({
|
|||||||
onClick={() => setResourceType('device')}
|
onClick={() => setResourceType('device')}
|
||||||
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
|
||||||
resourceType === 'device'
|
resourceType === 'device'
|
||||||
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
|
? 'bg-primary-soft border-primary text-primary'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Server className="w-3.5 h-3.5" /> Single Device
|
<Server className="w-3.5 h-3.5" /> Single Device
|
||||||
@ -582,11 +595,11 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
{resourceType === 'lab' ? (
|
{resourceType === 'lab' ? (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Topology</label>
|
<label className="block text-fg-muted font-semibold mb-1">Topology</label>
|
||||||
<select
|
<select
|
||||||
value={selectedLabId}
|
value={selectedLabId}
|
||||||
onChange={(e) => setSelectedLabId(e.target.value)}
|
onChange={(e) => setSelectedLabId(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
|
||||||
>
|
>
|
||||||
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
||||||
<optgroup label="Global Topologies">
|
<optgroup label="Global Topologies">
|
||||||
@ -606,11 +619,11 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Device</label>
|
<label className="block text-fg-muted font-semibold mb-1">Device</label>
|
||||||
<select
|
<select
|
||||||
value={selectedDeviceId}
|
value={selectedDeviceId}
|
||||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
<option key={d.id} value={d.id}>
|
<option key={d.id} value={d.id}>
|
||||||
@ -623,7 +636,7 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Start Date</label>
|
<label className="block text-fg-muted font-semibold mb-1">Start Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={startDate}
|
value={startDate}
|
||||||
@ -636,38 +649,38 @@ export default function BookingCalendar({
|
|||||||
setDayOffset(Math.round((sel - today) / 86_400_000));
|
setDayOffset(Math.round((sel - today) / 86_400_000));
|
||||||
if (e.target.value > endDate) setEndDate(e.target.value);
|
if (e.target.value > endDate) setEndDate(e.target.value);
|
||||||
}}
|
}}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">End Date</label>
|
<label className="block text-fg-muted font-semibold mb-1">End Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={endDate}
|
value={endDate}
|
||||||
min={startDate}
|
min={startDate}
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
onChange={(e) => setEndDate(e.target.value)}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Start</label>
|
<label className="block text-fg-muted font-semibold mb-1">Start</label>
|
||||||
<select
|
<select
|
||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={(e) => setStartTime(e.target.value)}
|
onChange={(e) => setStartTime(e.target.value)}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
|
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">End</label>
|
<label className="block text-fg-muted font-semibold mb-1">End</label>
|
||||||
<select
|
<select
|
||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={(e) => setEndTime(e.target.value)}
|
onChange={(e) => setEndTime(e.target.value)}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary font-mono"
|
||||||
>
|
>
|
||||||
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
|
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
</select>
|
</select>
|
||||||
@ -675,13 +688,13 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Notes / Objective</label>
|
<label className="block text-fg-muted font-semibold mb-1">Notes / Objective</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={3}
|
rows={3}
|
||||||
value={bookingNotes}
|
value={bookingNotes}
|
||||||
onChange={(e) => setBookingNotes(e.target.value)}
|
onChange={(e) => setBookingNotes(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-primary"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -692,16 +705,16 @@ export default function BookingCalendar({
|
|||||||
|
|
||||||
if (conflict.hasConflict) {
|
if (conflict.hasConflict) {
|
||||||
return (
|
return (
|
||||||
<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">
|
<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-400 shrink-0" />
|
<AlertCircle className="w-4 h-4 text-rose shrink-0" />
|
||||||
<span>{conflict.message}</span>
|
<span>{conflict.message}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (checkmkEnabled && offline.length > 0) {
|
if (cmkEnabled && offline.length > 0) {
|
||||||
return (
|
return (
|
||||||
<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">
|
<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-amber-400 shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-warning shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
Warning – {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
|
Warning – {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
|
||||||
</span>
|
</span>
|
||||||
@ -709,8 +722,8 @@ export default function BookingCalendar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<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">
|
<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-emerald-400 shrink-0" />
|
<CheckCircle2 className="w-4 h-4 text-success shrink-0" />
|
||||||
<span>Timeframe is available.</span>
|
<span>Timeframe is available.</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -724,7 +737,7 @@ export default function BookingCalendar({
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Confirm Reservation
|
Confirm Reservation
|
||||||
</button>
|
</button>
|
||||||
@ -735,7 +748,7 @@ export default function BookingCalendar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Reservation Table ── */}
|
{/* ── Reservation Table ── */}
|
||||||
<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">
|
<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">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowReservations(s => !s)}
|
onClick={() => setShowReservations(s => !s)}
|
||||||
@ -743,26 +756,26 @@ export default function BookingCalendar({
|
|||||||
aria-expanded={showReservations}
|
aria-expanded={showReservations}
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${showReservations ? '' : '-rotate-90'}`} />
|
<ChevronDown className={`w-4 h-4 text-fg-muted transition-transform ${showReservations ? '' : '-rotate-90'}`} />
|
||||||
<Database className="w-4 h-4 text-emerald-400" />
|
<Database className="w-4 h-4 text-success" />
|
||||||
Reservations
|
Reservations
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-slate-400 pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
|
<p className="text-xs text-fg-muted pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] bg-slate-950 px-2.5 py-1 rounded font-mono font-bold text-slate-300 border border-slate-800">
|
<span className="text-[10px] bg-inner px-2.5 py-1 rounded font-mono font-bold text-fg-muted border border-line">
|
||||||
DATABASE SELECT: {bookings.length} RECORDS
|
DATABASE SELECT: {bookings.length} RECORDS
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!showReservations ? null : bookings.length === 0 ? (
|
{!showReservations ? null : bookings.length === 0 ? (
|
||||||
<p className="mt-4 text-slate-500 text-xs text-center py-6 italic border border-dashed border-slate-800 rounded-lg">
|
<p className="mt-4 text-fg-faint text-xs text-center py-6 italic border border-dashed border-line rounded-lg">
|
||||||
No active reservation structures currently exist inside the database.
|
No active reservation structures currently exist inside the database.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/10">
|
<div className="mt-4 overflow-x-auto rounded-lg border border-line bg-inner">
|
||||||
<table className="w-full text-xs text-left text-slate-300 divide-y divide-slate-800">
|
<table className="w-full text-xs text-left text-fg-muted divide-y divide-line">
|
||||||
<thead className="bg-[#0f172a]/60 text-slate-400 font-mono text-[10px] uppercase tracking-wider">
|
<thead className="bg-inner text-fg-muted font-mono text-[10px] uppercase tracking-wider">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">ID</th>
|
<th className="px-4 py-3">ID</th>
|
||||||
<th className="px-4 py-3">Topology / Resource</th>
|
<th className="px-4 py-3">Topology / Resource</th>
|
||||||
@ -772,7 +785,7 @@ export default function BookingCalendar({
|
|||||||
<th className="px-4 py-3 text-right font-sans">Actions</th>
|
<th className="px-4 py-3 text-right font-sans">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-800 bg-slate-950/10 font-sans">
|
<tbody className="divide-y divide-line bg-card font-sans">
|
||||||
{bookings.map((b) => {
|
{bookings.map((b) => {
|
||||||
const lab = labs.find(l => l.id === b.labId);
|
const lab = labs.find(l => l.id === b.labId);
|
||||||
const isDeviceBooking = b.labId?.startsWith('device:');
|
const isDeviceBooking = b.labId?.startsWith('device:');
|
||||||
@ -782,35 +795,35 @@ export default function BookingCalendar({
|
|||||||
const tStart = new Date(b.startDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
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' });
|
const tEnd = new Date(b.endDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
return (
|
return (
|
||||||
<tr key={b.id} className="hover:bg-slate-900/40 transition">
|
<tr key={b.id} className="hover:bg-inner 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 font-mono font-bold text-success">#{b.id.slice(-8)}</td>
|
||||||
<td className="px-4 py-3.5">
|
<td className="px-4 py-3.5">
|
||||||
<span className="text-slate-100 font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
|
<span className="text-fg 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>
|
<span className="text-[10px] text-fg-muted font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5 font-mono">
|
<td className="px-4 py-3.5 font-mono">
|
||||||
<span className="block text-slate-200">{day}</span>
|
<span className="block text-fg">{day}</span>
|
||||||
<span className="text-[10px] text-slate-400">{tStart} - {tEnd}</span>
|
<span className="text-[10px] text-fg-muted">{tStart} - {tEnd}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3.5">
|
<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 ${
|
<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-emerald-950/60 border-emerald-500/30 text-emerald-400' :
|
b.status === 'active' ? 'bg-success-soft border-success-line text-success' :
|
||||||
b.status === 'upcoming' ? 'bg-indigo-950/60 border-indigo-500/30 text-indigo-400' :
|
b.status === 'upcoming' ? 'bg-primary-soft border-primary-line text-primary' :
|
||||||
b.status === 'completed' ? 'bg-slate-900 border-slate-800 text-slate-400' :
|
b.status === 'completed' ? 'bg-inner border-line text-fg-muted' :
|
||||||
'bg-rose-950/60 border-rose-500/20 text-rose-400 font-bold'
|
'bg-rose-soft border-rose-line text-rose font-bold'
|
||||||
}`}>{b.status}</span>
|
}`}>{b.status}</span>
|
||||||
</td>
|
</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-fg-muted max-w-[150px] truncate">{b.notes || '-'}</td>
|
||||||
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
|
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(b)}
|
onClick={() => onSelectBookingDetails(b)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
|
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
|
||||||
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"
|
className="px-2.5 py-1.5 text-[11px] bg-rose-soft hover:opacity-80 text-rose rounded transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -47,10 +47,10 @@ export default function BookingDetailsModal({
|
|||||||
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
|
const [localTeardownTriggered, setLocalTeardownTriggered] = useState(false);
|
||||||
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
|
const [localTeardownJobId, setLocalTeardownJobId] = useState('');
|
||||||
|
|
||||||
const setupTriggered = booking.ansibleSetupTriggered || localSetupTriggered;
|
const setupTriggered = booking.semaphoreSetupTriggered || localSetupTriggered;
|
||||||
const setupJobId = booking.ansibleSetupJobId || localSetupJobId;
|
const setupJobId = booking.semaphoreSetupJobId || localSetupJobId;
|
||||||
const teardownTriggered = booking.ansibleTeardownTriggered || localTeardownTriggered;
|
const teardownTriggered = booking.semaphoreTeardownTriggered || localTeardownTriggered;
|
||||||
const teardownJobId = booking.ansibleTeardownJobId || localTeardownJobId;
|
const teardownJobId = booking.semaphoreTeardownJobId || localTeardownJobId;
|
||||||
|
|
||||||
async function manualTrigger(type: 'setup' | 'teardown') {
|
async function manualTrigger(type: 'setup' | 'teardown') {
|
||||||
setTriggering(true);
|
setTriggering(true);
|
||||||
@ -118,26 +118,26 @@ export default function BookingDetailsModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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="fixed inset-0 bg-overlay 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]">
|
<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]">
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<div className="bg-slate-900 px-6 py-4 border-b border-slate-800 flex items-center justify-between font-sans">
|
<div className="bg-inner px-6 py-4 border-b border-line flex items-center justify-between font-sans">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-1.5 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400">
|
<div className="p-1.5 bg-success-soft border border-success-line rounded-lg text-success">
|
||||||
<HardDrive className="w-5 h-5" />
|
<HardDrive className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-bold text-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<span>Reservation Details</span>
|
<span>Reservation Details</span>
|
||||||
<span className="text-slate-500 font-mono font-normal">#{booking.id}</span>
|
<span className="text-fg-faint font-mono font-normal">#{booking.id}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400">Inspect allocation status and diagnostic automation APIs</p>
|
<p className="text-[11px] text-fg-muted">Inspect allocation status and diagnostic automation APIs</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded transition-colors"
|
className="text-fg-muted hover:text-fg p-1 hover:bg-inner rounded transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
@ -150,10 +150,10 @@ export default function BookingDetailsModal({
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||||
|
|
||||||
{/* Left Box: Meta stats block */}
|
{/* Left Box: Meta stats block */}
|
||||||
<div className="md:col-span-5 bg-slate-950/60 rounded-xl p-4 border border-slate-850 space-y-4">
|
<div className="md:col-span-5 bg-inner rounded-xl p-4 border border-line space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-slate-500">Scheduled Blueprint</span>
|
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-fg-faint">Scheduled Blueprint</span>
|
||||||
<h4 className="text-base font-bold text-white mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
|
<h4 className="text-base font-bold text-fg 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"
|
<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={{
|
style={{
|
||||||
backgroundColor:
|
backgroundColor:
|
||||||
@ -180,35 +180,35 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
{/* Time blocks */}
|
{/* Time blocks */}
|
||||||
<div className="space-y-2.5 font-sans">
|
<div className="space-y-2.5 font-sans">
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<Calendar className="w-4.5 h-4.5 text-emerald-400 shrink-0 mt-0.5" />
|
<Calendar className="w-4.5 h-4.5 text-success shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Start Time</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Start Time</span>
|
||||||
<span className="font-mono text-slate-200">{startFormatted}</span>
|
<span className="font-mono text-fg">{startFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<Clock className="w-4.5 h-4.5 text-indigo-400 shrink-0 mt-0.5" />
|
<Clock className="w-4.5 h-4.5 text-primary shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Terminations On</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Terminations On</span>
|
||||||
<span className="font-mono text-slate-200">{endFormatted}</span>
|
<span className="font-mono text-fg">{endFormatted}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2.5 text-xs text-slate-300">
|
<div className="flex gap-2.5 text-xs text-fg-muted">
|
||||||
<UserIcon className="w-4.5 h-4.5 text-slate-500 shrink-0 mt-0.5" />
|
<UserIcon className="w-4.5 h-4.5 text-fg-faint shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Reserved Operator</span>
|
<span className="font-semibold block text-fg-muted text-[10px] uppercase tracking-wider">Reserved Operator</span>
|
||||||
<span className="text-slate-200">{creator.name}</span>
|
<span className="text-fg">{creator.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Operator Notes */}
|
{/* Operator Notes */}
|
||||||
<div className="pt-3 border-t border-slate-850/80 font-sans text-xs">
|
<div className="pt-3 border-t border-line font-sans text-xs">
|
||||||
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
|
<span className="font-semibold block text-fg-muted 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">
|
<p className="text-fg-muted leading-relaxed italic bg-inner border border-line p-2.5 rounded">
|
||||||
"{booking.notes || 'No objectives specified.'}"
|
"{booking.notes || 'No objectives specified.'}"
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -216,27 +216,27 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Box: Allocated Device checklist */}
|
{/* Right Box: Allocated Device checklist */}
|
||||||
<div className="md:col-span-7 bg-slate-900/35 border border-slate-800 rounded-xl p-4 flex flex-col justify-between">
|
<div className="md:col-span-7 bg-inner border border-line rounded-xl p-4 flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-center mb-3">
|
<div className="flex justify-between items-center mb-3">
|
||||||
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-emerald-400">Allocated Nodes Pool ({mappedDevices.length})</span>
|
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-success">Allocated Nodes Pool ({mappedDevices.length})</span>
|
||||||
<span className="text-[10px] text-slate-500 font-mono">Location: {lab?.location}</span>
|
<span className="text-[10px] text-fg-faint font-mono">Location: {lab?.location}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mappedDevices.length === 0 ? (
|
{mappedDevices.length === 0 ? (
|
||||||
<p className="text-xs text-slate-400 italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
|
<p className="text-xs text-fg-muted 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">
|
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
|
||||||
{mappedDevices.map((device) => (
|
{mappedDevices.map((device) => (
|
||||||
<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 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 className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
|
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
|
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
|
||||||
<p className="text-[9px] text-slate-400 mt-1 font-mono leading-none">{device.type} • {device.location}</p>
|
<p className="text-[9px] text-fg-muted mt-1 font-mono leading-none">{device.type} • {device.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-mono font-bold text-emerald-400">{device.ip}</span>
|
<span className="text-xs font-mono font-bold text-success">{device.ip}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -244,8 +244,8 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Notice */}
|
{/* Notice */}
|
||||||
<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">
|
<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-emerald-400 shrink-0 mt-0.5" />
|
<HelpCircle className="w-4 h-4 text-success 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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@ -254,27 +254,27 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
{/* Ansible Semaphore automation status */}
|
{/* Ansible Semaphore automation status */}
|
||||||
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
|
{(lab?.semaphoreSetupTemplateId || lab?.semaphoreTeardownTemplateId) && (
|
||||||
<div className="border border-orange-900/40 rounded-xl bg-orange-950/10 p-4 font-sans">
|
<div className="border border-orange-line rounded-xl bg-orange-soft p-4 font-sans">
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<Terminal className="w-4 h-4 text-orange-400" />
|
<Terminal className="w-4 h-4 text-orange" />
|
||||||
<span className="text-[10px] uppercase tracking-wider font-bold text-orange-400">Ansible Automation</span>
|
<span className="text-[10px] uppercase tracking-wider font-bold text-orange">Ansible Automation</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
{lab.semaphoreSetupTemplateId && (
|
{lab.semaphoreSetupTemplateId && (
|
||||||
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
|
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
|
||||||
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Setup</p>
|
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Setup</p>
|
||||||
{setupTriggered ? (
|
{setupTriggered ? (
|
||||||
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
|
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
|
{setupJobId ? `Job #${setupJobId}` : 'Triggered'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500 font-mono">Pending</span>
|
<span className="text-xs text-fg-faint font-mono">Pending</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => manualTrigger('setup')}
|
onClick={() => manualTrigger('setup')}
|
||||||
disabled={triggering || booking.status === 'cancelled'}
|
disabled={triggering || booking.status === 'cancelled'}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Play className="w-2.5 h-2.5" /> Trigger now
|
<Play className="w-2.5 h-2.5" /> Trigger now
|
||||||
</button>
|
</button>
|
||||||
@ -283,20 +283,20 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lab.semaphoreTeardownTemplateId && (
|
{lab.semaphoreTeardownTemplateId && (
|
||||||
<div className="bg-slate-950/60 border border-slate-800 rounded-lg p-3 space-y-2">
|
<div className="bg-inner border border-line rounded-lg p-3 space-y-2">
|
||||||
<p className="text-[10px] text-slate-400 uppercase tracking-wide font-semibold">Teardown</p>
|
<p className="text-[10px] text-fg-muted uppercase tracking-wide font-semibold">Teardown</p>
|
||||||
{teardownTriggered ? (
|
{teardownTriggered ? (
|
||||||
<div className="flex items-center gap-1.5 text-emerald-400 text-xs font-mono">
|
<div className="flex items-center gap-1.5 text-success text-xs font-mono">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
|
{teardownJobId ? `Job #${teardownJobId}` : 'Triggered'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs text-slate-500 font-mono">Pending</span>
|
<span className="text-xs text-fg-faint font-mono">Pending</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => manualTrigger('teardown')}
|
onClick={() => manualTrigger('teardown')}
|
||||||
disabled={triggering || booking.status === 'cancelled'}
|
disabled={triggering || booking.status === 'cancelled'}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Play className="w-2.5 h-2.5" /> Trigger now
|
<Play className="w-2.5 h-2.5" /> Trigger now
|
||||||
</button>
|
</button>
|
||||||
@ -306,7 +306,7 @@ export default function BookingDetailsModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{triggerStatus && (
|
{triggerStatus && (
|
||||||
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
|
<p className={`mt-2 text-[11px] font-mono ${triggerStatus.startsWith('Error') ? 'text-danger' : 'text-success'}`}>
|
||||||
{triggerStatus}
|
{triggerStatus}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -339,10 +339,11 @@ export default function BookingDetailsModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modal Footer */}
|
{/* Modal Footer */}
|
||||||
<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="bg-inner px-6 py-4 border-t border-line flex justify-between items-center font-sans gap-3 flex-wrap">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|
||||||
{/* Delete button option */}
|
{/* Delete button option — admin only */}
|
||||||
|
{currentUser.role.toLowerCase() === 'admin' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
|
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
|
||||||
@ -350,11 +351,12 @@ export default function BookingDetailsModal({
|
|||||||
onClose();
|
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"
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
<span>Purge Entry (SQLite DELETE)</span>
|
<span>Purge Entry (SQLite DELETE)</span>
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Cancel Status Toggle */}
|
{/* Cancel Status Toggle */}
|
||||||
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
|
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
|
||||||
@ -365,7 +367,7 @@ export default function BookingDetailsModal({
|
|||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Ban className="w-3.5 h-3.5" />
|
<Ban className="w-3.5 h-3.5" />
|
||||||
<span>Cancel Reservation</span>
|
<span>Cancel Reservation</span>
|
||||||
@ -376,7 +378,7 @@ export default function BookingDetailsModal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Acknowledge Specs
|
Acknowledge Specs
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -15,6 +15,7 @@ interface DashboardProps {
|
|||||||
bookings: Booking[];
|
bookings: Booking[];
|
||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
|
users: User[];
|
||||||
links: QuickLink[];
|
links: QuickLink[];
|
||||||
onCancelBooking: (id: string) => void;
|
onCancelBooking: (id: string) => void;
|
||||||
onDeleteBooking: (id: string) => void;
|
onDeleteBooking: (id: string) => void;
|
||||||
@ -26,8 +27,8 @@ interface DashboardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LINK_ACCENT: Record<string, string> = {
|
const LINK_ACCENT: Record<string, string> = {
|
||||||
emerald: 'text-emerald-400', cyan: 'text-cyan-400', indigo: 'text-indigo-400',
|
emerald: 'text-success', cyan: 'text-info', indigo: 'text-primary',
|
||||||
amber: 'text-amber-400', rose: 'text-rose-400', violet: 'text-violet-400',
|
amber: 'text-warning', rose: 'text-rose', violet: 'text-violet',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({
|
||||||
@ -35,6 +36,7 @@ export default function Dashboard({
|
|||||||
bookings,
|
bookings,
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
|
users,
|
||||||
links,
|
links,
|
||||||
onCancelBooking,
|
onCancelBooking,
|
||||||
onDeleteBooking,
|
onDeleteBooking,
|
||||||
@ -53,14 +55,15 @@ export default function Dashboard({
|
|||||||
|
|
||||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||||
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
|
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
|
||||||
// "Active" = currently running, plus a 1h grace window after the end so
|
// "Active" = currently running across ALL users, plus a 1h grace window after
|
||||||
// freshly-finished sessions linger briefly instead of jumping to "Expired".
|
// the end so freshly-finished sessions linger briefly instead of jumping to "Expired".
|
||||||
const activeBookings = personalBookings.filter(b => {
|
const activeBookings = bookings.filter(b => {
|
||||||
|
if (b.status === 'cancelled') return false;
|
||||||
const start = new Date(b.startDateTime).getTime();
|
const start = new Date(b.startDateTime).getTime();
|
||||||
const end = new Date(b.endDateTime).getTime();
|
const end = new Date(b.endDateTime).getTime();
|
||||||
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
|
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
|
||||||
});
|
});
|
||||||
const upcomingBookings = personalBookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
|
const upcomingBookings = bookings.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!
|
// 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)
|
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
|
||||||
@ -92,26 +95,26 @@ export default function Dashboard({
|
|||||||
<div className="space-y-6" id="dashboard-cockpit-root">
|
<div className="space-y-6" id="dashboard-cockpit-root">
|
||||||
|
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<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="bg-card border border-line rounded-2xl p-6 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="text-xl font-bold tracking-tight text-white font-sans">
|
<h2 className="text-xl font-bold tracking-tight text-fg font-sans">
|
||||||
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>
|
Welcome back, <span className="text-success">{currentUser.name}</span>
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400 font-sans">
|
<p className="text-xs text-fg-muted font-sans">
|
||||||
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
|
Your lab management hub. Reserve hardware, track active sessions, and keep runbooks close.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3 shrink-0">
|
<div className="flex items-center gap-3 shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToCalendar}
|
onClick={onNavigateToCalendar}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Zap className="w-4 h-4 text-slate-950 fill-slate-950" />
|
<Zap className="w-4 h-4" />
|
||||||
Book Your Lab
|
Book Your Lab
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToDevices}
|
onClick={onNavigateToDevices}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Browse Inventory
|
Browse Inventory
|
||||||
</button>
|
</button>
|
||||||
@ -125,24 +128,25 @@ export default function Dashboard({
|
|||||||
<div className="lg:col-span-8 space-y-6">
|
<div className="lg:col-span-8 space-y-6">
|
||||||
|
|
||||||
{/* Active Sessions */}
|
{/* Active Sessions */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
|
<div className="bg-card border border-line 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">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-4 font-sans justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4 text-emerald-400" />
|
<Clock className="w-4 h-4 text-success" />
|
||||||
Active Reservations
|
Active Reservations
|
||||||
</span>
|
</span>
|
||||||
<span className="w-2 h-2 rounded-full bg-emerald-500 shrink-0" />
|
<span className="w-2 h-2 rounded-full bg-success shrink-0" />
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{activeBookings.length === 0 ? (
|
{activeBookings.length === 0 ? (
|
||||||
<div className="text-center py-8 bg-slate-900/35 rounded-lg border border-slate-800 font-sans">
|
<div className="text-center py-8 bg-inner rounded-lg border border-line font-sans">
|
||||||
<PlayCircle className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
<PlayCircle className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-xs text-slate-400">No active sessions.</p>
|
<p className="text-xs text-fg-muted">No active sessions.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 font-sans">
|
<div className="space-y-4 font-sans">
|
||||||
{activeBookings.map((booking) => {
|
{activeBookings.map((booking) => {
|
||||||
const lab = labs.find(l => l.id === booking.labId);
|
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 startDate = new Date(booking.startDateTime);
|
||||||
const endDate = new Date(booking.endDateTime);
|
const endDate = new Date(booking.endDateTime);
|
||||||
const sameDay = startDate.toDateString() === endDate.toDateString();
|
const sameDay = startDate.toDateString() === endDate.toDateString();
|
||||||
@ -153,43 +157,48 @@ export default function Dashboard({
|
|||||||
? endDate.toLocaleTimeString('en-US', timeFmt)
|
? endDate.toLocaleTimeString('en-US', timeFmt)
|
||||||
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
|
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
|
||||||
return (
|
return (
|
||||||
<div key={booking.id} className="p-4 bg-slate-950/60 border border-emerald-500/30 rounded-xl relative overflow-hidden">
|
<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-emerald-500" />
|
<div className="absolute top-0 right-0 bottom-0 w-1 bg-success" />
|
||||||
<div className="flex justify-between items-start mb-2 gap-2">
|
<div className="flex justify-between items-start mb-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-white font-sans">{lab?.name}</h4>
|
<h4 className="text-sm font-bold text-fg font-sans">{lab?.name}</h4>
|
||||||
<span className="text-[10px] text-slate-400 flex items-center gap-1 font-sans mt-0.5">
|
<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-slate-500" />
|
<MapPin className="w-3.5 h-3.5 text-fg-faint" />
|
||||||
{lab?.location}
|
{lab?.location}
|
||||||
</span>
|
</span>
|
||||||
|
{booker && (
|
||||||
|
<span className="text-[10px] text-fg-faint font-sans mt-0.5 block">{booker.name}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{/* Countdown Pill */}
|
{/* Countdown Pill */}
|
||||||
<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">
|
<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">
|
||||||
{getRemainingTimeText(booking.endDateTime)}
|
{getRemainingTimeText(booking.endDateTime)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800/60 flex justify-between items-center text-[10px]">
|
<div className="pt-3 border-t border-line flex justify-between items-center text-[10px]">
|
||||||
<span className="font-mono text-slate-400">
|
<span className="font-mono text-fg-muted">
|
||||||
{startF} – {endF}
|
{startF} – {endF}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(booking)}
|
onClick={() => onSelectBookingDetails(booking)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
|
{booking.userId === currentUser.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Release this reservation early?')) {
|
if (confirm('Release this reservation early?')) {
|
||||||
onCancelBooking(booking.id);
|
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"
|
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
|
Release
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -200,65 +209,73 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upcoming Sessions */}
|
{/* Upcoming Sessions */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line 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">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
|
||||||
<Calendar className="w-4 h-4 text-slate-400" />
|
<Calendar className="w-4 h-4 text-fg-muted" />
|
||||||
Upcoming ({upcomingBookings.length})
|
Upcoming ({upcomingBookings.length})
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{upcomingBookings.length === 0 ? (
|
{upcomingBookings.length === 0 ? (
|
||||||
<p className="text-xs text-slate-400 py-4 text-center">No upcoming reservations.</p>
|
<p className="text-xs text-fg-muted py-4 text-center">No upcoming reservations.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{upcomingBookings.map((booking) => {
|
{upcomingBookings.map((booking) => {
|
||||||
const lab = labs.find(l => l.id === booking.labId);
|
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 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 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' });
|
const endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
return (
|
return (
|
||||||
<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 key={booking.id} className="p-3 bg-inner border border-line hover:border-line-strong rounded-lg flex flex-col justify-between">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
<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">
|
<span className="font-mono font-bold text-[10px] text-primary bg-primary-soft border border-primary-line px-2 py-0.5 rounded">
|
||||||
{dayStr}
|
{dayStr}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-mono text-slate-500">
|
<span className="text-[10px] font-mono text-fg-faint">
|
||||||
{startF} – {endF}
|
{startF} – {endF}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
|
<h4 className="text-xs font-bold text-fg mt-1 font-sans">{lab?.name}</h4>
|
||||||
<p className="text-[10px] text-slate-400 line-clamp-1 mt-0.5 leading-normal">
|
{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">
|
||||||
{booking.notes}
|
{booking.notes}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 mt-2 border-t border-slate-800 flex justify-end gap-1.5">
|
<div className="pt-2 mt-2 border-t border-line flex justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
onClick={() => onSelectBookingDetails(booking)}
|
onClick={() => onSelectBookingDetails(booking)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Details
|
Details
|
||||||
</button>
|
</button>
|
||||||
|
{booking.userId === currentUser.id && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Cancel this upcoming reservation?')) {
|
if (confirm('Cancel this upcoming reservation?')) {
|
||||||
onCancelBooking(booking.id);
|
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"
|
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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{currentUser.role.toLowerCase() === 'admin' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm('Permanently delete this reservation?')) {
|
if (confirm('Permanently delete this reservation?')) {
|
||||||
onDeleteBooking(booking.id);
|
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"
|
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
|
Purge
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -273,9 +290,9 @@ export default function Dashboard({
|
|||||||
<div className="lg:col-span-4 space-y-6">
|
<div className="lg:col-span-4 space-y-6">
|
||||||
|
|
||||||
{/* Lab Checklist */}
|
{/* Lab Checklist */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line 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">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5">
|
||||||
<ListTodo className="w-4 h-4 text-slate-400" />
|
<ListTodo className="w-4 h-4 text-fg-muted" />
|
||||||
Lab Checklist
|
Lab Checklist
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@ -284,15 +301,15 @@ export default function Dashboard({
|
|||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => toggleTodo(item.id)}
|
onClick={() => toggleTodo(item.id)}
|
||||||
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"
|
className="flex items-start gap-2.5 p-2 bg-inner hover:bg-card rounded-lg cursor-pointer transition-all border border-line"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={item.checked}
|
checked={item.checked}
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
className="mt-0.5 rounded border-slate-700 text-emerald-500 w-3.5 h-3.5 shrink-0"
|
className="mt-0.5 rounded border-line-strong text-success w-3.5 h-3.5 shrink-0"
|
||||||
/>
|
/>
|
||||||
<span className={`text-xs leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-300'}`}>
|
<span className={`text-xs leading-tight ${item.checked ? 'text-fg-faint line-through' : 'text-fg-muted'}`}>
|
||||||
{item.text}
|
{item.text}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -301,27 +318,27 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Links */}
|
{/* Quick Links */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line 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">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2 mb-3.5 justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<LinkIcon className="w-4 h-4 text-slate-400" />
|
<LinkIcon className="w-4 h-4 text-fg-muted" />
|
||||||
Quick Links
|
Quick Links
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
onClick={onNavigateToLinks}
|
||||||
className="text-[10px] text-slate-400 hover:text-slate-200 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
|
className="text-[10px] text-fg-muted hover:text-fg font-semibold flex items-center gap-0.5 hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Manage <ArrowRight className="w-3 h-3" />
|
Manage <ArrowRight className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-800">
|
<div className="text-center py-6 bg-inner rounded-lg border border-line">
|
||||||
<Globe className="w-7 h-7 text-slate-600 mx-auto mb-2 opacity-50" />
|
<Globe className="w-7 h-7 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
<p className="text-xs text-slate-400">No shared links yet.</p>
|
<p className="text-xs text-fg-muted">No shared links yet.</p>
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
onClick={onNavigateToLinks}
|
||||||
className="text-xs text-slate-400 font-semibold underline mt-1.5 hover:text-slate-200 hover:cursor-pointer"
|
className="text-xs text-fg-muted font-semibold underline mt-1.5 hover:text-fg hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
Add links
|
Add links
|
||||||
</button>
|
</button>
|
||||||
@ -338,23 +355,23 @@ export default function Dashboard({
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<span className={`w-7 h-7 rounded-md bg-slate-950 border border-slate-800 flex items-center justify-center shrink-0 ${accent}`}>
|
<span className={`w-7 h-7 rounded-md bg-surface border border-line flex items-center justify-center shrink-0 ${accent}`}>
|
||||||
<Globe className="w-3.5 h-3.5" />
|
<Globe className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-xs font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
|
<span className="block text-xs font-semibold text-fg group-hover:text-fg truncate">{link.title}</span>
|
||||||
<span className={`block text-[10px] font-mono truncate ${accent}`}>{host}</span>
|
<span className={`block text-[10px] font-mono truncate ${accent}`}>{host}</span>
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-400 shrink-0" />
|
<ExternalLink className="w-3.5 h-3.5 text-fg-faint group-hover:text-fg-muted shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{links.length > 6 && (
|
{links.length > 6 && (
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLinks}
|
onClick={onNavigateToLinks}
|
||||||
className="w-full text-center text-[10px] text-slate-500 hover:text-slate-300 pt-1.5 font-semibold hover:cursor-pointer"
|
className="w-full text-center text-[10px] text-fg-faint hover:text-fg-muted pt-1.5 font-semibold hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
+{links.length - 6} more links
|
+{links.length - 6} more links
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -15,8 +15,8 @@ const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'
|
|||||||
|
|
||||||
interface DeviceInventoryProps {
|
interface DeviceInventoryProps {
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
checkmkEnabled: boolean;
|
cmkEnabled: boolean;
|
||||||
checkmkBaseUrl: string;
|
cmkBaseUrl: string;
|
||||||
onAddDevice: (device: Omit<Device, 'id'>) => void;
|
onAddDevice: (device: Omit<Device, 'id'>) => void;
|
||||||
onUpdateDevice: (device: Device) => void;
|
onUpdateDevice: (device: Device) => void;
|
||||||
onDeleteDevice: (id: string) => void;
|
onDeleteDevice: (id: string) => void;
|
||||||
@ -24,8 +24,8 @@ interface DeviceInventoryProps {
|
|||||||
|
|
||||||
export default function DeviceInventory({
|
export default function DeviceInventory({
|
||||||
devices,
|
devices,
|
||||||
checkmkEnabled,
|
cmkEnabled,
|
||||||
checkmkBaseUrl,
|
cmkBaseUrl,
|
||||||
onAddDevice,
|
onAddDevice,
|
||||||
onUpdateDevice,
|
onUpdateDevice,
|
||||||
onDeleteDevice,
|
onDeleteDevice,
|
||||||
@ -65,14 +65,14 @@ export default function DeviceInventory({
|
|||||||
|
|
||||||
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
|
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' => d.status;
|
||||||
const cmkHostUrl = (d: Device) =>
|
const cmkHostUrl = (d: Device) =>
|
||||||
checkmkEnabled && checkmkBaseUrl && d.cmkHostname
|
cmkEnabled && cmkBaseUrl && d.cmkHostname
|
||||||
? `${checkmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
|
? `${cmkBaseUrl}/index.py?host=${encodeURIComponent(d.cmkHostname)}`
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
|
const statusMeta = (s: 'online' | 'offline' | 'unknown') => {
|
||||||
if (s === 'online') return { label: 'online', badge: 'bg-emerald-950/60 border-emerald-900/80 text-emerald-400', dot: 'bg-emerald-400' };
|
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-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
|
if (s === 'offline') return { label: 'offline', badge: 'bg-rose-soft border-rose-line text-rose', dot: 'bg-rose' };
|
||||||
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
|
return { label: 'unknown', badge: 'bg-inner border-line text-fg-muted', dot: 'bg-fg-faint' };
|
||||||
};
|
};
|
||||||
|
|
||||||
// Filtered devices list
|
// 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
|
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
|
||||||
const renderEmergencySheetHtml = (text: string) => {
|
const renderEmergencySheetHtml = (text: string) => {
|
||||||
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
|
if (!text) return <p className="text-fg-muted italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
|
||||||
|
|
||||||
const lines = text.split('\n');
|
const lines = text.split('\n');
|
||||||
return lines.map((line, idx) => {
|
return lines.map((line, idx) => {
|
||||||
// Headers
|
// Headers
|
||||||
if (line.startsWith('### ')) {
|
if (line.startsWith('### ')) {
|
||||||
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>;
|
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>;
|
||||||
}
|
}
|
||||||
if (line.startsWith('#### ')) {
|
if (line.startsWith('#### ')) {
|
||||||
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
|
return <h5 key={idx} className="text-xs font-bold text-success mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
|
||||||
}
|
}
|
||||||
if (line.startsWith('**') && line.endsWith('**')) {
|
if (line.startsWith('**') && line.endsWith('**')) {
|
||||||
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
|
return <p key={idx} className="text-xs font-semibold text-fg mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
|
||||||
}
|
}
|
||||||
// Bullet lists
|
// Bullet lists
|
||||||
if (line.startsWith('* ') || line.startsWith('- ')) {
|
if (line.startsWith('* ') || line.startsWith('- ')) {
|
||||||
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
|
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
|
||||||
<span className="text-emerald-500">•</span>
|
<span className="text-success">•</span>
|
||||||
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
|
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
// Numeric lists
|
// Numeric lists
|
||||||
if (/^\d+\s*\.\s/.test(line)) {
|
if (/^\d+\s*\.\s/.test(line)) {
|
||||||
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
|
return <div key={idx} className="flex gap-2 text-xs text-fg-muted ml-4 font-sans align-top mb-1">
|
||||||
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
|
<span className="text-success font-bold">{line.match(/^\d+/)?.[0]}.</span>
|
||||||
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
|
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
@ -199,16 +199,16 @@ export default function DeviceInventory({
|
|||||||
// Inline formatting fallback
|
// Inline formatting fallback
|
||||||
if (line.includes('**')) {
|
if (line.includes('**')) {
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
|
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
|
||||||
{line.split('**').map((tok, ti) => {
|
{line.split('**').map((tok, ti) => {
|
||||||
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
|
return ti % 2 === 1 ? <strong key={ti} className="text-fg">{tok}</strong> : tok;
|
||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (line.includes('`')) {
|
if (line.includes('`')) {
|
||||||
return (
|
return (
|
||||||
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
|
<p key={idx} className="text-xs text-fg-muted my-1 font-sans">
|
||||||
{line.split('`').map((tok, ti) => {
|
{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;
|
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-slate-300 my-0.5 font-sans">{line}</p>;
|
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>;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -224,40 +224,40 @@ export default function DeviceInventory({
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Device List & Controls */}
|
{/* LEFT COLUMN: Device List & Controls */}
|
||||||
<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">
|
<div className="lg:col-span-7 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
|
||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2 font-sans">
|
||||||
<Server className="w-5 h-5 text-emerald-400" />
|
<Server className="w-5 h-5 text-success" />
|
||||||
Inventory
|
Inventory
|
||||||
</h2>
|
</h2>
|
||||||
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenAdd}
|
onClick={handleOpenAdd}
|
||||||
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"
|
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"
|
||||||
id="btn-add-device"
|
id="btn-add-device"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
|
<Plus className="w-4 h-4 stroke-[3]" />
|
||||||
Add Device
|
Add Device
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Toolbar */}
|
{/* Filter Toolbar */}
|
||||||
<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="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-400">
|
<span className="absolute left-3 top-2.5 text-fg-faint">
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
|
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
|
||||||
@ -267,8 +267,8 @@ export default function DeviceInventory({
|
|||||||
onClick={() => setTypeFilter(type)}
|
onClick={() => setTypeFilter(type)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
typeFilter === type
|
typeFilter === type
|
||||||
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
|
? 'bg-success-soft border border-success text-success'
|
||||||
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
|
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{type === 'all' ? 'All' : type}
|
{type === 'all' ? 'All' : type}
|
||||||
@ -280,7 +280,7 @@ export default function DeviceInventory({
|
|||||||
{/* Device Listing Card Table */}
|
{/* Device Listing Card Table */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
|
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
|
||||||
{filteredDevices.length === 0 ? (
|
{filteredDevices.length === 0 ? (
|
||||||
<div className="text-center py-12 text-slate-500 text-xs font-sans">
|
<div className="text-center py-12 text-fg-faint text-xs font-sans">
|
||||||
grep came back empty. no boxes match that filter.
|
grep came back empty. no boxes match that filter.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -292,29 +292,29 @@ export default function DeviceInventory({
|
|||||||
onClick={() => setSelectedDeviceId(device.id)}
|
onClick={() => setSelectedDeviceId(device.id)}
|
||||||
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
|
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
|
? 'bg-card border-success'
|
||||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-3.5">
|
<div className="flex items-start gap-3.5">
|
||||||
{/* Device Icon Circle */}
|
{/* Device Icon Circle */}
|
||||||
<div className={`p-2 rounded-lg border text-base ${
|
<div className={`p-2 rounded-lg border text-base ${
|
||||||
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
|
device.type === 'Firewall' ? 'bg-rose-soft border-rose-line text-rose' :
|
||||||
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
|
device.type === 'Access-Point' ? 'bg-warning-soft border-warning-line text-warning' :
|
||||||
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
|
device.type === 'Controller' ? 'bg-info-soft border-info-line text-info' :
|
||||||
'bg-teal-950/20 border-teal-900/60 text-teal-400'
|
'bg-success-soft border-success-line text-success'
|
||||||
}`}>
|
}`}>
|
||||||
<Server className="w-5 h-5" />
|
<Server className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
|
<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-slate-800 border border-slate-700 text-slate-400">{device.type}</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>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 mt-1 font-sans">
|
<div className="flex flex-col gap-0.5 mt-1 font-sans">
|
||||||
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
|
<span className="text-xs font-mono text-success">{device.ip}</span>
|
||||||
<span className="text-[10px] text-slate-400 flex items-center gap-1">
|
<span className="text-[10px] text-fg-muted flex items-center gap-1">
|
||||||
<MapPin className="w-3 h-3 text-slate-500" />
|
<MapPin className="w-3 h-3 text-fg-faint" />
|
||||||
{device.location}
|
{device.location}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -324,7 +324,7 @@ export default function DeviceInventory({
|
|||||||
{/* Right: Actions and Status */}
|
{/* Right: Actions and Status */}
|
||||||
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* CheckMK Status Badge – only when CheckMK is enabled */}
|
{/* CheckMK Status Badge – only when CheckMK is enabled */}
|
||||||
{checkmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
|
{cmkEnabled && (() => { const m = statusMeta(effectiveStatus(device)); return (
|
||||||
<div className="flex flex-col items-end gap-1 font-sans">
|
<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={`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}`} />
|
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
|
||||||
@ -334,13 +334,13 @@ export default function DeviceInventory({
|
|||||||
); })()}
|
); })()}
|
||||||
|
|
||||||
{/* Action Panel */}
|
{/* Action Panel */}
|
||||||
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
|
<div className="flex items-center gap-1.5 border-l border-line pl-3">
|
||||||
{cmkHostUrl(device) && (
|
{cmkHostUrl(device) && (
|
||||||
<a
|
<a
|
||||||
href={cmkHostUrl(device)!}
|
href={cmkHostUrl(device)!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
|
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-info transition-colors"
|
||||||
title="Open host in CheckMK"
|
title="Open host in CheckMK"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
@ -348,7 +348,7 @@ export default function DeviceInventory({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenEdit(device)}
|
onClick={() => handleOpenEdit(device)}
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
|
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-primary transition-colors"
|
||||||
title="Edit specifications"
|
title="Edit specifications"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
@ -359,7 +359,7 @@ export default function DeviceInventory({
|
|||||||
onDeleteDevice(device.id);
|
onDeleteDevice(device.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
|
className="p-1 px-1.5 rounded hover:bg-inner text-fg-muted hover:text-rose transition-colors"
|
||||||
title="Delete device"
|
title="Delete device"
|
||||||
>
|
>
|
||||||
<Trash className="w-3.5 h-3.5" />
|
<Trash className="w-3.5 h-3.5" />
|
||||||
@ -379,34 +379,34 @@ export default function DeviceInventory({
|
|||||||
{selectedDevice ? (
|
{selectedDevice ? (
|
||||||
<>
|
<>
|
||||||
{/* Header Spec Block */}
|
{/* Header Spec Block */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
|
<div className="bg-card border border-line 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">
|
<span className="text-[10px] font-mono uppercase bg-inner border border-line px-2.5 py-0.5 rounded text-warning font-semibold">
|
||||||
SPECS ID: {selectedDevice.id.toUpperCase()}
|
SPECS ID: {selectedDevice.id.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
|
<h3 className="text-lg font-bold text-fg mt-2 font-mono flex items-center justify-between">
|
||||||
<span>{selectedDevice.hostname}</span>
|
<span>{selectedDevice.hostname}</span>
|
||||||
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
|
<span className="text-xs font-sans text-fg-muted font-normal">Active Link State</span>
|
||||||
</h3>
|
</h3>
|
||||||
<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">
|
<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-slate-200">{selectedDevice.hostname}</span><br />
|
Hostname: <span className="text-fg">{selectedDevice.hostname}</span><br />
|
||||||
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
|
IP Address: <span className="text-success font-bold">{selectedDevice.ip}</span><br />
|
||||||
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
|
Location: <span className="text-fg">{selectedDevice.location}</span><br />
|
||||||
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
|
Node Class: <span className="text-fg">{selectedDevice.type}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 font-sans">
|
<div className="mt-4 font-sans">
|
||||||
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
|
<h4 className="text-xs font-semibold text-fg-muted">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">
|
<div className="mt-1 bg-inner rounded p-2.5 border border-line text-xs text-fg-muted leading-relaxed">
|
||||||
{selectedDevice.notes || 'No description notes registered.'}
|
{selectedDevice.notes || 'No description notes registered.'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CheckMK Monitoring Panel – only when CheckMK is enabled */}
|
{/* CheckMK Monitoring Panel – only when CheckMK is enabled */}
|
||||||
{checkmkEnabled && (
|
{cmkEnabled && (
|
||||||
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
|
<div className="mt-4 pt-4 border-t border-line space-y-2.5">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
|
<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-cyan-400" />
|
<Gauge className="w-3.5 h-3.5 text-info" />
|
||||||
CheckMK Monitoring
|
CheckMK Monitoring
|
||||||
</span>
|
</span>
|
||||||
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
|
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
|
||||||
@ -421,14 +421,14 @@ export default function DeviceInventory({
|
|||||||
href={cmkHostUrl(selectedDevice)!}
|
href={cmkHostUrl(selectedDevice)!}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-3.5 h-3.5" />
|
<ExternalLink className="w-3.5 h-3.5" />
|
||||||
Open host in CheckMK
|
Open host in CheckMK
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
{selectedDevice.lastCheckedAt && (
|
{selectedDevice.lastCheckedAt && (
|
||||||
<p className="text-[10px] text-slate-500 font-mono">
|
<p className="text-[10px] text-fg-faint font-mono">
|
||||||
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
|
Last checked: {new Date(selectedDevice.lastCheckedAt).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -437,33 +437,33 @@ export default function DeviceInventory({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Emergency rescue guidelines sheet */}
|
{/* Emergency rescue guidelines sheet */}
|
||||||
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
|
<div className="bg-warning-soft border border-warning-line 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="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-amber-900/30 pb-3 mb-4">
|
<div className="flex items-center justify-between border-b border-warning-line pb-3 mb-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BookOpen className="w-5 h-5 text-amber-500" />
|
<BookOpen className="w-5 h-5 text-warning" />
|
||||||
<h3 className="font-bold text-sm text-slate-100 font-sans">
|
<h3 className="font-bold text-sm text-fg font-sans">
|
||||||
Emergency Sheet & Disaster Recovery
|
Emergency Sheet & Disaster Recovery
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<span className="text-[9px] font-mono font-bold bg-warning-soft text-warning px-2 py-0.5 rounded border border-warning-line">
|
||||||
RESCUE SHEET
|
RESCUE SHEET
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Markdown Content box */}
|
{/* Markdown Content box */}
|
||||||
<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">
|
<div className="max-h-[350px] overflow-y-auto bg-surface p-4 rounded-lg border border-line leading-relaxed font-sans scrollbar-thin">
|
||||||
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
|
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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">
|
<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-amber-400 shrink-0" />
|
<Info className="w-4 h-4 text-warning shrink-0" />
|
||||||
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
|
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
|
<div className="bg-card border border-line rounded-xl p-10 text-center text-fg-faint text-xs font-sans">
|
||||||
Pick a box from the list to see its specs and break-glass playbook.
|
Pick a box from the list to see its specs and break-glass playbook.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -471,16 +471,16 @@ Pick a box from the list to see its specs and break-glass playbook.
|
|||||||
|
|
||||||
{/* FORM MODAL: Add / Edit Equipment */}
|
{/* FORM MODAL: Add / Edit Equipment */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-overlay 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-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-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
|
<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-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<Server className="w-4 h-4 text-emerald-400" />
|
<Server className="w-4 h-4 text-success" />
|
||||||
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
|
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</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">
|
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
|
<label className="block text-fg-muted font-semibold mb-1">Hostname</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.hostname}
|
value={formData.hostname}
|
||||||
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
|
<label className="block text-fg-muted font-semibold mb-1">IP Address</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.ip}
|
value={formData.ip}
|
||||||
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
|
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.location}
|
value={formData.location}
|
||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
|
<label className="block text-fg-muted font-semibold mb-1">Device Class</label>
|
||||||
<select
|
<select
|
||||||
value={isCustomType ? '__custom__' : formData.type}
|
value={isCustomType ? '__custom__' : formData.type}
|
||||||
onChange={(e) => {
|
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 });
|
setFormData({ ...formData, type: e.target.value as DeviceType });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
>
|
>
|
||||||
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
|
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
|
||||||
<option value="Firewall">Firewall / Security Appliance</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
|
autoFocus
|
||||||
value={formData.type}
|
value={formData.type}
|
||||||
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
|
onChange={(e) => setFormData({ ...formData, type: e.target.value as DeviceType })}
|
||||||
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"
|
className="w-full mt-2 bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
|
<label className="block text-fg-muted font-semibold mb-1">Technical Notes / Patching Mappings</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={formData.notes}
|
value={formData.notes}
|
||||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
|
<label className="block text-fg-muted font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={6}
|
rows={6}
|
||||||
value={formData.emergencySheet}
|
value={formData.emergencySheet}
|
||||||
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
|
||||||
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"
|
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"
|
||||||
placeholder="### EMERGENCY DETAILS..."
|
placeholder="### EMERGENCY DETAILS..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
|
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5 text-slate-950" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,17 +31,17 @@ export default function Header({
|
|||||||
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
|
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
<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">
|
||||||
{/* Brand Logo & Title */}
|
{/* Brand Logo & Title */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<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">
|
<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 animate-pulse" />
|
<GhostGridLogo className="w-10 h-10" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-white">
|
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-fg">
|
||||||
GhostGrid
|
GhostGrid
|
||||||
</h1>
|
</h1>
|
||||||
<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>
|
<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>
|
||||||
<div className="flex items-center gap-1 mt-1">
|
<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="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" />
|
<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 */}
|
{/* Theme Toggle */}
|
||||||
<button
|
<button
|
||||||
onClick={onThemeToggle}
|
onClick={onThemeToggle}
|
||||||
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"
|
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"
|
||||||
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
title={theme === 'dark' ? 'Switch to Light Mode' : 'Switch to Dark Mode'}
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="w-5 h-5 text-amber-400" /> : <Moon className="w-5 h-4.5 text-indigo-450" />}
|
{theme === 'dark' ? <Sun className="w-5 h-5 text-warning" /> : <Moon className="w-5 h-4.5 text-primary" />}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* System Indicator */}
|
{/* System Indicator */}
|
||||||
<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">
|
<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-emerald-500' : 'bg-amber-400'}`} />
|
<span className={`w-2 h-2 rounded-full animate-pulse ${isProduction ? 'bg-success' : 'bg-warning'}`} />
|
||||||
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
<span>System: {isProduction ? 'Production' : 'Development'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -74,36 +74,36 @@ export default function Header({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
|
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
|
||||||
className={`p-2.5 rounded-lg border transition-all relative ${
|
className={`p-2.5 rounded-lg border transition-all relative ${
|
||||||
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'
|
showMailInbox ? 'bg-card border-success text-success' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
title="E-Mail Inbox (Booking Confirmations)"
|
title="E-Mail Inbox (Booking Confirmations)"
|
||||||
>
|
>
|
||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
{userBookings.length > 0 && (
|
{userBookings.length > 0 && (
|
||||||
<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">
|
<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">
|
||||||
{userBookings.length}
|
{userBookings.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showMailInbox && (
|
{showMailInbox && (
|
||||||
<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="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-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-white flex items-center gap-2">
|
<h3 className="font-semibold text-sm text-fg flex items-center gap-2">
|
||||||
<Mail className="w-4 h-4 text-emerald-400" />
|
<Mail className="w-4 h-4 text-success" />
|
||||||
Mail Inbox: {currentUser.email}
|
Mail Inbox: {currentUser.email}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-sans">Automatic booking confirmations & dynamic alerts</p>
|
<p className="text-[10px] text-fg-muted font-sans">Automatic booking confirmations & dynamic alerts</p>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={() => setShowMailInbox(false)} className="text-slate-400 hover:text-white text-xs font-sans">Close</button>
|
<button onClick={() => setShowMailInbox(false)} className="text-fg-muted hover:text-fg text-xs font-sans">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-800 p-2 space-y-1">
|
<div className="max-h-[360px] overflow-y-auto divide-y divide-line p-2 space-y-1">
|
||||||
{userBookings.length === 0 ? (
|
{userBookings.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-400 text-sm font-sans">
|
<div className="text-center py-8 text-fg-muted text-sm font-sans">
|
||||||
<Mail className="w-8 h-8 text-slate-600 mx-auto mb-2 opacity-50" />
|
<Mail className="w-8 h-8 text-fg-faint mx-auto mb-2 opacity-50" />
|
||||||
No emails in inbox.
|
No emails in inbox.
|
||||||
<p className="text-xs text-slate-500 mt-1">Book a lab to receive automated SMTP confirmations.</p>
|
<p className="text-xs text-fg-faint mt-1">Book a lab to receive automated SMTP confirmations.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
userBookings.map((booking) => {
|
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 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' });
|
const formattedEnd = new Date(booking.endDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
|
||||||
return (
|
return (
|
||||||
<div key={booking.id} className="p-3 bg-slate-900/40 rounded-lg hover:bg-slate-900/80 transition-colors">
|
<div key={booking.id} className="p-3 bg-inner rounded-lg hover:bg-card transition-colors">
|
||||||
<div className="flex justify-between items-start mb-1 gap-1">
|
<div className="flex justify-between items-start mb-1 gap-1">
|
||||||
<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-[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-slate-400">Just now</span>
|
<span className="text-[10px] font-mono text-fg-muted">Just now</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-xs font-semibold text-white font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
|
<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-slate-300 leading-relaxed space-y-1.5 font-sans border-l-2 border-emerald-500/50 pl-2">
|
<div className="mt-2 text-[11px] text-fg-muted leading-relaxed space-y-1.5 font-sans border-l-2 border-success pl-2">
|
||||||
<p>Hello <strong>{currentUser.name}</strong>,</p>
|
<p>Hello <strong>{currentUser.name}</strong>,</p>
|
||||||
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
|
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
|
||||||
<div className="bg-slate-850 p-1.5 rounded font-mono text-[9px] text-slate-300 border border-slate-700/50">
|
<div className="bg-surface p-1.5 rounded font-mono text-[9px] text-fg-muted border border-line">
|
||||||
<strong>Lab Location:</strong> {lab?.location}<br />
|
<strong>Lab Location:</strong> {lab?.location}<br />
|
||||||
<strong>Start Time:</strong> {formattedStart}<br />
|
<strong>Start Time:</strong> {formattedStart}<br />
|
||||||
<strong>End Time:</strong> {formattedEnd}<br />
|
<strong>End Time:</strong> {formattedEnd}<br />
|
||||||
<strong>Notes:</strong> {booking.notes || 'None'}
|
<strong>Notes:</strong> {booking.notes || 'None'}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-slate-500 italic">GhostGrid Automation Mailbot</p>
|
<p className="text-[10px] text-fg-faint italic">GhostGrid Automation Mailbot</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -142,39 +142,39 @@ export default function Header({
|
|||||||
<button
|
<button
|
||||||
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
|
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
|
||||||
className={`p-2.5 rounded-lg border transition-all relative ${
|
className={`p-2.5 rounded-lg border transition-all relative ${
|
||||||
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'
|
showBellDropdown ? 'bg-card border-warning text-warning' : 'bg-inner border-line text-fg-muted hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
title="Interface & System Alerts"
|
title="Interface & System Alerts"
|
||||||
>
|
>
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<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">
|
<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">
|
||||||
{notifications.length}
|
{notifications.length}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{showBellDropdown && (
|
{showBellDropdown && (
|
||||||
<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="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-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
<div className="bg-inner px-4 py-3 border-b border-line flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-sm text-white flex items-center gap-2 font-sans">
|
<h3 className="font-semibold text-sm text-fg flex items-center gap-2 font-sans">
|
||||||
<Bell className="w-4 h-4 text-amber-400" />
|
<Bell className="w-4 h-4 text-warning" />
|
||||||
Notifications ({notifications.length})
|
Notifications ({notifications.length})
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-sans">Booking lifecycles & countdowns</p>
|
<p className="text-[10px] text-fg-muted font-sans">Booking lifecycles & countdowns</p>
|
||||||
</div>
|
</div>
|
||||||
{notifications.length > 0 && (
|
{notifications.length > 0 && (
|
||||||
<button onClick={onClearNotifications} className="text-amber-400 hover:text-amber-300 text-xs font-semibold font-sans">Clear All</button>
|
<button onClick={onClearNotifications} className="text-warning hover:opacity-80 text-xs font-semibold font-sans">Clear All</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[300px] overflow-y-auto divide-y divide-slate-800 p-2">
|
<div className="max-h-[300px] overflow-y-auto divide-y divide-line p-2">
|
||||||
{notifications.length === 0 ? (
|
{notifications.length === 0 ? (
|
||||||
<div className="text-center py-6 text-slate-400 text-xs font-sans">No active system alerts.</div>
|
<div className="text-center py-6 text-fg-muted text-xs font-sans">No active system alerts.</div>
|
||||||
) : (
|
) : (
|
||||||
notifications.map((notif, index) => (
|
notifications.map((notif, index) => (
|
||||||
<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">
|
<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-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-4 h-4 text-warning shrink-0 mt-0.5" />
|
||||||
<p>{notif}</p>
|
<p>{notif}</p>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@ -185,15 +185,15 @@ export default function Header({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Info + Logout */}
|
{/* User Info + Logout */}
|
||||||
<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="flex items-center gap-2 pl-2 pr-1 py-1.5 bg-inner border border-line rounded-lg text-fg">
|
||||||
<div className="hidden sm:block">
|
<div className="hidden sm:block">
|
||||||
<div className="text-xs font-semibold leading-3 text-white max-w-[120px] truncate">{currentUser.name}</div>
|
<div className="text-xs font-semibold leading-3 text-fg 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 className="text-[9px] text-info font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
title="Sign out"
|
title="Sign out"
|
||||||
className="ml-1 p-1.5 rounded-md text-slate-400 hover:text-red-400 hover:bg-red-950/40 transition-all"
|
className="ml-1 p-1.5 rounded-md text-fg-muted hover:text-danger hover:bg-danger-soft transition-all"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -178,18 +178,18 @@ export default function LabTemplates({
|
|||||||
onClick={() => setSelectedLab(lab)}
|
onClick={() => setSelectedLab(lab)}
|
||||||
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-slate-900 border-emerald-500'
|
? 'bg-card border-success'
|
||||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
: 'bg-inner border-line hover:border-line-strong hover:bg-card'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
<h3 className="font-bold text-sm text-fg">{lab.name}</h3>
|
||||||
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||||
{editable && (
|
{editable && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleOpenEdit(lab)}
|
onClick={() => handleOpenEdit(lab)}
|
||||||
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
className="text-fg-muted hover:text-primary p-0.5"
|
||||||
title="Edit template configuration"
|
title="Edit template configuration"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
@ -200,7 +200,7 @@ export default function LabTemplates({
|
|||||||
onDeleteLab(lab.id);
|
onDeleteLab(lab.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="text-slate-400 hover:text-rose-400 p-0.5"
|
className="text-fg-muted hover:text-rose p-0.5"
|
||||||
title="Delete template"
|
title="Delete template"
|
||||||
>
|
>
|
||||||
<Trash className="w-3.5 h-3.5" />
|
<Trash className="w-3.5 h-3.5" />
|
||||||
@ -210,37 +210,37 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
<p className="text-xs text-fg-muted mt-1 line-clamp-2 leading-relaxed">
|
||||||
{lab.description}
|
{lab.description}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<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="mt-3 pt-3 border-t border-line grid grid-cols-2 gap-1 text-[10px] text-fg-muted">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
<UserIcon className="w-3.5 h-3.5 text-fg-faint shrink-0" />
|
||||||
<span className="truncate">{lab.contactPerson}</span>
|
<span className="truncate">{lab.contactPerson}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
<MapPin className="w-3.5 h-3.5 text-fg-faint shrink-0" />
|
||||||
<span className="truncate">{lab.location}</span>
|
<span className="truncate">{lab.location}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center justify-between">
|
<div className="mt-2.5 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
<span className="text-[10px] font-mono text-success bg-success-soft px-2 py-0.5 rounded border border-success-line">
|
||||||
{lab.deviceIds.length} connected devices
|
{lab.deviceIds.length} connected devices
|
||||||
</span>
|
</span>
|
||||||
{lab.scope === 'personal' ? (
|
{lab.scope === 'personal' ? (
|
||||||
<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">
|
<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">
|
||||||
<Lock className="w-2.5 h-2.5" /> Personal
|
<Lock className="w-2.5 h-2.5" /> Personal
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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">
|
||||||
<Globe className="w-2.5 h-2.5" /> Global
|
<Globe className="w-2.5 h-2.5" /> Global
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
<ChevronRight className={`w-4 h-4 text-fg-faint transition-transform ${isSelected ? 'translate-x-1 text-success' : ''}`} />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Lab List */}
|
{/* LEFT COLUMN: Lab List */}
|
||||||
<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="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="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<Layers className="w-5 h-5 text-emerald-400" />
|
<Layers className="w-5 h-5 text-success" />
|
||||||
Topology
|
Topology
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Predefined architectural scenarios & wiring profiles.</p>
|
<p className="text-xs text-fg-muted">Predefined architectural scenarios & wiring profiles.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenAdd}
|
onClick={handleOpenAdd}
|
||||||
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"
|
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"
|
||||||
title="Create new lab template"
|
title="Create new lab template"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-slate-950" />
|
<Plus className="w-4 h-4" />
|
||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -273,24 +273,24 @@ export default function LabTemplates({
|
|||||||
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
|
||||||
{myPersonalLabs.length > 0 && (
|
{myPersonalLabs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
|
<p className="text-[10px] font-mono uppercase tracking-widest text-primary px-1">My Topologies</p>
|
||||||
{myPersonalLabs.map(renderLabCard)}
|
{myPersonalLabs.map(renderLabCard)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{globalLabs.length > 0 && (
|
{globalLabs.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
|
<p className="text-[10px] font-mono uppercase tracking-widest text-fg-faint px-1 mt-2">Global Topologies</p>
|
||||||
{globalLabs.map(renderLabCard)}
|
{globalLabs.map(renderLabCard)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{othersPersonal.length > 0 && (
|
{othersPersonal.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
|
<p className="text-[10px] font-mono uppercase tracking-widest text-fg-faint px-1 mt-2">Others' Personal</p>
|
||||||
{othersPersonal.map(renderLabCard)}
|
{othersPersonal.map(renderLabCard)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{labs.length === 0 && (
|
{labs.length === 0 && (
|
||||||
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
|
<p className="text-xs text-fg-faint text-center py-8">No topology templates yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -300,33 +300,33 @@ export default function LabTemplates({
|
|||||||
{selectedLab ? (
|
{selectedLab ? (
|
||||||
<>
|
<>
|
||||||
{/* Template Card Meta */}
|
{/* Template Card Meta */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line 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 className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<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">
|
<span className="text-[9px] font-mono uppercase tracking-widest text-fg-muted bg-inner border border-line px-2.5 py-0.5 rounded">
|
||||||
TEMPLATE ID: {selectedLab.id.toUpperCase()}
|
TEMPLATE ID: {selectedLab.id.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="text-lg font-bold text-white mt-1.5">{selectedLab.name}</h3>
|
<h3 className="text-lg font-bold text-fg mt-1.5">{selectedLab.name}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
<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-slate-400" />
|
<UserIcon className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
<p className="text-fg-muted leading-none">Primary Contact</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
<p className="text-fg font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
<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-slate-400" />
|
<MapPin className="w-3.5 h-3.5 text-fg-muted" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Testing Location</p>
|
<p className="text-fg-muted leading-none">Testing Location</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.location}</p>
|
<p className="text-fg font-semibold mt-0.5">{selectedLab.location}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-slate-300 leading-relaxed bg-slate-900/30 p-3 rounded-lg border border-slate-800">
|
<p className="text-xs text-fg-muted leading-relaxed bg-inner p-3 rounded-lg border border-line">
|
||||||
{selectedLab.description}
|
{selectedLab.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@ -339,28 +339,28 @@ export default function LabTemplates({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sub-Devices components list */}
|
{/* Sub-Devices components list */}
|
||||||
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
|
<div className="bg-card border border-line 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>
|
<h4 className="text-xs font-bold text-fg-muted uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{labDevices.map((device) => (
|
{labDevices.map((device) => (
|
||||||
<div
|
<div
|
||||||
key={device.id}
|
key={device.id}
|
||||||
onClick={() => onOpenDeviceDetails(device)}
|
onClick={() => onOpenDeviceDetails(device)}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2.5 font-sans">
|
<div className="flex items-center gap-2.5 font-sans">
|
||||||
<div className={`p-1.5 rounded text-indigo-400 bg-indigo-950/30 border border-indigo-900/50`}>
|
<div className={`p-1.5 rounded text-primary bg-primary-soft border border-primary-line`}>
|
||||||
<Server className="w-4 h-4" />
|
<Server className="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
|
<p className="text-xs font-mono font-bold text-fg leading-none">{device.hostname}</p>
|
||||||
<p className="text-[9px] font-mono text-emerald-400 mt-1">{device.ip}</p>
|
<p className="text-[9px] font-mono text-success mt-1">{device.ip}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 font-mono">
|
<div className="flex items-center gap-2 font-mono">
|
||||||
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
|
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-success' : 'bg-rose'}`} />
|
||||||
<span className="text-[10px] text-slate-400 capitalize">{device.status}</span>
|
<span className="text-[10px] text-fg-muted capitalize">{device.status}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -368,7 +368,7 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-16 text-center text-slate-500 text-xs font-sans">
|
<div className="bg-card border border-line rounded-xl p-16 text-center text-fg-faint text-xs font-sans">
|
||||||
Select a lab scenario template from the left directory column to inspect active port topology connections.
|
Select a lab scenario template from the left directory column to inspect active port topology connections.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -376,17 +376,17 @@ export default function LabTemplates({
|
|||||||
|
|
||||||
{/* FORM MODAL: Create or Edit Lab Template */}
|
{/* FORM MODAL: Create or Edit Lab Template */}
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-overlay 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-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-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans overflow-x-auto">
|
<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-white flex items-center gap-2">
|
<h3 className="text-sm font-bold text-fg flex items-center gap-2">
|
||||||
<Layers className="w-5 h-5 text-emerald-400" />
|
<Layers className="w-5 h-5 text-success" />
|
||||||
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
|
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@ -396,23 +396,23 @@ export default function LabTemplates({
|
|||||||
{/* Name & Location */}
|
{/* Name & Location */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Topology Name</label>
|
<label className="block text-fg-muted font-semibold mb-1">Topology Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
|
<label className="block text-fg-muted font-semibold mb-1">Physical Location</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.location}
|
value={formData.location}
|
||||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -420,38 +420,38 @@ export default function LabTemplates({
|
|||||||
{/* Description & Contact person */}
|
{/* Description & Contact person */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Caretaker / Owner</label>
|
<label className="block text-fg-muted font-semibold mb-1">Caretaker / Owner</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.contactPerson}
|
value={formData.contactPerson}
|
||||||
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Description</label>
|
<label className="block text-fg-muted font-semibold mb-1">Description</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scope toggle */}
|
{/* Scope toggle */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
|
<label className="block text-fg-muted font-semibold mb-1.5">Visibility</label>
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setFormData({ ...formData, scope: 'global' })}
|
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 ${
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
formData.scope === 'global'
|
formData.scope === 'global'
|
||||||
? 'bg-slate-800 border-slate-500 text-slate-200'
|
? 'bg-card border-line-strong text-fg'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Globe className="w-3.5 h-3.5" /> Global — visible to all
|
<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' })}
|
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 ${
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
formData.scope === 'personal'
|
formData.scope === 'personal'
|
||||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
|
? 'bg-primary-soft border-primary text-primary'
|
||||||
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
: 'bg-inner border-line text-fg-muted hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Lock className="w-3.5 h-3.5" /> Personal — only you
|
<Lock className="w-3.5 h-3.5" /> Personal — only you
|
||||||
@ -471,10 +471,10 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hardware checklist */}
|
{/* Hardware checklist */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
<label className="block text-fg-muted 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>
|
<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-slate-950/60 p-3 rounded-lg border border-slate-855">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-inner p-3 rounded-lg border border-line">
|
||||||
{devices.map((dev) => {
|
{devices.map((dev) => {
|
||||||
const isChecked = formData.deviceIds.includes(dev.id);
|
const isChecked = formData.deviceIds.includes(dev.id);
|
||||||
return (
|
return (
|
||||||
@ -484,15 +484,15 @@ export default function LabTemplates({
|
|||||||
onClick={() => handleToggleDevice(dev.id)}
|
onClick={() => handleToggleDevice(dev.id)}
|
||||||
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
|
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
|
||||||
isChecked
|
isChecked
|
||||||
? 'bg-emerald-900/20 border-emerald-500 text-slate-100'
|
? 'bg-success-soft border-success text-fg'
|
||||||
: 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300'
|
: 'bg-card border-line hover:border-line-strong text-fg-muted'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="truncate pr-1">
|
<div className="truncate pr-1">
|
||||||
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
|
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
|
||||||
<p className="text-[9px] font-mono text-slate-400 mt-1">{dev.ip}</p>
|
<p className="text-[9px] font-mono text-fg-muted mt-1">{dev.ip}</p>
|
||||||
</div>
|
</div>
|
||||||
{isChecked && <Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
|
{isChecked && <Check className="w-3.5 h-3.5 text-success shrink-0" />}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -500,18 +500,18 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Physical/Logical topology builder link creator */}
|
{/* Physical/Logical topology builder link creator */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">2. Define Ports & Link Connections</label>
|
<label className="block text-fg-muted 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>
|
<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>
|
||||||
|
|
||||||
{/* Connection Inputs */}
|
{/* Connection Inputs */}
|
||||||
<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 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>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Source Node</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Source Node</label>
|
||||||
<select
|
<select
|
||||||
value={linkFrom}
|
value={linkFrom}
|
||||||
onChange={(e) => setLinkFrom(e.target.value)}
|
onChange={(e) => setLinkFrom(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
|
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
|
||||||
>
|
>
|
||||||
<option value="">-- Choose --</option>
|
<option value="">-- Choose --</option>
|
||||||
{formData.deviceIds.map((id) => {
|
{formData.deviceIds.map((id) => {
|
||||||
@ -521,11 +521,11 @@ export default function LabTemplates({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Target Node</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Target Node</label>
|
||||||
<select
|
<select
|
||||||
value={linkTo}
|
value={linkTo}
|
||||||
onChange={(e) => setLinkTo(e.target.value)}
|
onChange={(e) => setLinkTo(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
|
className="w-full bg-field text-fg border border-line-strong rounded p-1 font-mono text-[11px]"
|
||||||
>
|
>
|
||||||
<option value="">-- Choose --</option>
|
<option value="">-- Choose --</option>
|
||||||
{formData.deviceIds.map((id) => {
|
{formData.deviceIds.map((id) => {
|
||||||
@ -535,10 +535,10 @@ export default function LabTemplates({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Link Identifier Description (Label)</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Link Identifier Description (Label)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
|
className="w-full bg-field text-fg border border-line-strong p-1 rounded font-mono text-[11px]"
|
||||||
value={linkType}
|
value={linkType}
|
||||||
onChange={(e) => setLinkType(e.target.value)}
|
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 toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
|
||||||
const isEditingThis = editingLinkIdx === idx;
|
const isEditingThis = editingLinkIdx === idx;
|
||||||
return (
|
return (
|
||||||
<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">
|
<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-slate-300 shrink-0"><strong>{fromDev}</strong> ────</span>
|
<span className="text-fg-muted shrink-0"><strong>{fromDev}</strong> ────</span>
|
||||||
{isEditingThis ? (
|
{isEditingThis ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
@ -583,17 +583,17 @@ export default function LabTemplates({
|
|||||||
}
|
}
|
||||||
if (e.key === 'Escape') setEditingLinkIdx(null);
|
if (e.key === 'Escape') setEditingLinkIdx(null);
|
||||||
}}
|
}}
|
||||||
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"
|
className="flex-1 min-w-0 bg-field text-fg border border-primary rounded px-1.5 py-0.5 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex-1 min-w-0 text-indigo-300 truncate">{link.type}</span>
|
<span className="flex-1 min-w-0 text-primary truncate">{link.type}</span>
|
||||||
)}
|
)}
|
||||||
<span className="text-slate-300 shrink-0">──── <strong>{toDev}</strong></span>
|
<span className="text-fg-muted shrink-0">──── <strong>{toDev}</strong></span>
|
||||||
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
|
<div className="flex gap-1.5 shrink-0 ml-auto font-sans">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
|
onClick={() => { setEditingLinkIdx(idx); setEditingLinkLabel(link.type); }}
|
||||||
className="text-slate-400 hover:text-indigo-400 transition-colors"
|
className="text-fg-muted hover:text-primary transition-colors"
|
||||||
title="Edit label"
|
title="Edit label"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3 h-3" />
|
<Pencil className="w-3 h-3" />
|
||||||
@ -601,7 +601,7 @@ export default function LabTemplates({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemoveLink(idx)}
|
onClick={() => handleRemoveLink(idx)}
|
||||||
className="text-rose-500 hover:text-rose-400 font-bold"
|
className="text-rose hover:opacity-80 font-bold"
|
||||||
>
|
>
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -611,53 +611,53 @@ export default function LabTemplates({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-[10px] text-slate-500 italic">No interface connections formulated yet.</p>
|
<p className="text-[10px] text-fg-faint italic">No interface connections formulated yet.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ansible Semaphore Automation */}
|
{/* Ansible Semaphore Automation */}
|
||||||
{semaphoreEnabled && <div className="border-t border-slate-800 pt-3">
|
{semaphoreEnabled && <div className="border-t border-line pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5 flex items-center gap-1.5">
|
<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-400" />
|
<Terminal className="w-3.5 h-3.5 text-orange" />
|
||||||
3. Ansible Automation (optional)
|
3. Ansible Automation (optional)
|
||||||
</label>
|
</label>
|
||||||
<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>
|
<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>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Setup Template ID</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Setup Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.semaphoreSetupTemplateId}
|
value={formData.semaphoreSetupTemplateId}
|
||||||
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, semaphoreSetupTemplateId: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[10px] text-slate-400 mb-1">Teardown Template ID</label>
|
<label className="block text-[10px] text-fg-muted mb-1">Teardown Template ID</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
value={formData.semaphoreTeardownTemplateId}
|
value={formData.semaphoreTeardownTemplateId}
|
||||||
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, semaphoreTeardownTemplateId: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 font-mono text-xs focus:outline-none focus:border-orange"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{/* Form submit handlers */}
|
{/* Form submit handlers */}
|
||||||
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
|
<div className="pt-3 border-t border-line flex justify-end gap-2.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsEditing(false)}
|
onClick={() => setIsEditing(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs animate-none"
|
className="px-4 py-2 bg-inner hover:bg-card text-fg-muted rounded font-semibold text-xs animate-none"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
|
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded text-xs animate-none"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -20,12 +20,12 @@ interface LinkDashboardProps {
|
|||||||
|
|
||||||
// Accent palette - keys are stored in the DB so they survive reloads.
|
// 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 }> = {
|
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
|
||||||
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
|
emerald: { ring: 'hover:border-success', text: 'text-success', bg: 'bg-success-soft', dot: 'bg-success', bar: 'bg-success' },
|
||||||
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
|
cyan: { ring: 'hover:border-info', text: 'text-info', bg: 'bg-info-soft', dot: 'bg-info', bar: 'bg-info' },
|
||||||
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
|
indigo: { ring: 'hover:border-primary', text: 'text-primary', bg: 'bg-primary-soft', dot: 'bg-primary', bar: 'bg-primary' },
|
||||||
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
|
amber: { ring: 'hover:border-warning', text: 'text-warning', bg: 'bg-warning-soft', dot: 'bg-warning', bar: 'bg-warning' },
|
||||||
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
|
rose: { ring: 'hover:border-rose', text: 'text-rose', bg: 'bg-rose-soft', dot: 'bg-rose', bar: 'bg-rose' },
|
||||||
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
|
violet: { ring: 'hover:border-violet', text: 'text-violet', bg: 'bg-violet-soft', dot: 'bg-violet', bar: 'bg-violet' },
|
||||||
};
|
};
|
||||||
const ACCENT_KEYS = Object.keys(ACCENTS);
|
const ACCENT_KEYS = Object.keys(ACCENTS);
|
||||||
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
|
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">
|
<div className="space-y-6 font-sans" id="link-dashboard-root">
|
||||||
|
|
||||||
{/* Header banner */}
|
{/* Header banner */}
|
||||||
<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="bg-card border border-line 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">
|
<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">
|
||||||
LINKS
|
LINKS
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
|
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
|
||||||
<LinkIcon className="w-6 h-6 text-emerald-400" />
|
<LinkIcon className="w-6 h-6 text-success" />
|
||||||
Tooling & Quick Links
|
Tooling & Quick Links
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<p className="text-xs text-fg-muted 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.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={openAdd}
|
onClick={openAdd}
|
||||||
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"
|
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"
|
||||||
id="btn-add-link">
|
id="btn-add-link">
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
Add Link
|
Add Link
|
||||||
@ -163,20 +163,20 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar: search + category filter */}
|
{/* Toolbar: search + category filter */}
|
||||||
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-[#1E293B] border border-slate-800 rounded-xl">
|
<div className="flex flex-col sm:flex-row gap-3 p-3 bg-card border border-line rounded-xl">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
|
<span className="absolute left-3 top-2.5 text-fg-faint"><Search className="w-4 h-4" /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 flex-wrap shrink-0">
|
<div className="flex gap-1 flex-wrap shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveCategory('all')}
|
onClick={() => setActiveCategory('all')}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
@ -184,7 +184,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setActiveCategory(cat)}
|
onClick={() => setActiveCategory(cat)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
@ -194,30 +194,30 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{links.length === 0 ? (
|
{links.length === 0 ? (
|
||||||
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
|
<div className="text-center py-16 bg-card border border-dashed border-line-strong rounded-2xl">
|
||||||
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
|
<Globe className="w-10 h-10 text-fg-faint mx-auto mb-3 opacity-60" />
|
||||||
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
|
<h3 className="text-sm font-bold text-fg">404: links not found</h3>
|
||||||
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
|
<p className="text-xs text-fg-muted 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.
|
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>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={openAdd}
|
onClick={openAdd}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" /> Add your first link
|
<Plus className="w-4 h-4" /> Add your first link
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
|
<p className="text-center py-16 text-fg-faint text-xs">No links match your search.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{grouped.map(([category, items]) => (
|
{grouped.map(([category, items]) => (
|
||||||
<section key={category}>
|
<section key={category}>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<FolderOpen className="w-4 h-4 text-slate-500" />
|
<FolderOpen className="w-4 h-4 text-fg-faint" />
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
|
<h3 className="text-xs font-bold uppercase tracking-wider text-fg-muted font-mono">{category}</h3>
|
||||||
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
|
<span className="text-[10px] text-fg-faint font-mono">({items.length})</span>
|
||||||
<div className="flex-1 h-px bg-slate-850 ml-2" />
|
<div className="flex-1 h-px bg-line ml-2" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<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 (
|
return (
|
||||||
<div
|
<div
|
||||||
key={link.id}
|
key={link.id}
|
||||||
className={`group relative bg-[#1E293B] border border-slate-800 rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
|
className={`group relative bg-card border border-line 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`} />
|
<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="flex items-start gap-3">
|
||||||
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-slate-800 flex items-center justify-center shrink-0 overflow-hidden`}>
|
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-line flex items-center justify-center shrink-0 overflow-hidden`}>
|
||||||
<Globe className={`w-5 h-5 ${a.text}`} />
|
<Globe className={`w-5 h-5 ${a.text}`} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -240,7 +240,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
href={link.url}
|
href={link.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
|
className="text-sm font-bold text-fg hover:underline flex items-center gap-1.5 truncate"
|
||||||
title={link.title}
|
title={link.title}
|
||||||
>
|
>
|
||||||
<span className="truncate">{link.title}</span>
|
<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 === 'Enter' && !e.shiftKey) { e.preventDefault(); commitDescEdit(link); }
|
||||||
if (e.key === 'Escape') { setEditingDescId(null); }
|
if (e.key === 'Escape') { setEditingDescId(null); }
|
||||||
}}
|
}}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p
|
<p
|
||||||
onClick={() => startDescEdit(link)}
|
onClick={() => startDescEdit(link)}
|
||||||
title="Click to edit description"
|
title="Click to edit description"
|
||||||
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`}
|
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`}
|
||||||
>
|
>
|
||||||
{link.description || 'Add a description…'}
|
{link.description || 'Add a description…'}
|
||||||
</p>
|
</p>
|
||||||
@ -278,7 +278,7 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
<button
|
<button
|
||||||
onClick={() => openEdit(link)}
|
onClick={() => openEdit(link)}
|
||||||
title="Edit link"
|
title="Edit link"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</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);
|
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
|
||||||
}}
|
}}
|
||||||
title="Delete link"
|
title="Delete link"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -303,47 +303,47 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
|
|
||||||
{/* Add / Edit modal */}
|
{/* Add / Edit modal */}
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<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="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
|
<div
|
||||||
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"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
|
<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-white flex items-center gap-2">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-2">
|
||||||
<Star className="w-4 h-4 text-emerald-400" />
|
<Star className="w-4 h-4 text-success" />
|
||||||
{editingId ? 'Edit Link' : 'New Quick Link'}
|
{editingId ? 'Edit Link' : 'New Quick Link'}
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
|
<button onClick={closeForm} className="text-fg-muted hover:text-fg"><X className="w-4 h-4" /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
|
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
|
<label className="block text-fg-muted font-semibold mb-1">Title *</label>
|
||||||
<input
|
<input
|
||||||
required autoFocus
|
required autoFocus
|
||||||
value={draft.title}
|
value={draft.title}
|
||||||
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
|
<label className="block text-fg-muted font-semibold mb-1">URL *</label>
|
||||||
<input
|
<input
|
||||||
required
|
required
|
||||||
value={draft.url}
|
value={draft.url}
|
||||||
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Category</label>
|
<label className="block text-fg-muted font-semibold mb-1">Category</label>
|
||||||
<input
|
<input
|
||||||
list="link-categories"
|
list="link-categories"
|
||||||
value={draft.category}
|
value={draft.category}
|
||||||
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
|
||||||
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success"
|
||||||
/>
|
/>
|
||||||
<datalist id="link-categories">
|
<datalist id="link-categories">
|
||||||
{categories.map(c => <option key={c} value={c} />)}
|
{categories.map(c => <option key={c} value={c} />)}
|
||||||
@ -351,24 +351,24 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Description</label>
|
<label className="block text-fg-muted font-semibold mb-1">Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows={2}
|
rows={2}
|
||||||
value={draft.description}
|
value={draft.description}
|
||||||
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
|
||||||
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"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none focus:border-success resize-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
|
<label className="block text-fg-muted font-semibold mb-1.5">Accent</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{ACCENT_KEYS.map(key => (
|
{ACCENT_KEYS.map(key => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => setDraft({ ...draft, color: 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-[#1E293B] ring-white 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-card ring-fg scale-110' : 'opacity-70 hover:opacity-100'}`}
|
||||||
title={key}
|
title={key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@ -376,10 +376,10 @@ export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateL
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<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">
|
<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">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<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">
|
<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">
|
||||||
<Save className="w-3.5 h-3.5" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
{editingId ? 'Save Changes' : 'Add Link'}
|
{editingId ? 'Save Changes' : 'Add Link'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -66,14 +66,14 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
const getLogTypeBadge = (type: string) => {
|
const getLogTypeBadge = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'maintenance':
|
case 'maintenance':
|
||||||
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
|
return 'bg-warning-soft border border-warning-line text-warning';
|
||||||
case 'booking':
|
case 'booking':
|
||||||
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
|
return 'bg-success-soft border border-success-line text-success';
|
||||||
case 'status':
|
case 'status':
|
||||||
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
|
return 'bg-info-soft border border-info-line text-info';
|
||||||
case 'system':
|
case 'system':
|
||||||
default:
|
default:
|
||||||
return 'bg-slate-900 border border-slate-800 text-slate-350';
|
return 'bg-inner border border-line text-fg-muted';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -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">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
|
||||||
|
|
||||||
{/* LEFT COLUMN: Chronological Log List */}
|
{/* LEFT COLUMN: Chronological Log List */}
|
||||||
<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="lg:col-span-8 bg-card border border-line rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-base font-bold text-white flex items-center gap-2">
|
<h2 className="text-base font-bold text-fg flex items-center gap-2">
|
||||||
<History className="w-5 h-5 text-emerald-400" />
|
<History className="w-5 h-5 text-success" />
|
||||||
Audit Log & Maintenance Journal
|
Audit Log & Maintenance Journal
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
|
<p className="text-xs text-fg-muted">Append-only history of who touched what. git blame, but for the lab.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddLog(!showAddLog)}
|
onClick={() => setShowAddLog(!showAddLog)}
|
||||||
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"
|
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"
|
||||||
id="btn-toggle-add-log"
|
id="btn-toggle-add-log"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 text-emerald-400" />
|
<Plus className="w-4 h-4 text-success" />
|
||||||
File Maintenance Report
|
File Maintenance Report
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search and Filters toolbar */}
|
{/* Search and Filters toolbar */}
|
||||||
<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="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-inner border border-line rounded-lg">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-550">
|
<span className="absolute left-3 top-2.5 text-fg-faint">
|
||||||
<Search className="w-4 h-4" />
|
<Search className="w-4 h-4" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 shrink-0 text-xs font-medium flex-wrap">
|
<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)}
|
onClick={() => setTypeFilter(key)}
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
typeFilter === key
|
typeFilter === key
|
||||||
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
|
? 'bg-success-soft border border-success text-success'
|
||||||
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
|
: 'bg-inner text-fg-muted border border-line hover:bg-card hover:text-fg'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@ -148,7 +148,7 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
{/* Audit Log Sheet */}
|
{/* Audit Log Sheet */}
|
||||||
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
|
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
|
||||||
{filteredLogs.length === 0 ? (
|
{filteredLogs.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">
|
<p className="text-center py-16 text-fg-faint text-xs">
|
||||||
No audit records match the selected filtering rules.
|
No audit records match the selected filtering rules.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
@ -161,29 +161,29 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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 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 className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
|
<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)}`}>
|
<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)}
|
{getLogTypeLabel(log.type)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[9px] font-mono text-slate-500 leading-none">
|
<span className="text-[9px] font-mono text-fg-faint leading-none">
|
||||||
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className="text-xs text-slate-200 leading-relaxed font-sans">{log.message}</p>
|
<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-slate-500 pt-1.5 border-t border-slate-900/40">
|
<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">
|
||||||
<span>Calendar Time: {timestampFormatted}</span>
|
<span>Calendar Time: {timestampFormatted}</span>
|
||||||
{user && (
|
{user && (
|
||||||
<span className="flex items-center gap-1 text-slate-400">
|
<span className="flex items-center gap-1 text-fg-muted">
|
||||||
<UserIcon className="w-3 h-3 text-slate-500" />
|
<UserIcon className="w-3 h-3 text-fg-faint" />
|
||||||
Operator: {user.name}
|
Operator: {user.name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{dev && (
|
{dev && (
|
||||||
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
|
<span className="flex items-center gap-1 text-success font-semibold">
|
||||||
<Server className="w-3 h-3 text-slate-500" />
|
<Server className="w-3 h-3 text-fg-faint" />
|
||||||
Node: {dev.hostname}
|
Node: {dev.hostname}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -199,15 +199,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
|
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
|
||||||
<div className="lg:col-span-4" id="logbook-forms-side">
|
<div className="lg:col-span-4" id="logbook-forms-side">
|
||||||
{showAddLog ? (
|
{showAddLog ? (
|
||||||
<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="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-slate-800">
|
<div className="flex items-center justify-between pb-2 border-b border-line">
|
||||||
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
|
<h3 className="font-bold text-sm text-fg flex items-center gap-1.5">
|
||||||
<Hammer className="w-4 h-4 text-amber-500" />
|
<Hammer className="w-4 h-4 text-warning" />
|
||||||
Journal Maintenance Work
|
Journal Maintenance Work
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddLog(false)}
|
onClick={() => setShowAddLog(false)}
|
||||||
className="text-slate-400 hover:text-white"
|
className="text-fg-muted hover:text-fg"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@ -215,11 +215,11 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
|
|
||||||
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
|
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
|
<label className="block text-fg-muted font-semibold mb-1">Target Network Host (Optional)</label>
|
||||||
<select
|
<select
|
||||||
value={targetDeviceId}
|
value={targetDeviceId}
|
||||||
onChange={(e) => setTargetDeviceId(e.target.value)}
|
onChange={(e) => setTargetDeviceId(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none"
|
||||||
>
|
>
|
||||||
<option value="">-- Complete Lab Cluster / General Event --</option>
|
<option value="">-- Complete Lab Cluster / General Event --</option>
|
||||||
{devices.map((d) => (
|
{devices.map((d) => (
|
||||||
@ -231,24 +231,24 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
|
<label className="block text-fg-muted font-semibold mb-1">Documented Actions / Findings</label>
|
||||||
<textarea
|
<textarea
|
||||||
required
|
required
|
||||||
rows={4}
|
rows={4}
|
||||||
value={logMessage}
|
value={logMessage}
|
||||||
onChange={(e) => setLogMessage(e.target.value)}
|
onChange={(e) => setLogMessage(e.target.value)}
|
||||||
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
|
className="w-full bg-field text-fg border border-line-strong rounded p-2 focus:outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
|
<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-emerald-400 shrink-0 mt-0.5" />
|
<Info className="w-4 h-4 text-success 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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Save className="w-3.5 h-3.5" />
|
<Save className="w-3.5 h-3.5" />
|
||||||
Publish to Shared Log Book
|
Publish to Shared Log Book
|
||||||
@ -256,15 +256,15 @@ export default function Logbook({ logs, devices, users, currentUser, onAddLog }:
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-5 shadow-sm text-xs text-slate-400 font-sans leading-relaxed">
|
<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-white mb-2 text-sm flex items-center gap-2">
|
<h3 className="font-bold text-fg mb-2 text-sm flex items-center gap-2">
|
||||||
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
|
<ChevronRight className="w-4 h-4 text-success shrink-0" />
|
||||||
Shared Audit & Fault Logging
|
Shared Audit & Fault Logging
|
||||||
</h3>
|
</h3>
|
||||||
<p>
|
<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.
|
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>
|
</p>
|
||||||
<div className="mt-4 p-3 bg-amber-950/20 border border-amber-900/40 rounded-lg text-amber-300 font-mono text-[10px]">
|
<div className="mt-4 p-3 bg-warning-soft border border-warning-line rounded-lg text-warning font-mono text-[10px]">
|
||||||
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
|
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -52,21 +52,21 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
|
||||||
{/* Logo & Brand */}
|
{/* Logo & Brand */}
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<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)]">
|
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
|
||||||
<GhostGridLogo className="w-14 h-14" />
|
<GhostGridLogo className="w-14 h-14" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
|
||||||
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
|
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
|
||||||
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 mt-2">
|
<div className="flex items-center justify-center gap-1.5 mt-2">
|
||||||
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
|
<span className="text-[9px] text-fg-faint 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="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" />
|
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
||||||
AirITSystems
|
AirITSystems
|
||||||
@ -76,22 +76,22 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Login Card */}
|
{/* Login Card */}
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
|
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Sign in</h2>
|
<h2 className="text-lg font-semibold text-fg">Sign in</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">Enter your credentials to access the platform.</p>
|
<p className="text-xs text-fg-muted mt-1">Enter your credentials to access the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<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">
|
<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-red-400" />
|
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="email">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="email">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -101,12 +101,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="password">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="password">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -117,12 +117,12 @@ export default function LoginPage({ onLogin, onNavigateToRegister, authError }:
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(v => !v)}
|
onClick={() => setShowPassword(v => !v)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<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 && (
|
{azureEnabled && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex-1 h-px bg-slate-800" />
|
<div className="flex-1 h-px bg-line" />
|
||||||
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-widest">or</span>
|
<span className="text-[10px] font-mono text-fg-faint uppercase tracking-widest">or</span>
|
||||||
<div className="flex-1 h-px bg-slate-800" />
|
<div className="flex-1 h-px bg-line" />
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { window.location.href = '/api/auth/azure'; }}
|
onClick={() => { window.location.href = '/api/auth/azure'; }}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{/* Microsoft M logo */}
|
{/* 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">
|
<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-slate-400">
|
<p className="text-center text-xs text-fg-muted">
|
||||||
No account yet?{' '}
|
No account yet?{' '}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToRegister}
|
onClick={onNavigateToRegister}
|
||||||
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
|
className="text-info hover:opacity-80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Create one
|
Create one
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -56,21 +56,21 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
|
<div className="min-h-screen bg-surface text-fg font-sans flex items-center justify-center p-4">
|
||||||
<div className="w-full max-w-md space-y-8">
|
<div className="w-full max-w-md space-y-8">
|
||||||
|
|
||||||
{/* Logo & Brand */}
|
{/* Logo & Brand */}
|
||||||
<div className="text-center space-y-3">
|
<div className="text-center space-y-3">
|
||||||
<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)]">
|
<div className="inline-flex p-3 bg-card border border-line rounded-2xl shadow-lg">
|
||||||
<GhostGridLogo className="w-14 h-14" />
|
<GhostGridLogo className="w-14 h-14" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
|
<h1 className="text-2xl font-bold tracking-tight text-fg">GhostGrid</h1>
|
||||||
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
|
<p className="text-[10px] font-mono text-info tracking-widest uppercase mt-0.5">
|
||||||
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center justify-center gap-1.5 mt-2">
|
<div className="flex items-center justify-center gap-1.5 mt-2">
|
||||||
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
|
<span className="text-[9px] text-fg-faint 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="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" />
|
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
|
||||||
AirIT Systems
|
AirIT Systems
|
||||||
@ -80,22 +80,22 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Register Card */}
|
{/* Register Card */}
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
|
<div className="bg-card border border-line rounded-2xl p-8 shadow-2xl space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">Create account</h2>
|
<h2 className="text-lg font-semibold text-fg">Create account</h2>
|
||||||
<p className="text-xs text-slate-400 mt-1">Register to gain access to the platform.</p>
|
<p className="text-xs text-fg-muted mt-1">Register to gain access to the platform.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<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">
|
<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-red-400" />
|
<AlertCircle className="w-4 h-4 shrink-0 text-danger" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-name">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-name">
|
||||||
Full name
|
Full name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -105,12 +105,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-email">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-email">
|
||||||
Email address
|
Email address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -120,12 +120,12 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-password">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-password">
|
||||||
Password
|
Password
|
||||||
</label>
|
</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@ -136,19 +136,19 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(v => !v)}
|
onClick={() => setShowPassword(v => !v)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{password.length > 0 && (
|
{password.length > 0 && (
|
||||||
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-emerald-400' : 'text-amber-400'}`}>
|
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-success' : 'text-warning'}`}>
|
||||||
<CheckCircle2 className="w-3 h-3" />
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
|
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
|
||||||
</div>
|
</div>
|
||||||
@ -156,7 +156,7 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-confirm">
|
<label className="block text-xs font-semibold text-fg-muted" htmlFor="reg-confirm">
|
||||||
Confirm password
|
Confirm password
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@ -166,10 +166,10 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
required
|
required
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={e => setConfirmPassword(e.target.value)}
|
onChange={e => setConfirmPassword(e.target.value)}
|
||||||
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 ${
|
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 ${
|
||||||
confirmPassword.length > 0 && confirmPassword !== password
|
confirmPassword.length > 0 && confirmPassword !== password
|
||||||
? 'border-red-700 focus:ring-red-500/50'
|
? 'border-danger focus:ring-danger/50'
|
||||||
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
|
: 'border-line-strong focus:ring-info/50 focus:border-info'
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -177,7 +177,7 @@ export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPag
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p className="text-center text-xs text-slate-400">
|
<p className="text-center text-xs text-fg-muted">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<button
|
<button
|
||||||
onClick={onNavigateToLogin}
|
onClick={onNavigateToLogin}
|
||||||
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
|
className="text-info hover:opacity-80 font-semibold transition-colors"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -50,17 +50,17 @@ interface SettingsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Label({ children }: { children: React.ReactNode }) {
|
function Label({ children }: { children: React.ReactNode }) {
|
||||||
return <label className="block text-[11px] font-semibold text-slate-400 uppercase tracking-wide mb-1.5">{children}</label>;
|
return <label className="block text-[11px] font-semibold text-fg-muted uppercase tracking-wide mb-1.5">{children}</label>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Hint({ children }: { children: React.ReactNode }) {
|
function Hint({ children }: { children: React.ReactNode }) {
|
||||||
return <p className="mt-1 text-[10px] text-slate-500 font-mono leading-relaxed">{children}</p>;
|
return <p className="mt-1 text-[10px] text-fg-faint font-mono leading-relaxed">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfiguredBadge() {
|
function ConfiguredBadge() {
|
||||||
return (
|
return (
|
||||||
<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="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-emerald-400 inline-block" />
|
<span className="w-1 h-1 rounded-full bg-success inline-block" />
|
||||||
CONFIGURED
|
CONFIGURED
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@ -94,7 +94,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{icon && (
|
{icon && (
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-faint pointer-events-none">
|
||||||
{icon}
|
{icon}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -103,7 +103,7 @@ function Input({ value, onChange, placeholder, monospace, icon }: {
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
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' : ''}`}
|
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' : ''}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -117,19 +117,19 @@ function SecretInput({ value, onChange, show, onToggleShow }: {
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 pointer-events-none">
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-fg-faint pointer-events-none">
|
||||||
<KeyRound className="w-3.5 h-3.5" />
|
<KeyRound className="w-3.5 h-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<input
|
<input
|
||||||
type={show ? 'text' : 'password'}
|
type={show ? 'text' : 'password'}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={e => onChange(e.target.value)}
|
onChange={e => onChange(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleShow}
|
onClick={onToggleShow}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-slate-300 transition-colors"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-faint hover:text-fg-muted transition-colors"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
{show ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
{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;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl overflow-hidden">
|
<div className="bg-card border border-line rounded-2xl overflow-hidden">
|
||||||
<div className={`h-0.5 w-full ${accentColor}`} />
|
<div className={`h-0.5 w-full ${accentColor}`} />
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-5">
|
||||||
{children}
|
{children}
|
||||||
@ -171,13 +171,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
|
const [azureAllowedGroup, setAzureAllowedGroup] = useState('');
|
||||||
const [showAzureSecret, setShowAzureSecret] = useState(false);
|
const [showAzureSecret, setShowAzureSecret] = useState(false);
|
||||||
|
|
||||||
const [checkmkEnabled, setCheckmkEnabled] = useState(false);
|
const [cmkEnabled, setCmkEnabled] = useState(false);
|
||||||
const [checkmkApiUrl, setCheckmkApiUrl] = useState('');
|
const [cmkApiUrl, setCheckmkApiUrl] = useState('');
|
||||||
const [checkmkApiUser, setCheckmkApiUser] = useState('');
|
const [cmkApiUser, setCheckmkApiUser] = useState('');
|
||||||
const [checkmkApiSecret, setCheckmkApiSecret] = useState('');
|
const [cmkApiSecret, setCheckmkApiSecret] = useState('');
|
||||||
const [checkmkSecretSet, setCheckmkSecretSet] = useState(false);
|
const [cmkSecretSet, setCheckmkSecretSet] = useState(false);
|
||||||
const [checkmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
|
const [cmkSyncInterval, setCheckmkSyncInterval] = useState('60000');
|
||||||
const [showCheckmkSecret, setShowCheckmkSecret] = useState(false);
|
const [showCmkSecret, setShowCheckmkSecret] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
|
||||||
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
const [semaphoreEnabled, setSemaphoreEnabled] = useState(false);
|
||||||
@ -251,7 +251,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
setAzureClientSecret('');
|
setAzureClientSecret('');
|
||||||
setAzureRedirectUri(data.azure_redirect_uri || '');
|
setAzureRedirectUri(data.azure_redirect_uri || '');
|
||||||
setAzureAllowedGroup(data.azure_allowed_group || '');
|
setAzureAllowedGroup(data.azure_allowed_group || '');
|
||||||
setCheckmkEnabled(data.checkmk_enabled === 'true');
|
setCmkEnabled(data.checkmk_enabled === 'true');
|
||||||
setCheckmkApiUrl(data.checkmk_api_url || '');
|
setCheckmkApiUrl(data.checkmk_api_url || '');
|
||||||
setCheckmkApiUser(data.checkmk_api_user || '');
|
setCheckmkApiUser(data.checkmk_api_user || '');
|
||||||
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
setCheckmkSecretSet(data.checkmk_api_secret === SECRET_SENTINEL);
|
||||||
@ -281,10 +281,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
azure_tenant_id: azureTenantId,
|
azure_tenant_id: azureTenantId,
|
||||||
azure_redirect_uri: azureRedirectUri,
|
azure_redirect_uri: azureRedirectUri,
|
||||||
azure_allowed_group: azureAllowedGroup,
|
azure_allowed_group: azureAllowedGroup,
|
||||||
checkmk_enabled: checkmkEnabled ? 'true' : 'false',
|
checkmk_enabled: cmkEnabled ? 'true' : 'false',
|
||||||
checkmk_api_url: checkmkApiUrl,
|
checkmk_api_url: cmkApiUrl,
|
||||||
checkmk_api_user: checkmkApiUser,
|
checkmk_api_user: cmkApiUser,
|
||||||
checkmk_sync_interval_ms: checkmkSyncInterval,
|
checkmk_sync_interval_ms: cmkSyncInterval,
|
||||||
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
|
semaphore_enabled: semaphoreEnabled ? 'true' : 'false',
|
||||||
semaphore_api_url: semaphoreApiUrl,
|
semaphore_api_url: semaphoreApiUrl,
|
||||||
semaphore_project_id: semaphoreProjectId,
|
semaphore_project_id: semaphoreProjectId,
|
||||||
@ -292,7 +292,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
caddy_admin_url: caddyAdminUrl,
|
caddy_admin_url: caddyAdminUrl,
|
||||||
};
|
};
|
||||||
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
|
if (azureClientSecret) payload.azure_client_secret = azureClientSecret;
|
||||||
if (checkmkApiSecret) payload.checkmk_api_secret = checkmkApiSecret;
|
if (cmkApiSecret) payload.checkmk_api_secret = cmkApiSecret;
|
||||||
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
|
if (semaphoreApiToken) payload.semaphore_api_token = semaphoreApiToken;
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/settings', { method: 'PUT', body: JSON.stringify(payload) });
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<span className="w-5 h-5 border-2 border-slate-700 border-t-cyan-400 rounded-full animate-spin" />
|
<span className="w-5 h-5 border-2 border-line-strong border-t-info rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -531,19 +531,19 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
{/* Page header */}
|
{/* Page header */}
|
||||||
<div className="flex items-start justify-between gap-4">
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1.5 text-[10px] font-mono text-slate-600 mb-3">
|
<div className="flex items-center gap-1.5 text-[10px] font-mono text-fg-faint mb-3">
|
||||||
<Settings2 className="w-3 h-3" />
|
<Settings2 className="w-3 h-3" />
|
||||||
<span>SYSTEM</span>
|
<span>SYSTEM</span>
|
||||||
<ChevronRight className="w-3 h-3" />
|
<ChevronRight className="w-3 h-3" />
|
||||||
<span className="text-slate-400">SETTINGS</span>
|
<span className="text-fg-muted">SETTINGS</span>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Settings</h1>
|
<h1 className="text-xl font-bold text-fg tracking-tight">Settings</h1>
|
||||||
<p className="text-xs text-slate-500 mt-0.5">Configure integrations and authentication providers.</p>
|
<p className="text-xs text-fg-faint mt-0.5">Configure integrations and authentication providers.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="shrink-0 flex items-center gap-2 bg-cyan-600 hover:bg-cyan-500 active:bg-cyan-700 disabled:bg-slate-800 disabled:text-slate-600 disabled:border disabled:border-slate-700 text-white font-semibold text-sm px-5 py-2.5 rounded-xl transition-all shadow-lg shadow-cyan-950/50"
|
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"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
<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 */}
|
{/* Feedback banners */}
|
||||||
{error && (
|
{error && (
|
||||||
<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">
|
<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-red-400 mt-0.5" />
|
<AlertCircle className="w-4 h-4 shrink-0 text-danger mt-0.5" />
|
||||||
<span>{error}</span>
|
<span>{error}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{successMsg && (
|
{successMsg && (
|
||||||
<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">
|
<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-emerald-400" />
|
<CheckCircle className="w-4 h-4 shrink-0 text-success" />
|
||||||
{successMsg}
|
{successMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Section tabs - switch between Integrations and System to keep the page light */}
|
{/* Section tabs - switch between Integrations and System to keep the page light */}
|
||||||
<div className="inline-flex items-center gap-0.5 p-0.5 bg-slate-900/50 border border-slate-800 rounded-lg w-fit">
|
<div className="inline-flex items-center gap-0.5 p-0.5 bg-inner border border-line rounded-lg w-fit">
|
||||||
{([
|
{([
|
||||||
{ id: 'integrations', label: 'Integrations', icon: <Plug className="w-3.5 h-3.5" /> },
|
{ 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" /> },
|
{ 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)}
|
onClick={() => setActiveSection(tab.id)}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
|
||||||
activeSection === tab.id
|
activeSection === tab.id
|
||||||
? 'bg-cyan-950/40 border border-cyan-900/50 text-cyan-400'
|
? 'bg-info-soft border border-info-line text-info'
|
||||||
: 'border border-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800'
|
: 'border border-transparent text-fg-muted hover:text-fg hover:bg-inner'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{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">
|
<SectionCard accentColor="bg-gradient-to-r from-blue-600 to-indigo-600">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-blue-950/60 border border-blue-900/40 rounded-xl">
|
<div className="p-2 bg-blue-soft border border-blue-line rounded-xl">
|
||||||
<Shield className="w-4 h-4 text-blue-400" />
|
<Shield className="w-4 h-4 text-blue" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold text-white">Microsoft Entra ID</h2>
|
<h2 className="text-sm font-semibold text-fg">Microsoft Entra ID</h2>
|
||||||
{azureEnabled && azureSecretSet && (
|
{azureEnabled && azureSecretSet && (
|
||||||
<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="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-400 animate-pulse inline-block" />
|
<span className="w-1 h-1 rounded-full bg-blue animate-pulse inline-block" />
|
||||||
ACTIVE
|
ACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
|
<p className="text-[11px] text-fg-faint mt-0.5">OAuth 2.0 SSO for organizational accounts</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue-400' : 'text-slate-600'}`}>
|
<span className={`text-[10px] font-semibold font-mono ${azureEnabled ? 'text-blue' : 'text-fg-faint'}`}>
|
||||||
{azureEnabled ? 'ENABLED' : 'DISABLED'}
|
{azureEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setAzureEnabled(v => !v)}
|
onClick={() => setAzureEnabled(v => !v)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
<div className={`grid grid-cols-1 sm:grid-cols-2 gap-5 transition-opacity duration-200 ${!azureEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
<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">
|
<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 */}
|
{/* Redirect URI – read-only */}
|
||||||
{effectiveRedirectUri && azureEnabled && (
|
{effectiveRedirectUri && azureEnabled && (
|
||||||
<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 items-start gap-3 bg-inner border border-line rounded-xl px-4 py-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">Required Redirect URI</p>
|
<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-slate-200 break-all">{effectiveRedirectUri}</p>
|
<p className="text-[11px] font-mono text-fg break-all">{effectiveRedirectUri}</p>
|
||||||
<p className="text-[10px] text-slate-500 mt-1">Register this in Azure Portal > App registrations > Authentication > Redirect URIs</p>
|
<p className="text-[10px] text-fg-faint mt-1">Register this in Azure Portal > App registrations > Authentication > Redirect URIs</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={copyRedirectUri}
|
onClick={copyRedirectUri}
|
||||||
className="shrink-0 p-1.5 rounded-lg text-slate-500 hover:text-slate-200 hover:bg-slate-800 transition-all"
|
className="shrink-0 p-1.5 rounded-lg text-fg-faint hover:text-fg hover:bg-inner transition-all"
|
||||||
title="Copy to clipboard"
|
title="Copy to clipboard"
|
||||||
>
|
>
|
||||||
{copied ? <CheckCircle className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
{copied ? <CheckCircle className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -700,42 +700,42 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
|
<SectionCard accentColor="bg-gradient-to-r from-emerald-600 to-teal-600">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-emerald-950/60 border border-emerald-900/40 rounded-xl">
|
<div className="p-2 bg-success-soft border border-success-line rounded-xl">
|
||||||
<Activity className="w-4 h-4 text-emerald-400" />
|
<Activity className="w-4 h-4 text-success" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold text-white">CheckMK</h2>
|
<h2 className="text-sm font-semibold text-fg">CheckMK</h2>
|
||||||
{checkmkEnabled && checkmkApiUrl && checkmkSecretSet && (
|
{cmkEnabled && cmkApiUrl && cmkSecretSet && (
|
||||||
<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="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-emerald-400 animate-pulse inline-block" />
|
<span className="w-1 h-1 rounded-full bg-success animate-pulse inline-block" />
|
||||||
ACTIVE
|
ACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">Device status sync via CheckMK REST API</p>
|
<p className="text-[11px] text-fg-faint mt-0.5">Device status sync via CheckMK REST API</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`text-[10px] font-semibold font-mono ${checkmkEnabled ? 'text-emerald-400' : 'text-slate-600'}`}>
|
<span className={`text-[10px] font-semibold font-mono ${cmkEnabled ? 'text-success' : 'text-fg-faint'}`}>
|
||||||
{checkmkEnabled ? 'ENABLED' : 'DISABLED'}
|
{cmkEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCheckmkEnabled(v => !v)}
|
onClick={() => setCmkEnabled(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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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'}`} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
<div className={`space-y-5 transition-opacity duration-200 ${!checkmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
<div className={`space-y-5 transition-opacity duration-200 ${!cmkEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||||
<FieldRow label="API URL">
|
<FieldRow label="API URL">
|
||||||
<Input
|
<Input
|
||||||
value={checkmkApiUrl}
|
value={cmkApiUrl}
|
||||||
onChange={setCheckmkApiUrl}
|
onChange={setCheckmkApiUrl}
|
||||||
monospace
|
monospace
|
||||||
icon={<Globe className="w-3.5 h-3.5" />}
|
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">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
|
<FieldRow label="Automation User" hint="Setup > Users > Automation user">
|
||||||
<Input
|
<Input
|
||||||
value={checkmkApiUser}
|
value={cmkApiUser}
|
||||||
onChange={setCheckmkApiUser}
|
onChange={setCheckmkApiUser}
|
||||||
monospace
|
monospace
|
||||||
icon={<KeyRound className="w-3.5 h-3.5" />}
|
icon={<KeyRound className="w-3.5 h-3.5" />}
|
||||||
@ -753,30 +753,30 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<FieldRow
|
<FieldRow
|
||||||
label="Automation Secret"
|
label="Automation Secret"
|
||||||
hint="Setup > Users > Automation user > Automation secret"
|
hint="Setup > Users > Automation user > Automation secret"
|
||||||
badge={checkmkSecretSet ? <ConfiguredBadge /> : undefined}
|
badge={cmkSecretSet ? <ConfiguredBadge /> : undefined}
|
||||||
>
|
>
|
||||||
<SecretInput
|
<SecretInput
|
||||||
value={checkmkApiSecret}
|
value={cmkApiSecret}
|
||||||
onChange={setCheckmkApiSecret}
|
onChange={setCheckmkApiSecret}
|
||||||
show={showCheckmkSecret}
|
show={showCmkSecret}
|
||||||
onToggleShow={() => setShowCheckmkSecret(v => !v)}
|
onToggleShow={() => setShowCheckmkSecret(v => !v)}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
|
<FieldRow label="Sync Interval" hint="Milliseconds between status polls (default: 60000)">
|
||||||
<Input
|
<Input
|
||||||
value={checkmkSyncInterval}
|
value={cmkSyncInterval}
|
||||||
onChange={setCheckmkSyncInterval}
|
onChange={setCheckmkSyncInterval}
|
||||||
monospace
|
monospace
|
||||||
icon={<Clock className="w-3.5 h-3.5" />}
|
icon={<Clock className="w-3.5 h-3.5" />}
|
||||||
/>
|
/>
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</div>
|
</div>
|
||||||
{checkmkApiUrl && checkmkSecretSet && (
|
{cmkApiUrl && cmkSecretSet && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={runSync}
|
onClick={runSync}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? 'animate-spin' : ''}`} />
|
||||||
{syncing ? 'Syncing…' : 'Run sync now'}
|
{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">
|
<SectionCard accentColor="bg-gradient-to-r from-orange-600 to-amber-600">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-orange-950/60 border border-orange-900/40 rounded-xl">
|
<div className="p-2 bg-orange-soft border border-orange-line rounded-xl">
|
||||||
<Terminal className="w-4 h-4 text-orange-400" />
|
<Terminal className="w-4 h-4 text-orange" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold text-white">Ansible Semaphore</h2>
|
<h2 className="text-sm font-semibold text-fg">Ansible Semaphore</h2>
|
||||||
{semaphoreEnabled && semaphoreApiUrl && semaphoreTokenSet && semaphoreProjectId && (
|
{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-950/60 border border-orange-900/50 text-orange-400">
|
<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-400 animate-pulse inline-block" />
|
<span className="w-1 h-1 rounded-full bg-orange animate-pulse inline-block" />
|
||||||
ACTIVE
|
ACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">Trigger playbooks automatically at booking start and end</p>
|
<p className="text-[11px] text-fg-faint mt-0.5">Trigger playbooks automatically at booking start and end</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange-400' : 'text-slate-600'}`}>
|
<span className={`text-[10px] font-semibold font-mono ${semaphoreEnabled ? 'text-orange' : 'text-fg-faint'}`}>
|
||||||
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
|
{semaphoreEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSemaphoreEnabled(v => !v)}
|
onClick={() => setSemaphoreEnabled(v => !v)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
<div className={`space-y-5 transition-opacity duration-200 ${!semaphoreEnabled ? 'opacity-40 pointer-events-none' : ''}`}>
|
||||||
<FieldRow label="API URL">
|
<FieldRow label="API URL">
|
||||||
@ -858,13 +858,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={testSemaphoreConnection}
|
onClick={testSemaphoreConnection}
|
||||||
disabled={semaphoreTesting}
|
disabled={semaphoreTesting}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 ${semaphoreTesting ? 'animate-spin' : ''}`} />
|
||||||
{semaphoreTesting ? 'Testing…' : 'Test connection'}
|
{semaphoreTesting ? 'Testing…' : 'Test connection'}
|
||||||
</button>
|
</button>
|
||||||
{semaphoreTestResult && (
|
{semaphoreTestResult && (
|
||||||
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-red-400' : 'text-emerald-400'}`}>
|
<p className={`text-[11px] font-mono ${semaphoreTestResult.startsWith('Error') ? 'text-danger' : 'text-success'}`}>
|
||||||
{semaphoreTestResult}
|
{semaphoreTestResult}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@ -886,46 +886,46 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
|
<SectionCard accentColor="bg-gradient-to-r from-sky-600 to-cyan-600">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-sky-950/60 border border-sky-900/40 rounded-xl">
|
<div className="p-2 bg-sky-soft border border-sky-line rounded-xl">
|
||||||
<Network className="w-4 h-4 text-sky-400" />
|
<Network className="w-4 h-4 text-sky" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-sm font-semibold text-white">Caddy Reverse Proxy</h2>
|
<h2 className="text-sm font-semibold text-fg">Caddy Reverse Proxy</h2>
|
||||||
{caddyEnabled && caddyStatus === 'available' && (
|
{caddyEnabled && caddyStatus === 'available' && (
|
||||||
<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="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-400 animate-pulse inline-block" />
|
<span className="w-1 h-1 rounded-full bg-sky animate-pulse inline-block" />
|
||||||
ACTIVE
|
ACTIVE
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">Manage reverse proxy routes for internal services</p>
|
<p className="text-[11px] text-fg-faint mt-0.5">Manage reverse proxy routes for internal services</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{caddyManaged ? (
|
{caddyManaged ? (
|
||||||
<>
|
<>
|
||||||
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky-400' : 'text-slate-600'}`}>
|
<span className={`text-[10px] font-semibold font-mono ${caddyEnabled ? 'text-sky' : 'text-fg-faint'}`}>
|
||||||
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
{caddyEnabled ? 'ENABLED' : 'DISABLED'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
onClick={() => setCaddyEnabled((v: boolean) => !v)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] font-semibold font-mono text-slate-500">MANAGED BY PRODUCTION</span>
|
<span className="text-[10px] font-semibold font-mono text-fg-faint">MANAGED BY PRODUCTION</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
{!caddyManaged && (
|
{!caddyManaged && (
|
||||||
<p className="text-[11px] font-mono text-slate-500 leading-relaxed">
|
<p className="text-[11px] font-mono text-fg-faint leading-relaxed">
|
||||||
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
|
Caddy is centrally managed by the production instance (ghostgrid). Manage proxy routes there.
|
||||||
</p>
|
</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>
|
<Hint>Prefix the upstream with https:// for TLS backends - the certificate is not verified.</Hint>
|
||||||
|
|
||||||
{caddyStatus === 'unavailable' && (
|
{caddyStatus === 'unavailable' && (
|
||||||
<p className="text-[11px] font-mono text-amber-400 mb-2">
|
<p className="text-[11px] font-mono text-warning mb-2">
|
||||||
Caddy Admin API not reachable - routes will be applied when Caddy starts.
|
Caddy Admin API not reachable - routes will be applied when Caddy starts.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{caddyRoutes.length === 0 && (
|
{caddyRoutes.length === 0 && (
|
||||||
<p className="text-[11px] font-mono text-slate-500 mb-2">
|
<p className="text-[11px] font-mono text-fg-faint mb-2">
|
||||||
No proxy routes configured yet.
|
No proxy routes configured yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Custom routes */}
|
{/* Custom routes */}
|
||||||
{caddyRoutes.map((r: CaddyRoute) => (
|
{caddyRoutes.map((r: CaddyRoute) => (
|
||||||
<div key={r.id} className="bg-slate-900/60 border border-slate-700/60 rounded-xl px-4 py-2.5">
|
<div key={r.id} className="bg-inner border border-line rounded-xl px-4 py-2.5">
|
||||||
{editingRouteId === r.id ? (
|
{editingRouteId === r.id ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-end gap-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 />
|
<Input value={editUpstream} onChange={setEditUpstream} monospace />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">TLS</span>
|
||||||
<button type="button" onClick={() => setEditTls(v => !v)}
|
<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-slate-800 border border-slate-700'}`}>
|
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'}`}>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">GZ</span>
|
||||||
<button type="button" onClick={() => setEditCompress(v => !v)}
|
<button type="button" onClick={() => setEditCompress(v => !v)}
|
||||||
className={`relative w-8 h-4 rounded-full transition-all duration-200 focus:outline-none ${editCompress ? 'bg-sky-600' : 'bg-slate-800 border border-slate-700'}`}>
|
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'}`}>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
|
<button type="button" onClick={() => handleEditSave(r.id)} disabled={savingRoute || !editHostname.trim() || !editUpstream.trim()}
|
||||||
className="flex items-center gap-1 bg-sky-950/60 hover:bg-sky-900/40 border border-sky-900/50 text-sky-400 text-xs font-semibold px-2.5 py-2 rounded-lg transition-all disabled:opacity-50 shrink-0">
|
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">
|
||||||
<CheckCircle className="w-3.5 h-3.5" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={handleEditCancel}
|
<button type="button" onClick={handleEditCancel}
|
||||||
className="p-2 rounded-lg text-slate-500 hover:text-slate-300 hover:bg-slate-800 transition-all shrink-0">
|
className="p-2 rounded-lg text-fg-faint hover:text-fg-muted hover:bg-inner transition-all shrink-0">
|
||||||
<X className="w-3.5 h-3.5" />
|
<X className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -996,20 +996,20 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<span className="text-[11px] font-mono text-white truncate">{r.hostname}</span>
|
<span className="text-[11px] font-mono text-fg truncate">{r.hostname}</span>
|
||||||
<span className="text-slate-600 text-[11px]">></span>
|
<span className="text-fg-faint text-[11px]">></span>
|
||||||
<span className="text-[11px] font-mono text-slate-400 truncate">{r.upstream}</span>
|
<span className="text-[11px] font-mono text-fg-muted truncate">{r.upstream}</span>
|
||||||
{r.tls ? <span className="text-[9px] font-semibold text-sky-500 font-mono">TLS</span> : null}
|
{r.tls ? <span className="text-[9px] font-semibold text-sky font-mono">TLS</span> : null}
|
||||||
{r.compress ? <span className="text-[9px] font-semibold text-slate-500 font-mono">GZ</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-slate-500 truncate">↳ {r.redirect}</span> : null}
|
{r.redirect ? <span className="text-[9px] font-mono text-fg-faint truncate">↳ {r.redirect}</span> : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 ml-3 shrink-0">
|
<div className="flex items-center gap-1 ml-3 shrink-0">
|
||||||
<button type="button" onClick={() => handleEditStart(r)}
|
<button type="button" onClick={() => handleEditStart(r)}
|
||||||
className="p-1.5 rounded-lg text-slate-600 hover:text-sky-400 hover:bg-sky-950/30 transition-all" title="Edit route">
|
className="p-1.5 rounded-lg text-fg-faint hover:text-sky hover:bg-sky-soft transition-all" title="Edit route">
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onClick={() => handleDeleteRoute(r.id)}
|
<button type="button" onClick={() => handleDeleteRoute(r.id)}
|
||||||
className="p-1.5 rounded-lg text-slate-600 hover:text-red-400 hover:bg-red-950/30 transition-all" title="Remove route">
|
className="p-1.5 rounded-lg text-fg-faint hover:text-danger hover:bg-danger-soft transition-all" title="Remove route">
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -1030,21 +1030,21 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
<Input value={newUpstream} onChange={setNewUpstream} monospace />
|
<Input value={newUpstream} onChange={setNewUpstream} monospace />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">TLS</span>
|
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">TLS</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewTls(v => !v)}
|
onClick={() => setNewTls(v => !v)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center gap-1 pb-0.5">
|
<div className="flex flex-col items-center gap-1 pb-0.5">
|
||||||
<span className="text-[9px] font-semibold text-slate-400 uppercase tracking-wide">GZ</span>
|
<span className="text-[9px] font-semibold text-fg-muted uppercase tracking-wide">GZ</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setNewCompress(v => !v)}
|
onClick={() => setNewCompress(v => !v)}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
<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'}`} />
|
<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>
|
</button>
|
||||||
@ -1053,7 +1053,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={handleAddRoute}
|
onClick={handleAddRoute}
|
||||||
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
|
disabled={addingRoute || !newHostname.trim() || !newUpstream.trim()}
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
{addingRoute ? 'Adding…' : 'Add'}
|
{addingRoute ? 'Adding…' : 'Add'}
|
||||||
@ -1073,25 +1073,25 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
{/* Header: icon + title + file size */}
|
{/* Header: icon + title + file size */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="p-2 bg-violet-950/60 border border-violet-900/40 rounded-xl">
|
<div className="p-2 bg-violet-soft border border-violet-line rounded-xl">
|
||||||
<HardDrive className="w-4 h-4 text-violet-400" />
|
<HardDrive className="w-4 h-4 text-violet" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-white">Database</h2>
|
<h2 className="text-sm font-semibold text-fg">Database</h2>
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
|
<p className="text-[11px] text-fg-faint mt-0.5">SQLite · {dbInfo?.path ?? 'ghostgrid.db'}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-xl font-bold text-white font-mono leading-none">
|
<p className="text-xl font-bold text-fg font-mono leading-none">
|
||||||
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
|
{dbInfo ? formatBytes(dbInfo.sizeBytes) : '-'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[10px] text-slate-500 font-mono mt-1">
|
<p className="text-[10px] text-fg-faint font-mono mt-1">
|
||||||
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
{dbInfo ? new Date(dbInfo.lastModified).toLocaleDateString() : 'Loading…'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
{/* Proportional usage bar + table stats */}
|
{/* Proportional usage bar + table stats */}
|
||||||
{dbInfo ? (() => {
|
{dbInfo ? (() => {
|
||||||
@ -1104,9 +1104,9 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex h-1.5 rounded-full overflow-hidden bg-slate-800 gap-px">
|
<div className="flex h-1.5 rounded-full overflow-hidden bg-line-strong gap-px">
|
||||||
{total === 0
|
{total === 0
|
||||||
? <div className="flex-1 bg-slate-700" />
|
? <div className="flex-1 bg-line-strong" />
|
||||||
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
|
: tableEntries.filter(([, n]) => n > 0).map(([t, n]) => (
|
||||||
<div
|
<div
|
||||||
key={t}
|
key={t}
|
||||||
@ -1119,10 +1119,10 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-4 gap-1.5">
|
<div className="grid grid-cols-4 gap-1.5">
|
||||||
{tableEntries.map(([t, n]) => (
|
{tableEntries.map(([t, n]) => (
|
||||||
<div key={t} className="bg-slate-900/50 border border-slate-700/60 rounded-lg px-2 py-1.5">
|
<div key={t} className="bg-inner border border-line rounded-lg px-2 py-1.5">
|
||||||
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
|
<div className={`w-1.5 h-1.5 rounded-full mb-1 ${palette[t] ?? 'bg-slate-500'}`} />
|
||||||
<p className="text-[8px] font-semibold text-slate-600 uppercase tracking-wide truncate">{t}</p>
|
<p className="text-[8px] font-semibold text-fg-faint uppercase tracking-wide truncate">{t}</p>
|
||||||
<p className="text-sm font-bold text-white font-mono">{n}</p>
|
<p className="text-sm font-bold text-fg font-mono">{n}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -1130,11 +1130,11 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
);
|
);
|
||||||
})() : (
|
})() : (
|
||||||
<div className="h-16 flex items-center justify-center">
|
<div className="h-16 flex items-center justify-center">
|
||||||
<span className="w-4 h-4 border-2 border-slate-700 border-t-violet-400 rounded-full animate-spin" />
|
<span className="w-4 h-4 border-2 border-line-strong border-t-violet rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="h-px bg-slate-800/60" />
|
<div className="h-px bg-line" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<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"
|
type="button"
|
||||||
onClick={handleBackup}
|
onClick={handleBackup}
|
||||||
disabled={backingUp}
|
disabled={backingUp}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-violet-950/60 hover:bg-violet-900/40 border border-violet-900/50 text-violet-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
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"
|
||||||
>
|
>
|
||||||
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
|
<Download className={`w-3.5 h-3.5 ${backingUp ? 'animate-pulse' : ''}`} />
|
||||||
{backingUp ? 'Creating backup…' : 'Download Backup'}
|
{backingUp ? 'Creating backup…' : 'Download Backup'}
|
||||||
@ -1157,13 +1157,13 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
{/* Import */}
|
{/* Import */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Import</Label>
|
<Label>Import</Label>
|
||||||
<div className="flex items-start gap-2 bg-amber-950/40 border border-amber-900/50 rounded-xl px-3 py-2">
|
<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-amber-400 shrink-0 mt-0.5" />
|
<AlertTriangle className="w-3.5 h-3.5 text-warning shrink-0 mt-0.5" />
|
||||||
<p className="text-[11px] text-amber-300 leading-relaxed">
|
<p className="text-[11px] text-warning leading-relaxed">
|
||||||
<strong>Import overwrites the entire database</strong> - this cannot be undone.
|
<strong>Import overwrites the entire database</strong> - this cannot be undone.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<label className="w-full flex items-center gap-2 cursor-pointer bg-slate-900 border border-slate-700 hover:border-slate-600 rounded-lg px-3 py-2 text-xs text-slate-400 hover:text-slate-200 transition-all">
|
<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">
|
||||||
<Upload className="w-3.5 h-3.5 shrink-0" />
|
<Upload className="w-3.5 h-3.5 shrink-0" />
|
||||||
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
|
<span className="truncate">{importFile ? importFile.name : 'Select .db file…'}</span>
|
||||||
<input
|
<input
|
||||||
@ -1186,16 +1186,16 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
onChange={e => setImportConfirmed(e.target.checked)}
|
onChange={e => setImportConfirmed(e.target.checked)}
|
||||||
className="w-3.5 h-3.5 rounded accent-violet-500"
|
className="w-3.5 h-3.5 rounded accent-violet-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-[11px] text-slate-400">I confirm this will overwrite all existing data</span>
|
<span className="text-[11px] text-fg-muted">I confirm this will overwrite all existing data</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleImport}
|
onClick={handleImport}
|
||||||
disabled={importing || !importConfirmed}
|
disabled={importing || !importConfirmed}
|
||||||
className="w-full flex items-center justify-center gap-2 bg-red-950/60 hover:bg-red-900/40 border border-red-900/50 text-red-400 text-xs font-semibold px-3 py-2 rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
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"
|
||||||
>
|
>
|
||||||
{importing
|
{importing
|
||||||
? <span className="w-3.5 h-3.5 border-2 border-red-400/30 border-t-red-400 rounded-full animate-spin" />
|
? <span className="w-3.5 h-3.5 border-2 border-danger/30 border-t-danger rounded-full animate-spin" />
|
||||||
: <Upload className="w-3.5 h-3.5" />
|
: <Upload className="w-3.5 h-3.5" />
|
||||||
}
|
}
|
||||||
{importing ? 'Importing…' : 'Import Database'}
|
{importing ? 'Importing…' : 'Import Database'}
|
||||||
@ -1203,7 +1203,7 @@ export default function Settings({ currentUser: _currentUser }: SettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{importResult && (
|
{importResult && (
|
||||||
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-emerald-400' : 'text-red-400'}`}>
|
<p className={`text-[11px] font-mono ${importResult.ok ? 'text-success' : 'text-danger'}`}>
|
||||||
{importResult.msg}
|
{importResult.msg}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -100,60 +100,60 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
const getDeviceIcon = (type: string) => {
|
const getDeviceIcon = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Firewall':
|
case 'Firewall':
|
||||||
return <Shield className="w-5 h-5 text-rose-400" />;
|
return <Shield className="w-5 h-5 text-rose" />;
|
||||||
case 'Access-Point':
|
case 'Access-Point':
|
||||||
return <Wifi className="w-5 h-5 text-amber-400" />;
|
return <Wifi className="w-5 h-5 text-warning" />;
|
||||||
case 'Controller':
|
case 'Controller':
|
||||||
return <Cpu className="w-5 h-5 text-cyan-400" />;
|
return <Cpu className="w-5 h-5 text-info" />;
|
||||||
case 'Switch':
|
case 'Switch':
|
||||||
default:
|
default:
|
||||||
return <Server className="w-5 h-5 text-teal-400" />;
|
return <Server className="w-5 h-5 text-success" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDeviceColorClass = (type: string) => {
|
const getDeviceColorClass = (type: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'Firewall':
|
case 'Firewall':
|
||||||
return 'border-rose-500/60 bg-rose-950/20 text-rose-200';
|
return 'border-rose-line bg-rose-soft text-rose';
|
||||||
case 'Access-Point':
|
case 'Access-Point':
|
||||||
return 'border-amber-500/60 bg-amber-950/20 text-amber-200';
|
return 'border-warning-line bg-warning-soft text-warning';
|
||||||
case 'Controller':
|
case 'Controller':
|
||||||
return 'border-cyan-500/60 bg-cyan-950/20 text-cyan-200';
|
return 'border-info-line bg-info-soft text-info';
|
||||||
case 'Switch':
|
case 'Switch':
|
||||||
default:
|
default:
|
||||||
return 'border-teal-500/60 bg-teal-950/20 text-teal-200';
|
return 'border-success-line bg-success-soft text-success';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-inner" id="topology-panel">
|
<div className="bg-card border border-line rounded-xl p-4 shadow-inner" id="topology-panel">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-slate-100 flex items-center gap-2 font-sans">
|
<h3 className="text-sm font-semibold text-fg flex items-center gap-2 font-sans">
|
||||||
<Activity className="w-4 h-4 text-emerald-400 animate-pulse" />
|
<Activity className="w-4 h-4 text-success" />
|
||||||
Interactive Topology Diagram (Physical & Logical Links)
|
Interactive Topology Diagram (Physical & Logical Links)
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[11px] text-slate-400 font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
|
<p className="text-[11px] text-fg-muted font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 text-[10px] font-mono">
|
<div className="flex gap-2 text-[10px] font-mono">
|
||||||
<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="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-teal-500"></span> Switch
|
<span className="w-1.5 h-1.5 rounded-full bg-success"></span> Switch
|
||||||
</span>
|
</span>
|
||||||
<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="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-500"></span> Firewall
|
<span className="w-1.5 h-1.5 rounded-full bg-rose"></span> Firewall
|
||||||
</span>
|
</span>
|
||||||
<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="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-amber-500"></span> AP
|
<span className="w-1.5 h-1.5 rounded-full bg-warning"></span> AP
|
||||||
</span>
|
</span>
|
||||||
<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="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-cyan-500"></span> WLC
|
<span className="w-1.5 h-1.5 rounded-full bg-info"></span> WLC
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative overflow-auto border border-slate-800 rounded-lg bg-slate-950/60 flex justify-center items-center">
|
<div className="relative overflow-auto border border-line rounded-lg bg-inner flex justify-center items-center">
|
||||||
{devices.length === 0 ? (
|
{devices.length === 0 ? (
|
||||||
<div className="py-20 text-center text-slate-500 text-xs font-sans">
|
<div className="py-20 text-center text-fg-faint text-xs font-sans">
|
||||||
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
|
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -246,8 +246,8 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
key={`badge-${idx}`}
|
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 ${
|
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
|
isHovered
|
||||||
? 'bg-emerald-500 text-slate-950 scale-110 font-bold border border-emerald-400 z-30'
|
? 'bg-success text-white scale-110 font-bold border border-success z-30'
|
||||||
: 'bg-slate-800 text-slate-400 border border-slate-700'
|
: 'bg-inner text-fg-muted border border-line'
|
||||||
}`}
|
}`}
|
||||||
style={{ left: layout.apexX, top: layout.apexY }}
|
style={{ left: layout.apexX, top: layout.apexY }}
|
||||||
>
|
>
|
||||||
@ -265,25 +265,25 @@ export default function TopologyPanel({ devices, links, onSelectDevice }: Topolo
|
|||||||
<button
|
<button
|
||||||
key={device.id}
|
key={device.id}
|
||||||
onClick={() => onSelectDevice && onSelectDevice(device)}
|
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-slate-900/90 hover:scale-105 hover:border-emerald-500/80 hover:shadow-[0_0_15px_rgba(16,185,129,0.15)]`}
|
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`}
|
||||||
style={{ left: pos.x, top: pos.y }}
|
style={{ left: pos.x, top: pos.y }}
|
||||||
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
|
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
|
||||||
>
|
>
|
||||||
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
|
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
|
||||||
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
|
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-card ${
|
||||||
device.status === 'online' ? 'bg-emerald-500' :
|
device.status === 'online' ? 'bg-success' :
|
||||||
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
|
device.status === 'offline' ? 'bg-rose' : 'bg-fg-faint'
|
||||||
}`} />
|
}`} />
|
||||||
|
|
||||||
<div className="p-1.5 bg-slate-950/60 rounded-lg border border-slate-800/40">
|
<div className="p-1.5 bg-inner rounded-lg border border-line">
|
||||||
{getDeviceIcon(device.type)}
|
{getDeviceIcon(device.type)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="leading-none">
|
<div className="leading-none">
|
||||||
<p className="text-[11px] font-mono font-bold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
|
<p className="text-[11px] font-mono font-bold tracking-tight text-fg group-hover:text-success transition-colors">
|
||||||
{device.hostname}
|
{device.hostname}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[9px] font-mono text-slate-400 group-hover:text-slate-300 mt-0.5">
|
<p className="text-[9px] font-mono text-fg-muted group-hover:text-fg mt-0.5">
|
||||||
{device.ip}
|
{device.ip}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -60,53 +60,53 @@ function EditModal({ user, onClose, onSave }: EditModalProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/70 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-overlay backdrop-blur-sm">
|
||||||
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl shadow-2xl w-full max-w-md">
|
<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-slate-800">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-line">
|
||||||
<h3 className="text-sm font-semibold text-white">Edit User</h3>
|
<h3 className="text-sm font-semibold text-fg">Edit User</h3>
|
||||||
<button onClick={onClose} className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-all">
|
<button onClick={onClose} className="p-1 rounded-lg text-fg-muted hover:text-fg hover:bg-inner transition-all">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<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">
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
|
||||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Name</label>
|
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Name</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
required
|
required
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label className="block text-xs font-semibold text-slate-400 uppercase tracking-wide">Email address</label>
|
<label className="block text-xs font-semibold text-fg-muted uppercase tracking-wide">Email address</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 rounded-lg text-xs font-semibold text-slate-400 hover:text-white hover:bg-slate-800 transition-all"
|
className="px-4 py-2 rounded-lg text-xs font-semibold text-fg-muted hover:text-fg hover:bg-inner transition-all"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saving}
|
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-slate-700 disabled:text-slate-500 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-line-strong disabled:text-fg-faint 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 ? <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'}
|
{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">
|
<div className="space-y-6 font-sans" id="user-directory-root">
|
||||||
|
|
||||||
{/* Header banner */}
|
{/* Header banner */}
|
||||||
<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="bg-card border border-line 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">
|
<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">
|
||||||
TEAM
|
TEAM
|
||||||
</div>
|
</div>
|
||||||
<div className="relative space-y-1.5">
|
<div className="relative space-y-1.5">
|
||||||
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
<h2 className="text-2xl font-bold tracking-tight text-fg flex items-center gap-2.5">
|
||||||
<Users className="w-6 h-6 text-emerald-400" />
|
<Users className="w-6 h-6 text-success" />
|
||||||
Registered Operators
|
Registered Operators
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
|
<p className="text-xs text-fg-muted max-w-xl leading-relaxed">
|
||||||
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
|
Everyone with an account on this box. Booking counts come straight from the shared reservation pool.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2 pt-3">
|
<div className="flex flex-wrap gap-2 pt-3">
|
||||||
<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">
|
<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-emerald-400" />
|
<Users className="w-3.5 h-3.5 text-success" />
|
||||||
<strong className="text-white font-mono">{users.length}</strong> registered
|
<strong className="text-fg font-mono">{users.length}</strong> registered
|
||||||
</span>
|
</span>
|
||||||
<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">
|
<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-indigo-400" />
|
<Calendar className="w-3.5 h-3.5 text-primary" />
|
||||||
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
|
<strong className="text-fg font-mono">{bookings.length}</strong> total bookings
|
||||||
</span>
|
</span>
|
||||||
<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">
|
<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-emerald-400" />
|
<Activity className="w-3.5 h-3.5 text-success" />
|
||||||
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
|
<strong className="text-fg font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{roleError && (
|
{roleError && (
|
||||||
<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">
|
<div className="flex items-center gap-2 bg-danger-soft border border-danger-line rounded-lg px-3 py-2 text-xs text-danger">
|
||||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||||
{roleError}
|
{roleError}
|
||||||
<button onClick={() => setRoleError(null)} className="ml-auto text-red-400 hover:text-white"><X className="w-3 h-3" /></button>
|
<button onClick={() => setRoleError(null)} className="ml-auto text-danger hover:text-fg"><X className="w-3 h-3" /></button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<span className="absolute left-3 top-2.5 text-slate-500"><Search className="w-4 h-4" /></span>
|
<span className="absolute left-3 top-2.5 text-fg-faint"><Search className="w-4 h-4" /></span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={search}
|
value={search}
|
||||||
onChange={e => setSearch(e.target.value)}
|
onChange={e => setSearch(e.target.value)}
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User grid */}
|
{/* User grid */}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
|
<p className="text-center py-16 text-fg-faint text-xs">No operators match your search.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{filtered.map(user => {
|
{filtered.map(user => {
|
||||||
@ -238,10 +238,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={user.id}
|
key={user.id}
|
||||||
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'}`}
|
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'}`}
|
||||||
>
|
>
|
||||||
{isMe && (
|
{isMe && (
|
||||||
<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">
|
<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">
|
||||||
You
|
You
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -251,10 +251,10 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
{initials(user.name)}
|
{initials(user.name)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
|
<h3 className="text-sm font-bold text-fg truncate">{user.name}</h3>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${user.email}`}
|
href={`mailto:${user.email}`}
|
||||||
className="text-[11px] text-slate-400 hover:text-emerald-400 truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
|
className="text-[11px] text-fg-muted hover:text-success truncate flex items-center gap-1 mt-0.5 w-fit max-w-full"
|
||||||
>
|
>
|
||||||
<Mail className="w-3 h-3 shrink-0" />
|
<Mail className="w-3 h-3 shrink-0" />
|
||||||
<span className="truncate">{user.email}</span>
|
<span className="truncate">{user.email}</span>
|
||||||
@ -262,20 +262,20 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 pt-3 border-t border-slate-800 flex items-center justify-between gap-2">
|
<div className="mt-4 pt-3 border-t border-line flex items-center justify-between gap-2">
|
||||||
{user.role.toLowerCase() === 'admin'
|
{user.role.toLowerCase() === 'admin'
|
||||||
? <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="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-slate-500 uppercase tracking-wider">User</span>
|
: <span className="text-[10px] font-mono text-fg-faint uppercase tracking-wider">User</span>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
|
<div className="flex items-center gap-3 text-[10px] font-mono text-fg-muted">
|
||||||
<span className="flex items-center gap-1" title="Total bookings">
|
<span className="flex items-center gap-1" title="Total bookings">
|
||||||
<Calendar className="w-3 h-3 text-indigo-400" />
|
<Calendar className="w-3 h-3 text-primary" />
|
||||||
{total}
|
{total}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
<span className="flex items-center gap-1" title="Active / upcoming bookings">
|
||||||
<Activity className="w-3 h-3 text-emerald-400" />
|
<Activity className="w-3 h-3 text-success" />
|
||||||
{active}
|
{active}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -286,11 +286,11 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleToggleRole(user)}
|
onClick={() => handleToggleRole(user)}
|
||||||
disabled={togglingRoleId === user.id}
|
disabled={togglingRoleId === user.id}
|
||||||
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'}`}
|
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'}`}
|
||||||
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
|
title={user.role.toLowerCase() === 'admin' ? 'Remove admin' : 'Make admin'}
|
||||||
>
|
>
|
||||||
{togglingRoleId === user.id
|
{togglingRoleId === user.id
|
||||||
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-amber-400 rounded-full animate-spin inline-block" />
|
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-warning rounded-full animate-spin inline-block" />
|
||||||
: user.role.toLowerCase() === 'admin'
|
: user.role.toLowerCase() === 'admin'
|
||||||
? <ShieldCheck className="w-3.5 h-3.5" />
|
? <ShieldCheck className="w-3.5 h-3.5" />
|
||||||
: <Shield 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
|
<button
|
||||||
onClick={() => setEditingUser(user)}
|
onClick={() => setEditingUser(user)}
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-cyan-400 hover:bg-slate-900 transition-all"
|
className="p-1.5 rounded-lg text-fg-faint hover:text-info hover:bg-inner transition-all"
|
||||||
title="Edit name / email"
|
title="Edit name / email"
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
@ -307,11 +307,11 @@ export default function UserDirectory({ users, currentUser, bookings, onDeleteUs
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(user.id)}
|
onClick={() => handleDelete(user.id)}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
className="p-1.5 rounded-lg text-slate-500 hover:text-rose-400 hover:bg-slate-900 transition-all disabled:opacity-40"
|
className="p-1.5 rounded-lg text-fg-faint hover:text-rose hover:bg-inner transition-all disabled:opacity-40"
|
||||||
title="Delete user"
|
title="Delete user"
|
||||||
>
|
>
|
||||||
{isDeleting
|
{isDeleting
|
||||||
? <span className="w-3.5 h-3.5 border-2 border-slate-600 border-t-rose-400 rounded-full animate-spin inline-block" />
|
? <span className="w-3.5 h-3.5 border-2 border-line-strong border-t-rose rounded-full animate-spin inline-block" />
|
||||||
: <Trash2 className="w-3.5 h-3.5" />}
|
: <Trash2 className="w-3.5 h-3.5" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
959
src/index.css
959
src/index.css
@ -6,6 +6,38 @@
|
|||||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
|
--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 ────────────────────────────────────────── */
|
/* ── AirIT brand tokens ────────────────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
--airit-navy: #003A70;
|
--airit-navy: #003A70;
|
||||||
@ -18,21 +50,37 @@
|
|||||||
--airit-border: #D6DADF;
|
--airit-border: #D6DADF;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CSS custom properties ─────────────────────────────────────── */
|
/* ── Theme values: DARK (default) ──────────────────────────────── */
|
||||||
:root {
|
:root {
|
||||||
|
/* neutrals */
|
||||||
--bg: #0b0f19;
|
--bg: #0b0f19;
|
||||||
--bg-header: #0f172a;
|
--bg-header: #0f172a;
|
||||||
--bg-card: #1e293b;
|
--bg-card: #181f2b;
|
||||||
--bg-inner: #090d16;
|
--bg-inner: #0c1119;
|
||||||
--bg-input: #020408;
|
--bg-input: #0a0e16;
|
||||||
--border: #1e293b;
|
--border: #283142;
|
||||||
--border-muted:#334155;
|
--border-muted:#3a4659;
|
||||||
--text: #f1f5f9;
|
--text: #e9eef5;
|
||||||
--text-muted: #94a3b8;
|
--text-muted: #94a3b8;
|
||||||
--text-label: #cbd5e1;
|
--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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Theme values: LIGHT ───────────────────────────────────────── */
|
||||||
:root.light {
|
:root.light {
|
||||||
|
/* neutrals */
|
||||||
--bg: #f1f5f9;
|
--bg: #f1f5f9;
|
||||||
--bg-header: #ffffff;
|
--bg-header: #ffffff;
|
||||||
--bg-card: #ffffff;
|
--bg-card: #ffffff;
|
||||||
@ -42,879 +90,40 @@
|
|||||||
--border-muted:#cbd5e1;
|
--border-muted:#cbd5e1;
|
||||||
--text: #0f172a;
|
--text: #0f172a;
|
||||||
--text-muted: #475569;
|
--text-muted: #475569;
|
||||||
--text-label: #334155;
|
--text-faint: #94a3b8;
|
||||||
}
|
--overlay: rgba(15, 23, 42, 0.45);
|
||||||
|
|
||||||
/* ── LIGHT MODE OVERRIDES ─────────────────────────────────────── */
|
/* accents */
|
||||||
|
--success: #059669; --success-soft: #d1fae5; --success-line: #6ee7b7;
|
||||||
/* Root / body */
|
--info: #0891b2; --info-soft: #cffafe; --info-line: #67e8f9;
|
||||||
:root.light body,
|
--primary: #4f46e5; --primary-soft: #e0e7ff; --primary-line: #a5b4fc;
|
||||||
:root.light #main-root {
|
--warning: #d97706; --warning-soft: #fef3c7; --warning-line: #fde68a;
|
||||||
background-color: var(--bg) !important;
|
--danger: #dc2626; --danger-soft: #fee2e2; --danger-line: #fca5a5;
|
||||||
color: var(--text) !important;
|
--rose: #be123c; --rose-soft: #ffe4e6; --rose-line: #fca5a5;
|
||||||
}
|
--violet: #7c3aed; --violet-soft: #ede9fe; --violet-line: #c4b5fd;
|
||||||
|
--sky: #0284c7; --sky-soft: #e0f2fe; --sky-line: #7dd3fc;
|
||||||
/* ── Backgrounds: all dark hex variants > card/inner */
|
--orange: #ea580c; --orange-soft: #fff7ed; --orange-line: #fdba74;
|
||||||
:root.light .bg-\[\#0B0F19\],
|
--blue: #1d4ed8; --blue-soft: #dbeafe; --blue-line: #93c5fd;
|
||||||
:root.light .bg-\[\#0b0f19\] {
|
}
|
||||||
background-color: var(--bg) !important;
|
|
||||||
}
|
/* ── Base element styling ──────────────────────────────────────────
|
||||||
|
The component layer now consumes the semantic tokens above directly,
|
||||||
:root.light .bg-\[\#0F172A\],
|
so the old ~260-rule `:root.light` utility-override block is gone.
|
||||||
:root.light .bg-\[\#0f172a\] {
|
Only genuinely global, theme-agnostic rules remain here. */
|
||||||
background-color: var(--bg-header) !important;
|
|
||||||
border-color: var(--border) !important;
|
/* AirIT badge - always white text on navy, regardless of theme */
|
||||||
}
|
|
||||||
|
|
||||||
: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 {
|
.airit-badge {
|
||||||
color: #ffffff !important;
|
color: #ffffff;
|
||||||
background-color: var(--airit-navy) !important;
|
background-color: var(--airit-navy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Text selection ───────────────────────────────────────────── */
|
/* Text selection - subtle brand tint in both themes */
|
||||||
:root.light ::selection {
|
::selection {
|
||||||
background-color: rgba(5, 150, 105, 0.2) !important;
|
background-color: rgba(16, 185, 129, 0.25);
|
||||||
color: #047857 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Reservation Details Modal (BookingDetailsModal) ──────────── */
|
/* Date picker calendar icon - invert to dark so it reads on light inputs */
|
||||||
|
|
||||||
/* 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 {
|
:root.light input[type="date"]::-webkit-calendar-picker-indicator {
|
||||||
filter: invert(0.6);
|
filter: invert(0.6);
|
||||||
cursor: pointer;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -50,10 +50,10 @@ export interface Booking {
|
|||||||
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
|
||||||
notified: boolean;
|
notified: boolean;
|
||||||
emailSent?: boolean;
|
emailSent?: boolean;
|
||||||
ansibleSetupTriggered?: boolean;
|
semaphoreSetupTriggered?: boolean;
|
||||||
ansibleTeardownTriggered?: boolean;
|
semaphoreTeardownTriggered?: boolean;
|
||||||
ansibleSetupJobId?: string;
|
semaphoreSetupJobId?: string;
|
||||||
ansibleTeardownJobId?: string;
|
semaphoreTeardownJobId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LogEntry {
|
export interface LogEntry {
|
||||||
|
|||||||
Reference in New Issue
Block a user