feat(topology): add personal/global scope to lab templates
Labs can now be marked as Personal or Global when creating or editing. Personal topologies are visible only to the owner and admins; others cannot see, book, or edit them. Global topologies are visible to all but editable only by the creator, admins, or legacy (migrated) labs. - DB: idempotent ALTER TABLE adds scope + ownerId columns to labs - API: POST sets ownerId from JWT; PUT/DELETE enforce ownership (403 for unauthorized edits; legacy ownerId='' remains freely editable) - Types: LabTemplate extended with scope and ownerId fields - LabTemplates UI: sectioned list (My / Global / Others' Personal), Personal/Global toggle in form, Lock/Globe badges on cards, edit+delete buttons hidden for non-owners - BookingCalendar: personal labs filtered from selects/quick booking, optgroup grouping for Global vs Personal in topology dropdown - Light mode: add missing bg-slate-950/50 and border-slate-800/50 overrides so the Global badge renders correctly
This commit is contained in:
@ -99,6 +99,10 @@ db.exec(`
|
|||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
// Idempotent migrations — SQLite throws on duplicate column; the catch makes them safe to re-run.
|
||||||
|
try { db.prepare("ALTER TABLE labs ADD COLUMN scope TEXT NOT NULL DEFAULT 'global'").run(); } catch {}
|
||||||
|
try { db.prepare("ALTER TABLE labs ADD COLUMN ownerId TEXT NOT NULL DEFAULT ''").run(); } catch {}
|
||||||
|
|
||||||
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
// Seed default settings — INSERT OR IGNORE writes a key only if it is absent.
|
||||||
const DEFAULT_SETTINGS: Record<string, string> = {
|
const DEFAULT_SETTINGS: Record<string, string> = {
|
||||||
azure_enabled: 'false',
|
azure_enabled: 'false',
|
||||||
|
|||||||
42
server.ts
42
server.ts
@ -526,6 +526,8 @@ async function startServer() {
|
|||||||
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
|
deviceIds: JSON.parse(r.deviceIds), topology: JSON.parse(r.topology),
|
||||||
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
|
semaphoreSetupTemplateId: r.semaphoreSetupTemplateId || '',
|
||||||
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
|
semaphoreTeardownTemplateId: r.semaphoreTeardownTemplateId || '',
|
||||||
|
scope: (r.scope === 'personal' ? 'personal' : 'global') as 'global' | 'personal',
|
||||||
|
ownerId: r.ownerId ?? '',
|
||||||
}));
|
}));
|
||||||
res.json(labs);
|
res.json(labs);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@ -535,40 +537,54 @@ async function startServer() {
|
|||||||
|
|
||||||
app.post('/api/labs', requireAuth, (req, res) => {
|
app.post('/api/labs', requireAuth, (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body;
|
||||||
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
if (!name || !deviceIds || !Array.isArray(deviceIds)) {
|
||||||
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
return res.status(400).json({ error: 'Missing name or associated device configurations.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global';
|
||||||
|
const ownerId = req.user!.userId;
|
||||||
const id = uid("lab");
|
const id = uid("lab");
|
||||||
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
db.prepare(`INSERT INTO labs (id, name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope, ownerId) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||||
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '');
|
.run(id, name, description || '', contactPerson || '', location || '', JSON.stringify(deviceIds), JSON.stringify(topology || []), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, ownerId);
|
||||||
|
|
||||||
addLog('maintenance',
|
addLog('maintenance',
|
||||||
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
`Released a new lab template profile "${name}" (${location || 'Staging Area'}) for engineering teams.`,
|
||||||
{ 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;
|
||||||
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 || '' });
|
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 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/labs/:id', requireAuth, (req, res) => {
|
app.put('/api/labs/:id', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId } = req.body;
|
const { name, description, contactPerson, location, deviceIds, topology, semaphoreSetupTemplateId, semaphoreTeardownTemplateId, scope } = req.body;
|
||||||
|
|
||||||
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ? WHERE id = ?`)
|
const existing = db.prepare('SELECT ownerId, scope FROM labs WHERE id = ?').get(id) as any;
|
||||||
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', id);
|
if (!existing) return res.status(404).json({ error: 'Lab template not found.' });
|
||||||
|
|
||||||
|
const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any;
|
||||||
|
const isAdmin = reqUser?.role?.toLowerCase() === 'admin';
|
||||||
|
const isOwner = existing.ownerId === req.user!.userId;
|
||||||
|
const isLegacy = existing.ownerId === '';
|
||||||
|
if (!isOwner && !isAdmin && !isLegacy) {
|
||||||
|
return res.status(403).json({ error: 'You do not have permission to edit this topology.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeScope: 'global' | 'personal' = scope === 'personal' ? 'personal' : 'global';
|
||||||
|
db.prepare(`UPDATE labs SET name = ?, description = ?, contactPerson = ?, location = ?, deviceIds = ?, topology = ?, semaphoreSetupTemplateId = ?, semaphoreTeardownTemplateId = ?, scope = ? WHERE id = ?`)
|
||||||
|
.run(name, description, contactPerson, location, JSON.stringify(deviceIds), JSON.stringify(topology), semaphoreSetupTemplateId || '', semaphoreTeardownTemplateId || '', safeScope, id);
|
||||||
|
|
||||||
addLog('maintenance',
|
addLog('maintenance',
|
||||||
`Modified the active topology mapping schema for the "${name}" lab template.`,
|
`Modified the active topology mapping schema for the "${name}" lab template.`,
|
||||||
{ 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;
|
||||||
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 || '' });
|
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 });
|
||||||
}
|
}
|
||||||
@ -580,6 +596,14 @@ async function startServer() {
|
|||||||
const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
const lab = db.prepare('SELECT * FROM labs WHERE id = ?').get(id) as any;
|
||||||
if (!lab) return res.status(404).json({ error: 'Lab template not found.' });
|
if (!lab) return res.status(404).json({ error: 'Lab template not found.' });
|
||||||
|
|
||||||
|
const reqUser = db.prepare('SELECT role FROM users WHERE id = ?').get(req.user!.userId) as any;
|
||||||
|
const isAdmin = reqUser?.role?.toLowerCase() === 'admin';
|
||||||
|
const isOwner = lab.ownerId === req.user!.userId;
|
||||||
|
const isLegacy = lab.ownerId === '';
|
||||||
|
if (!isOwner && !isAdmin && !isLegacy) {
|
||||||
|
return res.status(403).json({ error: 'You do not have permission to delete this topology.' });
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
|
db.prepare('DELETE FROM labs WHERE id = ?').run(id);
|
||||||
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
|
db.prepare(`UPDATE bookings SET status = 'cancelled' WHERE labId = ? AND status = 'upcoming'`).run(id);
|
||||||
|
|
||||||
|
|||||||
@ -299,7 +299,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Lab handlers
|
// Lab handlers
|
||||||
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
|
const handleAddLab = async (newLab: Omit<LabTemplate, 'id' | 'ownerId'>) => {
|
||||||
try {
|
try {
|
||||||
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -597,6 +597,7 @@ export default function App() {
|
|||||||
<LabTemplates
|
<LabTemplates
|
||||||
labs={labs}
|
labs={labs}
|
||||||
devices={devices}
|
devices={devices}
|
||||||
|
currentUser={currentUser!}
|
||||||
onAddLab={handleAddLab}
|
onAddLab={handleAddLab}
|
||||||
onUpdateLab={handleUpdateLab}
|
onUpdateLab={handleUpdateLab}
|
||||||
onDeleteLab={handleDeleteLab}
|
onDeleteLab={handleDeleteLab}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useEffect } from 'react';
|
||||||
import { Booking, LabTemplate, Device, User } from '../types';
|
import { Booking, LabTemplate, Device, User } from '../types';
|
||||||
import {
|
import {
|
||||||
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
|
||||||
@ -172,11 +172,23 @@ export default function BookingCalendar({
|
|||||||
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
|
||||||
}, [quickDuration]);
|
}, [quickDuration]);
|
||||||
|
|
||||||
|
const bookableLabs = useMemo(() => labs.filter(l =>
|
||||||
|
l.scope === 'global' ||
|
||||||
|
l.ownerId === currentUser.id ||
|
||||||
|
currentUser.role?.toLowerCase() === 'admin'
|
||||||
|
), [labs, currentUser.id, currentUser.role]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedLabId && !bookableLabs.find(l => l.id === selectedLabId)) {
|
||||||
|
setSelectedLabId(bookableLabs[0]?.id || '');
|
||||||
|
}
|
||||||
|
}, [bookableLabs]);
|
||||||
|
|
||||||
// A lab is quick-bookable when every device is free (regardless of online status).
|
// A lab is quick-bookable when every device is free (regardless of online status).
|
||||||
const availableLabs = useMemo(() => labs.filter(lab =>
|
const availableLabs = useMemo(() => bookableLabs.filter(lab =>
|
||||||
lab.deviceIds.length > 0 &&
|
lab.deviceIds.length > 0 &&
|
||||||
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
lab.deviceIds.every(dId => !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs))
|
||||||
), [labs, devices, bookings, quickWindow]);
|
), [bookableLabs, devices, bookings, quickWindow]);
|
||||||
|
|
||||||
const availableDevices = useMemo(() => devices.filter(dev =>
|
const availableDevices = useMemo(() => devices.filter(dev =>
|
||||||
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
|
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
|
||||||
@ -576,9 +588,20 @@ export default function BookingCalendar({
|
|||||||
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-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
|
||||||
>
|
>
|
||||||
{labs.map((l) => (
|
{bookableLabs.filter(l => l.scope === 'global').length > 0 && (
|
||||||
|
<optgroup label="Global Topologies">
|
||||||
|
{bookableLabs.filter(l => l.scope === 'global').map((l) => (
|
||||||
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').length > 0 && (
|
||||||
|
<optgroup label="My Personal Topologies">
|
||||||
|
{bookableLabs.filter(l => l.scope === 'personal').map((l) => (
|
||||||
|
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -4,17 +4,18 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { LabTemplate, Device, TopologyLink } from '../types';
|
import { LabTemplate, Device, TopologyLink, User } from '../types';
|
||||||
import TopologyPanel from './TopologyPanel';
|
import TopologyPanel from './TopologyPanel';
|
||||||
import {
|
import {
|
||||||
Server, Plus, Edit3, Trash, User, MapPin,
|
Server, Plus, Edit3, Trash, User as UserIcon, MapPin,
|
||||||
Layers, ChevronRight, Save, X, Check, Pencil, Terminal,
|
Layers, ChevronRight, X, Check, Pencil, Terminal, Lock, Globe,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface LabTemplatesProps {
|
interface LabTemplatesProps {
|
||||||
labs: LabTemplate[];
|
labs: LabTemplate[];
|
||||||
devices: Device[];
|
devices: Device[];
|
||||||
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
|
currentUser: User;
|
||||||
|
onAddLab: (lab: Omit<LabTemplate, 'id' | 'ownerId'>) => void;
|
||||||
onUpdateLab: (lab: LabTemplate) => void;
|
onUpdateLab: (lab: LabTemplate) => void;
|
||||||
onDeleteLab: (id: string) => void;
|
onDeleteLab: (id: string) => void;
|
||||||
onOpenDeviceDetails: (device: Device) => void;
|
onOpenDeviceDetails: (device: Device) => void;
|
||||||
@ -23,6 +24,7 @@ interface LabTemplatesProps {
|
|||||||
export default function LabTemplates({
|
export default function LabTemplates({
|
||||||
labs,
|
labs,
|
||||||
devices,
|
devices,
|
||||||
|
currentUser,
|
||||||
onAddLab,
|
onAddLab,
|
||||||
onUpdateLab,
|
onUpdateLab,
|
||||||
onDeleteLab,
|
onDeleteLab,
|
||||||
@ -49,6 +51,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: string[];
|
deviceIds: string[];
|
||||||
semaphoreSetupTemplateId: string;
|
semaphoreSetupTemplateId: string;
|
||||||
semaphoreTeardownTemplateId: string;
|
semaphoreTeardownTemplateId: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
}>({
|
}>({
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
@ -57,6 +60,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [],
|
deviceIds: [],
|
||||||
semaphoreSetupTemplateId: '',
|
semaphoreSetupTemplateId: '',
|
||||||
semaphoreTeardownTemplateId: '',
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate filtered devices associated with selected lab
|
// Calculate filtered devices associated with selected lab
|
||||||
@ -75,6 +79,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [],
|
deviceIds: [],
|
||||||
semaphoreSetupTemplateId: '',
|
semaphoreSetupTemplateId: '',
|
||||||
semaphoreTeardownTemplateId: '',
|
semaphoreTeardownTemplateId: '',
|
||||||
|
scope: 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -91,6 +96,7 @@ export default function LabTemplates({
|
|||||||
deviceIds: [...lab.deviceIds],
|
deviceIds: [...lab.deviceIds],
|
||||||
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
semaphoreSetupTemplateId: lab.semaphoreSetupTemplateId || '',
|
||||||
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
semaphoreTeardownTemplateId: lab.semaphoreTeardownTemplateId || '',
|
||||||
|
scope: lab.scope ?? 'global',
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
};
|
};
|
||||||
@ -137,20 +143,107 @@ export default function LabTemplates({
|
|||||||
topology: tempLinks,
|
topology: tempLinks,
|
||||||
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
semaphoreSetupTemplateId: formData.semaphoreSetupTemplateId,
|
||||||
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
semaphoreTeardownTemplateId: formData.semaphoreTeardownTemplateId,
|
||||||
|
scope: formData.scope,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (formMode === 'add') {
|
if (formMode === 'add') {
|
||||||
onAddLab(savedLabData);
|
onAddLab(savedLabData);
|
||||||
} else if (formMode === 'edit' && formData.id) {
|
} else if (formMode === 'edit' && formData.id) {
|
||||||
|
const existing = labs.find(l => l.id === formData.id);
|
||||||
onUpdateLab({
|
onUpdateLab({
|
||||||
...savedLabData,
|
...savedLabData,
|
||||||
id: formData.id
|
id: formData.id,
|
||||||
|
ownerId: existing?.ownerId ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isAdmin = currentUser.role?.toLowerCase() === 'admin';
|
||||||
|
const canEdit = (lab: LabTemplate) => isAdmin || lab.ownerId === currentUser.id || lab.ownerId === '';
|
||||||
|
|
||||||
|
const myPersonalLabs = labs.filter(l => l.scope === 'personal' && l.ownerId === currentUser.id);
|
||||||
|
const globalLabs = labs.filter(l => l.scope === 'global');
|
||||||
|
const othersPersonal = isAdmin ? labs.filter(l => l.scope === 'personal' && l.ownerId !== currentUser.id) : [];
|
||||||
|
|
||||||
|
const renderLabCard = (lab: LabTemplate) => {
|
||||||
|
const isSelected = selectedLab?.id === lab.id;
|
||||||
|
const editable = canEdit(lab);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={lab.id}
|
||||||
|
onClick={() => setSelectedLab(lab)}
|
||||||
|
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-slate-900 border-emerald-500'
|
||||||
|
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
||||||
|
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
||||||
|
{editable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => handleOpenEdit(lab)}
|
||||||
|
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
||||||
|
title="Edit template configuration"
|
||||||
|
>
|
||||||
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
||||||
|
onDeleteLab(lab.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-rose-400 p-0.5"
|
||||||
|
title="Delete template"
|
||||||
|
>
|
||||||
|
<Trash className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
||||||
|
{lab.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<UserIcon className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{lab.contactPerson}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate">{lab.location}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2.5 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
||||||
|
{lab.deviceIds.length} connected devices
|
||||||
|
</span>
|
||||||
|
{lab.scope === 'personal' ? (
|
||||||
|
<span className="text-[10px] font-mono text-indigo-400 bg-indigo-950/50 px-2 py-0.5 rounded border border-indigo-900/50 flex items-center gap-1">
|
||||||
|
<Lock className="w-2.5 h-2.5" /> Personal
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[10px] font-mono text-slate-400 bg-slate-950/50 px-2 py-0.5 rounded border border-slate-800/50 flex items-center gap-1">
|
||||||
|
<Globe className="w-2.5 h-2.5" /> Global
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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">
|
||||||
|
|
||||||
@ -174,68 +267,29 @@ export default function LabTemplates({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Labs templates list */}
|
{/* Labs templates list — sectioned */}
|
||||||
<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">
|
||||||
{labs.map((lab) => {
|
{myPersonalLabs.length > 0 && (
|
||||||
const isSelected = selectedLab?.id === lab.id;
|
<>
|
||||||
return (
|
<p className="text-[10px] font-mono uppercase tracking-widest text-indigo-400 px-1">My Topologies</p>
|
||||||
<div
|
{myPersonalLabs.map(renderLabCard)}
|
||||||
key={lab.id}
|
</>
|
||||||
onClick={() => setSelectedLab(lab)}
|
)}
|
||||||
className={`p-4 rounded-xl border transition-all cursor-pointer relative ${
|
{globalLabs.length > 0 && (
|
||||||
isSelected
|
<>
|
||||||
? 'bg-slate-900 border-emerald-500'
|
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Global Topologies</p>
|
||||||
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
|
{globalLabs.map(renderLabCard)}
|
||||||
}`}
|
</>
|
||||||
>
|
)}
|
||||||
<div className="flex justify-between items-start">
|
{othersPersonal.length > 0 && (
|
||||||
<h3 className="font-bold text-sm text-white">{lab.name}</h3>
|
<>
|
||||||
<div className="flex gap-1.5" onClick={e => e.stopPropagation()}>
|
<p className="text-[10px] font-mono uppercase tracking-widest text-slate-500 px-1 mt-2">Others' Personal</p>
|
||||||
<button
|
{othersPersonal.map(renderLabCard)}
|
||||||
onClick={() => handleOpenEdit(lab)}
|
</>
|
||||||
className="text-slate-400 hover:text-indigo-400 p-0.5"
|
)}
|
||||||
title="Edit template configuration"
|
{labs.length === 0 && (
|
||||||
>
|
<p className="text-xs text-slate-500 text-center py-8">No topology templates yet.</p>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
)}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(`Are you sure you want to delete lab template "${lab.name}"? Active/completed scheduling logs are retained in the system.`)) {
|
|
||||||
onDeleteLab(lab.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-slate-400 hover:text-rose-400 p-0.5"
|
|
||||||
title="Delete template"
|
|
||||||
>
|
|
||||||
<Trash className="w-3.5 h-3.5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-xs text-slate-400 mt-1 line-clamp-2 leading-relaxed">
|
|
||||||
{lab.description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-3 pt-3 border-t border-slate-800/80 grid grid-cols-2 gap-1 text-[10px] text-slate-350">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<User className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
||||||
<span className="truncate">{lab.contactPerson}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<MapPin className="w-3.5 h-3.5 text-slate-500 shrink-0" />
|
|
||||||
<span className="truncate">{lab.location}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center justify-between">
|
|
||||||
<span className="text-[10px] font-mono text-emerald-400 bg-emerald-950/50 px-2 py-0.5 rounded border border-emerald-900/50">
|
|
||||||
{lab.deviceIds.length} connected devices
|
|
||||||
</span>
|
|
||||||
<ChevronRight className={`w-4 h-4 text-slate-500 transition-transform ${isSelected ? 'translate-x-1 text-emerald-400' : ''}`} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -254,7 +308,7 @@ export default function LabTemplates({
|
|||||||
</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-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
|
||||||
<User className="w-3.5 h-3.5 text-slate-400" />
|
<UserIcon className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-slate-400 leading-none">Primary Contact</p>
|
<p className="text-slate-400 leading-none">Primary Contact</p>
|
||||||
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
|
||||||
@ -389,6 +443,35 @@ export default function LabTemplates({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scope toggle */}
|
||||||
|
<div className="border-t border-slate-800 pt-3">
|
||||||
|
<label className="block text-slate-300 font-semibold mb-1.5">Visibility</label>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, scope: 'global' })}
|
||||||
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
formData.scope === 'global'
|
||||||
|
? 'bg-slate-800 border-slate-500 text-slate-200'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Globe className="w-3.5 h-3.5" /> Global — visible to all
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, scope: 'personal' })}
|
||||||
|
className={`py-2 rounded-lg text-xs font-semibold border transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
formData.scope === 'personal'
|
||||||
|
? 'bg-indigo-900/30 border-indigo-500 text-indigo-300'
|
||||||
|
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Lock className="w-3.5 h-3.5" /> Personal — only you
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Hardware checklist */}
|
{/* Hardware checklist */}
|
||||||
<div className="border-t border-slate-800 pt-3">
|
<div className="border-t border-slate-800 pt-3">
|
||||||
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
|
||||||
|
|||||||
@ -109,6 +109,7 @@
|
|||||||
:root.light .bg-slate-950\/20,
|
:root.light .bg-slate-950\/20,
|
||||||
:root.light .bg-slate-950\/30,
|
:root.light .bg-slate-950\/30,
|
||||||
:root.light .bg-slate-950\/40,
|
:root.light .bg-slate-950\/40,
|
||||||
|
:root.light .bg-slate-950\/50,
|
||||||
:root.light .bg-slate-950\/60,
|
:root.light .bg-slate-950\/60,
|
||||||
:root.light .bg-slate-950\/80 {
|
:root.light .bg-slate-950\/80 {
|
||||||
background-color: rgba(241, 245, 249, 0.85) !important;
|
background-color: rgba(241, 245, 249, 0.85) !important;
|
||||||
@ -730,6 +731,12 @@
|
|||||||
|
|
||||||
/* ── Missing border opacity variants ─────────────────────────────── */
|
/* ── 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 */
|
/* slate-700 with opacity */
|
||||||
:root.light .border-slate-700\/40,
|
:root.light .border-slate-700\/40,
|
||||||
:root.light .border-slate-700\/50,
|
:root.light .border-slate-700\/50,
|
||||||
|
|||||||
@ -36,6 +36,8 @@ export interface LabTemplate {
|
|||||||
topology: TopologyLink[];
|
topology: TopologyLink[];
|
||||||
semaphoreSetupTemplateId?: string;
|
semaphoreSetupTemplateId?: string;
|
||||||
semaphoreTeardownTemplateId?: string;
|
semaphoreTeardownTemplateId?: string;
|
||||||
|
scope: 'global' | 'personal';
|
||||||
|
ownerId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Booking {
|
export interface Booking {
|
||||||
|
|||||||
Reference in New Issue
Block a user