Initial commit

This commit is contained in:
Brückner
2026-06-03 15:20:06 +02:00
commit eed01b9665
34 changed files with 11921 additions and 0 deletions

582
src/App.tsx Normal file
View File

@ -0,0 +1,582 @@
import React, { useState, useEffect } from 'react';
import { User, Device, LabTemplate, Booking, LogEntry, QuickLink } from './types';
import { authFetch, getToken, getStoredUser, clearSession } from './lib/auth';
import Header, { GhostGridLogo } from './components/Header';
import Dashboard from './components/Dashboard';
import BookingCalendar from './components/BookingCalendar';
import DeviceInventory from './components/DeviceInventory';
import LabTemplates from './components/LabTemplates';
import Logbook from './components/Logbook';
import LinkDashboard from './components/LinkDashboard';
import UserDirectory from './components/UserDirectory';
import BookingDetailsModal from './components/BookingDetailsModal';
import LoginPage from './components/LoginPage';
import RegisterPage from './components/RegisterPage';
import {
LayoutDashboard, Calendar, Server, Layers, History, Link as LinkIcon, Users,
PanelLeftClose, PanelLeftOpen,
} from 'lucide-react';
type AuthView = 'login' | 'register';
export default function App() {
// Auth state
const [currentUser, setCurrentUser] = useState<User | null>(() => getStoredUser());
const [authView, setAuthView] = useState<AuthView>('login');
const [authChecked, setAuthChecked] = useState(false);
// App data
const [users, setUsers] = useState<User[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [labs, setLabs] = useState<LabTemplate[]>([]);
const [bookings, setBookings] = useState<Booking[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [links, setLinks] = useState<QuickLink[]>([]);
const [loading, setLoading] = useState(false);
const [selectedBookingForDetails, setSelectedBookingForDetails] = useState<Booking | null>(null);
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
return (localStorage.getItem('ghostgrid_theme') as 'dark' | 'light') || 'dark';
});
const [activeTab, setActiveTab] = useState<string>('dashboard');
const [navCollapsed, setNavCollapsed] = useState<boolean>(() => localStorage.getItem('ghostgrid_nav_collapsed') === '1');
useEffect(() => {
localStorage.setItem('ghostgrid_nav_collapsed', navCollapsed ? '1' : '0');
}, [navCollapsed]);
const [notifications, setNotifications] = useState<string[]>([]);
const [remindedBookings, setRemindedBookings] = useState<Set<string>>(new Set());
const [inventoryHighlightDevice, setInventoryHighlightDevice] = useState<Device | null>(null);
useEffect(() => {
const root = document.documentElement;
if (theme === 'light') root.classList.add('light');
else root.classList.remove('light');
localStorage.setItem('ghostgrid_theme', theme);
}, [theme]);
// Verify stored token on startup
useEffect(() => {
async function verifyToken() {
const token = getToken();
if (!token) {
setAuthChecked(true);
return;
}
try {
const res = await authFetch('/api/auth/me');
if (res.ok) {
const user = await res.json();
setCurrentUser(user);
} else {
clearSession();
setCurrentUser(null);
}
} catch {
// Server unreachable - keep stored user, will fail on data load
} finally {
setAuthChecked(true);
}
}
verifyToken();
}, []);
// Load data once authenticated
useEffect(() => {
if (!currentUser) return;
async function loadData() {
setLoading(true);
try {
const [usersRes, devicesRes, labsRes, bookingsRes, logsRes, linksRes] = await Promise.all([
authFetch('/api/users'),
authFetch('/api/devices'),
authFetch('/api/labs'),
authFetch('/api/bookings'),
authFetch('/api/logs'),
authFetch('/api/links'),
]);
if (usersRes.ok) setUsers(await usersRes.json());
if (devicesRes.ok) setDevices(await devicesRes.json());
if (labsRes.ok) setLabs(await labsRes.json());
if (bookingsRes.ok) setBookings(await bookingsRes.json());
if (logsRes.ok) setLogs(await logsRes.json());
if (linksRes.ok) setLinks(await linksRes.json());
} catch (err) {
console.error('[App] Failed to load data:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [currentUser]);
// Cyclic device-status check: poll the inventory every 30s so CheckMK-driven
// status changes (online/offline) surface without a manual reload. The backend
// is the source of truth - it syncs each device's status from the CheckMK API.
useEffect(() => {
if (!currentUser) return;
const refreshDevices = async () => {
try {
const res = await authFetch('/api/devices');
if (res.ok) setDevices(await res.json());
} catch {
// transient network/server hiccup - keep last known state, retry next tick
}
};
const id = setInterval(refreshDevices, 30_000);
return () => clearInterval(id);
}, [currentUser]);
// Upcoming-booking reminder - checks every 60s, fires once per booking
useEffect(() => {
if (!currentUser || bookings.length === 0) return;
const check = () => {
const now = Date.now();
bookings
.filter(b => b.userId === currentUser.id && b.status === 'upcoming')
.forEach(b => {
const startsIn = new Date(b.startDateTime).getTime() - now;
if (startsIn > 0 && startsIn <= 30 * 60_000 && !remindedBookings.has(b.id)) {
const labName = labs.find(l => l.id === b.labId)?.name ?? 'your lab';
const mins = Math.ceil(startsIn / 60_000);
setNotifications(prev => [`Reminder: "${labName}" starts in ${mins} min.`, ...prev]);
setRemindedBookings(prev => new Set([...prev, b.id]));
}
});
};
check();
const id = setInterval(check, 60_000);
return () => clearInterval(id);
}, [bookings, currentUser, labs, remindedBookings]);
const handleLogin = (user: User) => {
setCurrentUser(user);
};
const handleLogout = () => {
clearSession();
setCurrentUser(null);
setUsers([]);
setDevices([]);
setLabs([]);
setBookings([]);
setLogs([]);
setLinks([]);
setActiveTab('dashboard');
};
// Booking handlers
const handleAddBooking = async (newB: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => {
try {
const res = await authFetch('/api/bookings', {
method: 'POST',
body: JSON.stringify({ ...newB, operatorName: currentUser!.name }),
});
if (res.ok) {
const data = await res.json();
setBookings(prev => [data.booking, ...prev]);
if (data.alertGenerated) setNotifications(prev => [data.alertGenerated, ...prev]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding booking:', err); }
};
const handleCancelBooking = async (bookingId: string) => {
try {
const booking = bookings.find(b => b.id === bookingId);
const res = await authFetch(`/api/bookings/${bookingId}`, {
method: 'PUT',
body: JSON.stringify({ status: 'cancelled', operatorName: currentUser!.name }),
});
if (res.ok) {
const updated = await res.json();
setBookings(prev => prev.map(b => b.id === bookingId ? updated : b));
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(updated);
const labName = booking ? (labs.find(l => l.id === booking.labId)?.name ?? 'Lab') : 'Lab';
setNotifications(prev => [`Booking cancelled: "${labName}" - slot has been released.`, ...prev]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error cancelling booking:', err); }
};
const handleDeleteBooking = async (bookingId: string) => {
try {
const res = await authFetch(`/api/bookings/${bookingId}`, { method: 'DELETE' });
if (res.ok) {
setBookings(prev => prev.filter(b => b.id !== bookingId));
if (selectedBookingForDetails?.id === bookingId) setSelectedBookingForDetails(null);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting booking:', err); }
};
// Device handlers
const handleAddDevice = async (newDev: Omit<Device, 'id'>) => {
try {
const res = await authFetch('/api/devices', { method: 'POST', body: JSON.stringify(newDev) });
if (res.ok) {
const created = await res.json();
setDevices(prev => [...prev, created]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding device:', err); }
};
const handleUpdateDevice = async (updatedDev: Device) => {
try {
const res = await authFetch(`/api/devices/${updatedDev.id}`, {
method: 'PUT',
body: JSON.stringify({ ...updatedDev, operatorName: currentUser!.name }),
});
if (res.ok) {
const updated = await res.json();
setDevices(prev => prev.map(d => d.id === updatedDev.id ? updated : d));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error updating device:', err); }
};
const handleDeleteDevice = async (id: string) => {
try {
const res = await authFetch(`/api/devices/${id}`, { method: 'DELETE' });
if (res.ok) {
setDevices(prev => prev.filter(d => d.id !== id));
const labsRes = await authFetch('/api/labs');
if (labsRes.ok) setLabs(await labsRes.json());
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting device:', err); }
};
// Lab handlers
const handleAddLab = async (newLab: Omit<LabTemplate, 'id'>) => {
try {
const res = await authFetch('/api/labs', { method: 'POST', body: JSON.stringify(newLab) });
if (res.ok) {
const created = await res.json();
setLabs(prev => [...prev, created]);
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error adding lab:', err); }
};
const handleUpdateLab = async (updatedLab: LabTemplate) => {
try {
const res = await authFetch(`/api/labs/${updatedLab.id}`, { method: 'PUT', body: JSON.stringify(updatedLab) });
if (res.ok) {
const data = await res.json();
setLabs(prev => prev.map(l => l.id === updatedLab.id ? data : l));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error updating lab:', err); }
};
const handleDeleteLab = async (id: string) => {
try {
const res = await authFetch(`/api/labs/${id}`, { method: 'DELETE' });
if (res.ok) {
setLabs(prev => prev.filter(l => l.id !== id));
setBookings(prev => prev.map(b => b.labId === id && b.status === 'upcoming' ? { ...b, status: 'cancelled' as const } : b));
const logsRes = await authFetch('/api/logs');
if (logsRes.ok) setLogs(await logsRes.json());
}
} catch (err) { console.error('[App] Error deleting lab:', err); }
};
const handleAddLogManually = async (newLogEntry: Omit<LogEntry, 'id' | 'timestamp'>) => {
try {
const res = await authFetch('/api/logs', { method: 'POST', body: JSON.stringify(newLogEntry) });
if (res.ok) { const log = await res.json(); setLogs(prev => [log, ...prev]); }
} catch (err) { console.error('[App] Error adding log:', err); }
};
// Quick-link handlers (shared link dashboard)
const handleAddLink = async (newLink: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => {
try {
const res = await authFetch('/api/links', { method: 'POST', body: JSON.stringify(newLink) });
if (res.ok) {
const created = await res.json();
setLinks(prev => [...prev, created]);
}
} catch (err) { console.error('[App] Error adding link:', err); }
};
const handleUpdateLink = async (updated: QuickLink) => {
try {
const res = await authFetch(`/api/links/${updated.id}`, { method: 'PUT', body: JSON.stringify(updated) });
if (res.ok) {
const data = await res.json();
setLinks(prev => prev.map(l => l.id === updated.id ? data : l));
}
} catch (err) { console.error('[App] Error updating link:', err); }
};
const handleDeleteLink = async (id: string) => {
try {
const res = await authFetch(`/api/links/${id}`, { method: 'DELETE' });
if (res.ok) setLinks(prev => prev.filter(l => l.id !== id));
} catch (err) { console.error('[App] Error deleting link:', err); }
};
const handleOpenDeviceDetailsFromTopology = (dev: Device) => {
setInventoryHighlightDevice(dev);
setActiveTab('devices');
};
const navigationGroups: { label: string | null; items: { id: string; label: string; icon: React.ReactNode }[] }[] = [
{
label: null,
items: [
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Lab Management',
items: [
{ id: 'calendar', label: 'Booking', icon: <Calendar className="w-4 h-4 shrink-0" /> },
{ id: 'devices', label: 'Inventory', icon: <Server className="w-4 h-4 shrink-0" /> },
{ id: 'labs', label: 'Topology', icon: <Layers className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Resources',
items: [
{ id: 'links', label: 'Quick Links', icon: <LinkIcon className="w-4 h-4 shrink-0" /> },
{ id: 'users', label: 'Team', icon: <Users className="w-4 h-4 shrink-0" /> },
],
},
{
label: 'Audit',
items: [
{ id: 'logs', label: 'Logbook', icon: <History className="w-4 h-4 shrink-0" /> },
],
},
];
// Startup check not done yet
if (!authChecked) {
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
<div className="text-center space-y-4">
<div className="p-4 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
<GhostGridLogo className="w-16 h-16 animate-pulse" />
</div>
<p className="text-xs text-slate-400 font-mono">booting...</p>
</div>
</div>
);
}
// Not logged in
if (!currentUser) {
if (authView === 'register') {
return <RegisterPage onLogin={handleLogin} onNavigateToLogin={() => setAuthView('login')} />;
}
return <LoginPage onLogin={handleLogin} onNavigateToRegister={() => setAuthView('register')} />;
}
// Loading data after login
if (loading) {
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex flex-col items-center justify-center p-4">
<div className="text-center space-y-6 max-w-sm">
<div className="p-4 bg-slate-950/80 border border-slate-850 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)] inline-flex">
<GhostGridLogo className="w-20 h-20 animate-pulse" />
</div>
<div className="space-y-2">
<h2 className="text-base font-bold tracking-tight text-white">GhostGrid Virtualization</h2>
<p className="text-xs text-slate-400 leading-normal">Mounting the SQLite file, walking the topology graph and pulling fresh box states. tail -f the spinner...</p>
<div className="inline-flex items-center gap-1 bg-cyan-950/40 border border-cyan-900/50 rounded-full px-2.5 py-0.5 text-[9px] font-mono text-cyan-400 font-semibold mt-1">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-400 animate-ping"></span>
SQLITE DATABASE HYDRATION ONGOING
</div>
</div>
</div>
</div>
);
}
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">
<Header
currentUser={currentUser}
bookings={bookings}
labs={labs}
notifications={notifications}
onClearNotifications={() => setNotifications([])}
theme={theme}
onThemeToggle={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
onLogout={handleLogout}
/>
<div className="flex-1 flex flex-col md:flex-row">
<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'}`}
id="nav-sidebar"
>
<div className="space-y-5">
{/* Collapse toggle */}
<div className={`flex items-center px-1 ${navCollapsed ? 'md:justify-center justify-between' : 'justify-between'}`}>
<span className={`text-[10px] uppercase font-mono font-bold tracking-wider text-slate-500 ${navCollapsed ? 'md:hidden' : 'block'}`}>Main Menu</span>
<button
onClick={() => setNavCollapsed(c => !c)}
title={navCollapsed ? 'Expand menu' : 'Collapse menu'}
className="p-1.5 rounded-md text-slate-400 hover:text-emerald-400 hover:bg-slate-900 transition-all hover:cursor-pointer"
>
{navCollapsed ? <PanelLeftOpen className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
</button>
</div>
<nav className="space-y-4">
{navigationGroups.map((group, gi) => (
<div key={gi} className="space-y-1">
{group.label && (
<span className={`text-[9px] uppercase font-mono font-bold tracking-wider text-slate-600 px-3 ${navCollapsed ? 'md:hidden block' : 'block'}`}>
{group.label}
</span>
)}
{/* Thin divider stands in for the group label when collapsed */}
{group.label && navCollapsed && <div className="hidden md:block h-px bg-slate-850 mx-2" />}
{group.items.map((item) => {
const isActive = activeTab === item.id;
return (
<button
key={item.id}
onClick={() => {
setActiveTab(item.id);
if (item.id !== 'devices') setInventoryHighlightDevice(null);
}}
title={navCollapsed ? item.label : undefined}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-semibold font-sans tracking-wide transition-all ${navCollapsed ? 'md:justify-center' : ''} ${
isActive
? 'bg-gradient-to-r from-emerald-500/10 to-teal-500/5 border-l-4 border-emerald-500 text-white shadow-[inset_1px_0_10px_rgba(16,185,129,0.03)]'
: 'text-slate-400 hover:text-white hover:bg-slate-900'
}`}
>
{item.icon}
<span className={navCollapsed ? 'md:hidden' : ''}>{item.label}</span>
</button>
);
})}
</div>
))}
</nav>
</div>
<div className={`bg-slate-950 p-4 border border-slate-900 rounded-xl space-y-2 mt-8 ${navCollapsed ? 'hidden' : 'hidden md:block'}`}>
<h4 className="text-[10px] text-emerald-400 font-mono font-bold">Overall Status</h4>
<div className="text-[11px] text-slate-400 leading-relaxed font-sans space-y-0.5">
<div>Active: <span className="text-emerald-400 font-semibold font-mono">{bookings.filter(b => b.status === 'active').length}</span></div>
<div>Upcoming: <span className="text-indigo-400 font-semibold font-mono">{bookings.filter(b => b.status === 'upcoming').length}</span></div>
<div>Online: <span className="text-white font-semibold font-mono">{devices.filter(d => d.checkMkUrl && d.status === 'online').length}<span className="text-slate-500">/{devices.length}</span></span> devices</div>
<div>Labs: <span className="text-white font-semibold font-mono">{labs.length}</span> configured</div>
</div>
<div className="h-1 bg-gradient-to-r from-emerald-500 to-teal-600 rounded-full w-full animate-pulse mt-2" /></div>
</aside>
<main className="flex-1 p-6 md:p-8 overflow-y-auto" id="main-content-display">
{activeTab === 'dashboard' && (
<Dashboard
currentUser={currentUser}
bookings={bookings}
labs={labs}
devices={devices}
links={links}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
onSelectBookingDetails={setSelectedBookingForDetails}
onNavigateToCalendar={() => setActiveTab('calendar')}
onNavigateToDevices={() => setActiveTab('devices')}
onNavigateToLabs={() => setActiveTab('labs')}
onNavigateToLinks={() => setActiveTab('links')}
/>
)}
{activeTab === 'calendar' && (
<BookingCalendar
bookings={bookings}
labs={labs}
devices={devices}
currentUser={currentUser}
onAddBooking={handleAddBooking}
onCancelBooking={handleCancelBooking}
onDeleteBooking={handleDeleteBooking}
onSelectBookingDetails={setSelectedBookingForDetails}
/>
)}
{activeTab === 'devices' && (
<DeviceInventory
devices={devices}
onAddDevice={handleAddDevice}
onUpdateDevice={handleUpdateDevice}
onDeleteDevice={handleDeleteDevice}
/>
)}
{activeTab === 'labs' && (
<LabTemplates
labs={labs}
devices={devices}
onAddLab={handleAddLab}
onUpdateLab={handleUpdateLab}
onDeleteLab={handleDeleteLab}
onOpenDeviceDetails={handleOpenDeviceDetailsFromTopology}
/>
)}
{activeTab === 'links' && (
<LinkDashboard
links={links}
currentUser={currentUser}
onAddLink={handleAddLink}
onUpdateLink={handleUpdateLink}
onDeleteLink={handleDeleteLink}
/>
)}
{activeTab === 'users' && (
<UserDirectory
users={users}
currentUser={currentUser}
bookings={bookings}
/>
)}
{activeTab === 'logs' && (
<Logbook
logs={logs}
devices={devices}
users={users}
currentUser={currentUser}
onAddLog={handleAddLogManually}
/>
)}
</main>
</div>
{selectedBookingForDetails && (
<BookingDetailsModal
booking={selectedBookingForDetails}
labs={labs}
devices={devices}
users={users}
currentUser={currentUser}
onClose={() => setSelectedBookingForDetails(null)}
onCancel={handleCancelBooking}
onDelete={handleDeleteBooking}
onAddLog={handleAddLogManually}
/>
)}
</div>
);
}

View File

@ -0,0 +1,811 @@
import React, { useState, useMemo } from 'react';
import { Booking, LabTemplate, Device, User } from '../types';
import {
Calendar, Zap, CheckCircle2, AlertCircle, AlertTriangle, ChevronLeft, ChevronRight, Database,
X, Layers, Server, Clock, ChevronDown
} from 'lucide-react';
/** A device can only be reserved when CheckMK reports it online. */
function effectiveStatus(d: Device): 'online' | 'offline' | 'unknown' {
return d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : 'unknown';
}
function isBookable(d: Device): boolean {
return effectiveStatus(d) === 'online';
}
interface BookingCalendarProps {
bookings: Booking[];
labs: LabTemplate[];
devices: Device[];
currentUser: User;
onAddBooking: (booking: Omit<Booking, 'id' | 'notified' | 'emailSent'>) => void;
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
onSelectBookingDetails: (booking: Booking) => void;
}
// ── helpers ────────────────────────────────────────────────────────────────
/** Midnight of today + offset days in LOCAL time */
function dayBase(offset: number): Date {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(d.getDate() + offset);
return d;
}
/** 'YYYY-MM-DD' string in LOCAL time (avoids UTC rollover in getTime()) */
function localDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** 'YYYY-MM-DDTHH:MM:SS' in LOCAL time - no Z suffix, consistent with form input */
function toLocalISO(d: Date): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${localDateStr(d)}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
/** Slot boundary as UTC-ms using LOCAL clock (avoids mixed-zone comparison) */
function slotMs(offset: number, hhmm: string): number {
const [h, m] = hhmm.split(':').map(Number);
const d = dayBase(offset);
d.setHours(h, m, 0, 0);
return d.getTime();
}
function fmtDate(offset: number) {
return dayBase(offset).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
}
const TIME_SLOTS = Array.from({ length: 12 }, (_, i) => {
const h = 8 + i;
const start = `${String(h).padStart(2, '0')}:00`;
const end = `${String(h + 1).padStart(2, '0')}:00`;
return { start, end, label: start };
});
const TIME_OPTIONS = ['08:00', '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00', '18:00', '19:00', '20:00'];
// ── component ──────────────────────────────────────────────────────────────
export default function BookingCalendar({
bookings,
labs,
devices,
currentUser,
onAddBooking,
onCancelBooking,
onDeleteBooking,
onSelectBookingDetails,
}: BookingCalendarProps) {
// Calendar navigation - 0 = today, positive = future days
const [dayOffset, setDayOffset] = useState(0);
// Standard booking form
const [resourceType, setResourceType] = useState<'lab' | 'device'>('lab');
const [selectedLabId, setSelectedLabId] = useState<string>(labs[0]?.id || '');
const [selectedDeviceId, setSelectedDeviceId] = useState<string>(devices[0]?.id || '');
const [bookingNotes, setBookingNotes] = useState('');
const [startDate, setStartDate] = useState(localDateStr(new Date()));
const [endDate, setEndDate] = useState(localDateStr(new Date()));
const [startTime, setStartTime] = useState('08:00');
const [endTime, setEndTime] = useState('12:00');
// Quick Booking modal state
const [showQuickPanel, setShowQuickPanel] = useState(false);
const [quickDuration, setQuickDuration] = useState(2);
const [quickTab, setQuickTab] = useState<'labs' | 'devices'>('labs');
// Reservation registry is collapsed by default to keep the page tidy
const [showReservations, setShowReservations] = useState(false);
// ── availability helpers ───────────────────────────────────────────────
function bookingCoversDevice(b: Booking, deviceId: string): boolean {
if (b.labId.startsWith('device:')) return b.labId === `device:${deviceId}`;
return !!labs.find(l => l.id === b.labId)?.deviceIds.includes(deviceId);
}
function isDeviceBooked(deviceId: string, startMs: number, endMs: number): boolean {
return bookings.some(b => {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (!bookingCoversDevice(b, deviceId)) return false;
const bStart = new Date(b.startDateTime).getTime();
const bEnd = new Date(b.endDateTime).getTime();
return startMs < bEnd && endMs > bStart;
});
}
function getBookingForDeviceInSlot(device: Device, offset: number, slotStart: string, slotEnd: string): Booking | undefined {
const sMs = slotMs(offset, slotStart);
const eMs = slotMs(offset, slotEnd);
return bookings.find(b => {
if (b.status === 'cancelled' || b.status === 'completed') return false;
if (!bookingCoversDevice(b, device.id)) return false;
const bStart = new Date(b.startDateTime).getTime();
const bEnd = new Date(b.endDateTime).getTime();
return sMs < bEnd && eMs > bStart;
});
}
// Resolve which physical devices a standard booking would occupy.
function targetDeviceIds(): string[] {
if (resourceType === 'device') return selectedDeviceId ? [selectedDeviceId] : [];
return labs.find(l => l.id === selectedLabId)?.deviceIds ?? [];
}
function checkConflict(deviceIds: string[], sDate: string, sTime: string, eDate: string, eTime: string) {
const reqStart = new Date(`${sDate}T${sTime}:00`).getTime();
const reqEnd = new Date(`${eDate}T${eTime}:00`).getTime();
if (reqEnd <= reqStart) return { hasConflict: true, message: 'End date/time must be after start.' };
for (const dId of deviceIds) {
if (isDeviceBooked(dId, reqStart, reqEnd)) {
const confLab = bookings
.filter(b => b.status !== 'cancelled' && b.status !== 'completed')
.find(b => {
if (!bookingCoversDevice(b, dId)) return false;
const bS = new Date(b.startDateTime).getTime();
const bE = new Date(b.endDateTime).getTime();
return reqStart < bE && reqEnd > bS;
});
const devName = devices.find(x => x.id === dId)?.hostname || dId;
const lName = confLab ? labs.find(l => l.id === confLab.labId)?.name : undefined;
return { hasConflict: true, message: `Hardware Conflict: "${devName}" is already allocated${lName ? ` by "${lName}"` : ''} during this timeframe.` };
}
}
return { hasConflict: false };
}
// Devices in the current selection that CheckMK does not report as online - these block the booking.
function blockingDevices(deviceIds: string[]): Device[] {
return deviceIds
.map(id => devices.find(d => d.id === id))
.filter((d): d is Device => !!d && !isBookable(d));
}
// ── available-now helpers for Quick Booking ────────────────────────────
const quickWindow = useMemo(() => {
const start = new Date();
const end = new Date(start.getTime() + quickDuration * 3600_000);
return { startMs: start.getTime(), endMs: end.getTime(), start, end };
}, [quickDuration]);
// A lab is quick-bookable only when every device is free AND reported online by CheckMK.
const availableLabs = useMemo(() => labs.filter(lab =>
lab.deviceIds.length > 0 &&
lab.deviceIds.every(dId => {
const dev = devices.find(d => d.id === dId);
return !!dev && isBookable(dev) && !isDeviceBooked(dId, quickWindow.startMs, quickWindow.endMs);
})
), [labs, devices, bookings, quickWindow]);
const availableDevices = useMemo(() => devices.filter(dev =>
isBookable(dev) && !isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
), [devices, bookings, quickWindow]);
// ── booking actions ────────────────────────────────────────────────────
const handleCreateBooking = (e: React.FormEvent) => {
e.preventDefault();
const deviceIds = targetDeviceIds();
if (deviceIds.length === 0) { alert('Please select a resource to reserve.'); return; }
const blocked = blockingDevices(deviceIds);
if (blocked.length > 0) {
alert(`Not bookable: ${blocked.map(d => `"${d.hostname}" (${effectiveStatus(d)})`).join(', ')} ${blocked.length === 1 ? 'is' : 'are'} not online in CheckMK.`);
return;
}
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (conflict.hasConflict) { alert(conflict.message); return; }
onAddBooking({
labId: resourceType === 'device' ? `device:${selectedDeviceId}` : selectedLabId,
userId: currentUser.id,
startDateTime: `${startDate}T${startTime}:00`,
endDateTime: `${endDate}T${endTime}:00`,
notes: bookingNotes,
status: 'upcoming',
});
setBookingNotes('');
};
const handleQuickBookLab = (lab: LabTemplate) => {
onAddBooking({
labId: lab.id,
userId: currentUser.id,
startDateTime: toLocalISO(quickWindow.start),
endDateTime: toLocalISO(quickWindow.end),
notes: `Quick Booking - ${lab.name} [${quickDuration}h]`,
status: 'active',
});
setShowQuickPanel(false);
setDayOffset(0);
};
const handleQuickBookDevice = (device: Device) => {
if (!isBookable(device)) {
alert(`"${device.hostname}" is ${effectiveStatus(device)} in CheckMK and cannot be reserved.`);
return;
}
// Find or pick a lab that contains this device; fall back to device ID as labId marker
const hostLab = labs.find(l => l.deviceIds.includes(device.id));
onAddBooking({
labId: hostLab?.id ?? `device:${device.id}`,
userId: currentUser.id,
startDateTime: toLocalISO(quickWindow.start),
endDateTime: toLocalISO(quickWindow.end),
notes: `Quick Device Reservation - ${device.hostname} [${quickDuration}h]`,
status: 'active',
});
setShowQuickPanel(false);
setDayOffset(0);
};
// ── render ─────────────────────────────────────────────────────────────
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="calendar-dashboard-root">
{/* ── Quick Booking Modal ── */}
{showQuickPanel && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg bg-[#0F172A] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden">
{/* Modal Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-800">
<div className="flex items-center gap-2">
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
<h3 className="font-bold text-sm text-white font-sans">Quick Booking</h3>
</div>
<button onClick={() => setShowQuickPanel(false)} className="text-slate-400 hover:text-white transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Duration Selector */}
<div className="px-5 pt-4 space-y-1">
<p className="text-[11px] text-slate-400 font-sans">Duration starting now:</p>
<div className="flex gap-2">
{[1, 2, 4, 8].map(h => (
<button
key={h}
onClick={() => setQuickDuration(h)}
className={`flex-1 py-2 rounded-lg text-xs font-semibold font-sans border transition-all ${
quickDuration === h
? 'bg-emerald-600 border-emerald-500 text-white'
: 'bg-slate-900 border-slate-800 text-slate-300 hover:border-emerald-700 hover:text-emerald-400'
}`}
>
{h}h
</button>
))}
</div>
<p className="text-[10px] text-slate-500 font-mono">
{new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {' '}
{new Date(Date.now() + quickDuration * 3600_000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</p>
</div>
{/* Tabs */}
<div className="flex gap-1 px-5 pt-3">
<button
onClick={() => setQuickTab('labs')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
quickTab === 'labs' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
}`}
>
<Layers className="w-3.5 h-3.5" /> Lab Topologies ({availableLabs.length} available)
</button>
<button
onClick={() => setQuickTab('devices')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-all ${
quickTab === 'devices' ? 'bg-emerald-900/50 text-emerald-400 border border-emerald-800/60' : 'text-slate-400 hover:text-white'
}`}
>
<Server className="w-3.5 h-3.5" /> Individual Devices ({availableDevices.length} free)
</button>
</div>
{/* Content */}
<div className="px-5 pb-5 pt-3 max-h-72 overflow-y-auto space-y-2">
{quickTab === 'labs' ? (
availableLabs.length === 0 ? (
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free and fully online for {quickDuration}h right now. all boxes either leased or not reporting in.</p>
) : (
availableLabs.map(lab => {
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
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 className="min-w-0">
<p className="text-xs font-bold text-white truncate">{lab.name}</p>
<p className="text-[10px] text-slate-400 font-mono">{lab.location} · {labDevices.length} device{labDevices.length !== 1 ? 's' : ''}</p>
<p className="text-[9px] text-slate-500 truncate mt-0.5">{labDevices.map(d => d.hostname).join(', ')}</p>
</div>
<button
onClick={() => handleQuickBookLab(lab)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
>
Book
</button>
</div>
);
})
)
) : (
devices.map(device => {
const status = effectiveStatus(device);
const online = status === 'online';
const free = !isDeviceBooked(device.id, quickWindow.startMs, quickWindow.endMs);
const bookable = online && free;
return (
<div key={device.id} className={`flex items-center justify-between gap-3 p-3 border rounded-lg transition-all ${
bookable ? 'bg-slate-900/60 border-slate-800 hover:border-emerald-800/50' : 'bg-slate-950/40 border-slate-900 opacity-60'
}`}>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full ${online ? 'bg-emerald-400' : status === 'offline' ? 'bg-rose-400' : 'bg-slate-500'}`} />
<p className="text-xs font-bold text-white font-mono">{device.hostname}</p>
</div>
<p className="text-[10px] text-slate-400 font-mono">{device.type} · {device.ip}</p>
<p className="text-[9px] text-slate-500">{device.location}</p>
</div>
{bookable ? (
<button
onClick={() => handleQuickBookDevice(device)}
className="shrink-0 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold rounded-lg transition-all"
>
Book
</button>
) : !online ? (
<span className="shrink-0 flex items-center gap-1 text-[10px] text-amber-400 font-mono font-semibold capitalize" title="Not online in CheckMK - cannot be reserved">
<AlertTriangle className="w-3 h-3" />{status}
</span>
) : (
<span className="shrink-0 text-[10px] text-rose-400 font-mono font-semibold">Busy</span>
)}
</div>
);
})
)}
</div>
</div>
</div>
)}
{/* ── 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="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Calendar className="text-emerald-400 w-5 h-5" />
Bookings
</h2>
<p className="text-xs text-slate-400">Who has which box, and until when. mutex for hardware, basically.</p>
</div>
{/* Day navigation */}
<div className="flex items-center gap-1 bg-slate-950 p-1 rounded-lg border border-slate-805 shrink-0">
<button
onClick={() => setDayOffset(dayOffset - 1)}
disabled={dayOffset <= -30}
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded disabled:opacity-30 transition-opacity"
>
<ChevronLeft className="w-3.5 h-3.5" />
</button>
<div className="text-xs font-semibold px-2.5 text-center text-slate-200 min-w-[130px] font-mono select-none">
{dayOffset === 0 ? `${fmtDate(0)} (Today)` : dayOffset < 0 ? `${fmtDate(dayOffset)} (Past)` : fmtDate(dayOffset)}
</div>
<button
onClick={() => setDayOffset(dayOffset + 1)}
className="p-1 px-1.5 bg-slate-900 border border-slate-800 text-slate-350 hover:text-white rounded transition-opacity"
>
<ChevronRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Matrix Grid */}
<div className="flex-1 overflow-x-auto rounded-lg border border-slate-850 p-1 bg-slate-950/40">
<div style={{ minWidth: '860px' }}>
{/* Header row */}
<div
className="border-b border-slate-800 pb-1"
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>
{TIME_SLOTS.map((slot, i) => (
<div key={i} className="text-center py-1 border-l border-slate-855">
<span className="text-[9px] font-mono text-slate-300 leading-none">{slot.label}</span>
</div>
))}
</div>
{/* Device rows */}
<div className="divide-y divide-slate-850 max-h-[460px] overflow-y-auto">
{devices.map((device) => (
<div
key={device.id}
className="items-center group hover:bg-slate-900/35"
style={{ display: 'grid', gridTemplateColumns: '140px repeat(12, minmax(0, 1fr))' }}
>
<div className="pl-3 py-2 text-left">
<p className="font-mono font-bold text-[11px] text-white group-hover:text-emerald-400 transition-colors leading-none truncate">{device.hostname}</p>
<p className="text-[9px] font-mono text-slate-500 mt-0.5 leading-none">{device.type}</p>
</div>
{TIME_SLOTS.map((slot, sIdx) => {
const cur = getBookingForDeviceInSlot(device, dayOffset, slot.start, slot.end);
const prev = sIdx > 0 ? getBookingForDeviceInSlot(device, dayOffset, TIME_SLOTS[sIdx - 1].start, TIME_SLOTS[sIdx - 1].end) : undefined;
const next = sIdx < TIME_SLOTS.length - 1 ? getBookingForDeviceInSlot(device, dayOffset, TIME_SLOTS[sIdx + 1].start, TIME_SLOTS[sIdx + 1].end) : undefined;
if (!cur) {
return (
<div key={sIdx} className="px-0.5 py-1.5 h-10 flex items-center border-l border-slate-850">
<div className="w-full h-full rounded border border-dashed border-slate-800/40 hover:border-slate-700/60 transition-all" />
</div>
);
}
const isMe = cur.userId === currentUser.id;
const isFirst = !prev || prev.id !== cur.id;
const isLast = !next || next.id !== cur.id;
const lab = labs.find(l => l.id === cur.labId);
const radius = isFirst && isLast ? 'rounded'
: isFirst ? 'rounded-l'
: isLast ? 'rounded-r'
: '';
const borderCls = isMe
? `bg-emerald-500/30 border-emerald-500/60 hover:bg-emerald-500/45 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`
: `bg-indigo-500/25 border-indigo-500/50 hover:bg-indigo-500/35 ${isFirst ? 'border-l' : ''} border-y ${isLast ? 'border-r' : ''}`;
return (
<div
key={sIdx}
className={`py-1.5 h-10 flex items-center ${isFirst ? 'pl-0.5' : ''} ${isLast ? 'pr-0.5' : ''} border-l border-slate-850`}
>
<div
onClick={() => onSelectBookingDetails(cur)}
title={`${slot.label} - ${TIME_SLOTS[sIdx + 1]?.label ?? '20:00'}\n${lab?.name ?? 'Device booking'}\n${isMe ? 'My booking' : 'Colleague'}`}
className={`w-full h-full cursor-pointer transition-all overflow-hidden flex flex-col justify-center ${radius} ${borderCls}`}
>
{isFirst && (
<span className="text-[8px] font-semibold leading-tight truncate px-1 text-white/90">
{lab?.name ?? 'Device'}
</span>
)}
</div>
</div>
);
})}
</div>
))}
</div>
</div>
</div>
{/* 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="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-indigo-500/25 border border-indigo-500/50" /> Colleague's Allocation</span>
<span className="flex items-center gap-1.5"><span className="w-2.5 h-2.5 rounded border border-dashed border-slate-800/40" /> Available</span>
</div>
<p className="italic">Double-bookings get rejected at commit time. no race conditions on your watch.</p>
</div>
</div>
{/* ── RIGHT: Booking Form ── */}
<div className="lg:col-span-4 space-y-6" id="booking-actions-card">
{/* Quick Booking Trigger */}
<div className="bg-[#1D2535] border border-emerald-900/30 rounded-xl p-5 shadow-sm relative overflow-hidden">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-emerald-500 to-teal-500" />
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5 mb-1.5 font-sans">
<Zap className="w-4 h-4 text-emerald-400 fill-emerald-500/30" />
Quick Booking
</h3>
<p className="text-[11px] text-slate-400 leading-relaxed font-sans mb-4">
Pick a duration, then grab a free lab or a single box from the live list. ttl-based hardware leasing, basically.
</p>
<div className="grid grid-cols-4 gap-2 mb-3">
{[1, 2, 4, 8].map(h => (
<button
key={h}
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"
>
{h}h
</button>
))}
</div>
<button
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"
>
<Clock className="w-3.5 h-3.5" />
Show Available Now
</button>
<div className="mt-3 flex items-center gap-3 text-[10px] text-slate-500 font-mono">
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-emerald-400" />{availableLabs.length} labs free</span>
<span className="flex items-center gap-1"><span className="w-1.5 h-1.5 rounded-full bg-cyan-400" />{availableDevices.length} devices free</span>
</div>
</div>
{/* Standard Booking Form */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-3">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Reserve Slot
</h3>
<form onSubmit={handleCreateBooking} className="space-y-4 text-xs">
{/* Resource type toggle: whole lab topology or a single device */}
<div>
<label className="block text-slate-300 font-semibold mb-1">Reserve</label>
<div className="grid grid-cols-2 gap-1.5">
<button
type="button"
onClick={() => setResourceType('lab')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'lab'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Layers className="w-3.5 h-3.5" /> Topology
</button>
<button
type="button"
onClick={() => setResourceType('device')}
className={`flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-semibold border transition-all ${
resourceType === 'device'
? 'bg-indigo-500/15 border-indigo-500 text-indigo-300'
: 'bg-slate-950 border-slate-800 text-slate-400 hover:text-white'
}`}
>
<Server className="w-3.5 h-3.5" /> Single Device
</button>
</div>
</div>
{resourceType === 'lab' ? (
<div>
<label className="block text-slate-300 font-semibold mb-1">Topology</label>
<select
value={selectedLabId}
onChange={(e) => setSelectedLabId(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500"
>
{labs.map((l) => (
<option key={l.id} value={l.id}>{l.name} ({l.location})</option>
))}
</select>
</div>
) : (
<div>
<label className="block text-slate-300 font-semibold mb-1">Device</label>
<select
value={selectedDeviceId}
onChange={(e) => setSelectedDeviceId(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono"
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{isBookable(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
</option>
))}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start Date</label>
<input
type="date"
value={startDate}
min={localDateStr(new Date())}
onChange={(e) => {
setStartDate(e.target.value);
// Navigate calendar to selected date
const today = dayBase(0).getTime();
const sel = new Date(e.target.value + 'T00:00:00').getTime();
setDayOffset(Math.round((sel - today) / 86_400_000));
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"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End Date</label>
<input
type="date"
value={endDate}
min={startDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono text-xs"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-slate-300 font-semibold mb-1">Start</label>
<select
value={startTime}
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"
>
{TIME_OPTIONS.slice(0, -1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">End</label>
<select
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-indigo-500 font-mono"
>
{TIME_OPTIONS.slice(1).map(t => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Notes / Objective</label>
<textarea
required
rows={3}
placeholder="e.g. Validating STP failover convergence times..."
value={bookingNotes}
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"
/>
</div>
{(() => {
const deviceIds = targetDeviceIds();
const blocked = blockingDevices(deviceIds);
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (blocked.length > 0) {
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">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>
Not bookable - {blocked.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {blocked.length === 1 ? 'is' : 'are'} not online in CheckMK. Hardware must be reachable before it can be reserved.
</span>
</div>
);
}
return conflict.hasConflict ? (
<div className="bg-rose-950/40 p-2.5 rounded border border-rose-900/60 flex gap-2 text-rose-300 text-[11px] leading-normal">
<AlertCircle className="w-4 h-4 text-rose-400 shrink-0" />
<span>{conflict.message}</span>
</div>
) : (
<div className="bg-emerald-950/20 p-2.5 rounded border border-emerald-900/40 flex gap-2 text-emerald-300 text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Online & free. Timeframe is available.</span>
</div>
);
})()}
{(() => {
const deviceIds = targetDeviceIds();
const disabled = deviceIds.length === 0
|| blockingDevices(deviceIds).length > 0
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
return (
<button
type="submit"
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"
>
Confirm Reservation
</button>
);
})()}
</form>
</div>
</div>
{/* ── 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">
<button
type="button"
onClick={() => setShowReservations(s => !s)}
className="w-full flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 text-left hover:cursor-pointer"
aria-expanded={showReservations}
>
<div>
<h3 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<ChevronDown className={`w-4 h-4 text-slate-400 transition-transform ${showReservations ? '' : '-rotate-90'}`} />
<Database className="w-4 h-4 text-emerald-400" />
Reservations
</h3>
<p className="text-xs text-slate-400 pl-6">SELECT * FROM bookings. every lease ever, straight from SQLite.</p>
</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">
DATABASE SELECT: {bookings.length} RECORDS
</span>
</button>
{!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">
No active reservation structures currently exist inside the database.
</p>
) : (
<div className="mt-4 overflow-x-auto rounded-lg border border-slate-800 bg-slate-900/10">
<table className="w-full text-xs text-left text-slate-300 divide-y divide-slate-800">
<thead className="bg-[#0f172a]/60 text-slate-400 font-mono text-[10px] uppercase tracking-wider">
<tr>
<th className="px-4 py-3">ID</th>
<th className="px-4 py-3">Topology / Resource</th>
<th className="px-4 py-3">Scheduled Window</th>
<th className="px-4 py-3">Status</th>
<th className="px-4 py-3">Notes</th>
<th className="px-4 py-3 text-right font-sans">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800 bg-slate-950/10 font-sans">
{bookings.map((b) => {
const lab = labs.find(l => l.id === b.labId);
const isDeviceBooking = b.labId?.startsWith('device:');
const deviceId = isDeviceBooking ? b.labId.replace('device:', '') : null;
const device = deviceId ? devices.find(d => d.id === deviceId) : null;
const day = new Date(b.startDateTime).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
const tStart = new Date(b.startDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const tEnd = new Date(b.endDateTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
return (
<tr key={b.id} className="hover:bg-slate-900/40 transition">
<td className="px-4 py-3.5 font-mono font-bold text-emerald-500/80">#{b.id.slice(-8)}</td>
<td className="px-4 py-3.5">
<span className="text-slate-100 font-semibold block">{isDeviceBooking ? (device?.hostname ?? deviceId) : (lab?.name ?? 'Unknown')}</span>
<span className="text-[10px] text-slate-400 font-mono">{isDeviceBooking ? 'Device booking' : (lab?.location ?? 'Staging')}</span>
</td>
<td className="px-4 py-3.5 font-mono">
<span className="block text-slate-200">{day}</span>
<span className="text-[10px] text-slate-400">{tStart} - {tEnd}</span>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold border capitalize ${
b.status === 'active' ? 'bg-emerald-950/60 border-emerald-500/30 text-emerald-400' :
b.status === 'upcoming' ? 'bg-indigo-950/60 border-indigo-500/30 text-indigo-400' :
b.status === 'completed' ? 'bg-slate-900 border-slate-800 text-slate-400' :
'bg-rose-950/60 border-rose-500/20 text-rose-400 font-bold'
}`}>{b.status}</span>
</td>
<td className="px-4 py-3.5 text-slate-400 max-w-[150px] truncate">{b.notes || '-'}</td>
<td className="px-4 py-3.5 text-right space-x-1 whitespace-nowrap">
<button
onClick={() => onSelectBookingDetails(b)}
className="px-2.5 py-1.5 bg-slate-900 border border-slate-800 hover:border-slate-700 text-cyan-400 hover:text-cyan-300 rounded text-[11px] font-semibold cursor-pointer"
>
Details
</button>
<button
onClick={() => { if (confirm(`Delete reservation #${b.id.slice(-8)}?`)) onDeleteBooking(b.id); }}
className="px-2.5 py-1.5 text-[11px] bg-rose-950/20 hover:bg-rose-900/30 text-rose-400 hover:text-rose-300 rounded transition cursor-pointer"
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,457 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Booking, LabTemplate, Device, User } from '../types';
import {
X, Calendar, Clock, UserIcon, Database, Terminal, Cpu, Play, Check,
Trash2, Ban, Copy, CheckCircle, HelpCircle, HardDrive
} from 'lucide-react';
interface BookingDetailsModalProps {
booking: Booking;
labs: LabTemplate[];
devices: Device[];
users: User[];
currentUser: User;
onClose: () => void;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onAddLog: (log: { type: 'system' | 'maintenance' | 'booking'; message: string; userId?: string }) => void;
}
export default function BookingDetailsModal({
booking,
labs,
devices,
users,
currentUser,
onClose,
onCancel,
onDelete,
onAddLog
}: BookingDetailsModalProps) {
const lab = labs.find(l => l.id === booking.labId);
const creator = users.find(u => u.id === booking.userId) || currentUser;
// Find devices mapped to this booking
const mappedDevices = devices.filter(d => lab?.deviceIds.includes(d.id));
// Developer panel tabs ('rest', 'ansible', 'terminal')
const [activeTab, setActiveTab] = useState<'rest' | 'ansible' | 'terminal'>('ansible');
const [isCopied, setIsCopied] = useState(false);
const [isSimulating, setIsSimulating] = useState(false);
const [simulationLogs, setSimulationLogs] = useState<string[]>([]);
const [simStep, setSimStep] = useState(0);
const startFormatted = new Date(booking.startDateTime).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
const endFormatted = new Date(booking.endDateTime).toLocaleString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
timeZoneName: 'short'
});
// Dynamic Ansible playbook string based on active nodes
const ipList = mappedDevices.map(d => d.ip);
const ansiblePlaybook = `---
- name: Reset GhostGrid Infrastructure Post-Reservation
hosts: localhost
gather_facts: false
vars:
target_nodes:
${ipList.map(ip => ` - "${ip}"`).join('\n') || ' - "No registered targets"'}
backup_repo: "https://git.ghostgrid.io/topology-configs"
tasks:
- name: Audit out-of-band diagnostic link states
ansible.builtin.ping:
register: ping_result
- name: Fetch designated golden config profile
ansible.builtin.get_url:
url: "{{ backup_repo }}/golden/${booking.labId}.cfg"
dest: "/tmp/golden_${booking.id}.cfg"
- name: Commit golden parameters & purge current stack
ansible.netcommon.net_config:
src: "/tmp/golden_${booking.id}.cfg"
replace: block
when: ping_result is succeeded
`;
// Dynamic REST Response
const mockJsonResponse = JSON.stringify({
retrievedAt: new Date().toISOString(),
apiEndpoint: `/api/bookings/${booking.id}`,
payload: {
...booking,
expandedLab: {
id: lab?.id,
name: lab?.name,
location: lab?.location,
hardwareTotal: mappedDevices.length,
devices: mappedDevices.map(d => ({ hostname: d.hostname, ip: d.ip, type: d.type }))
}
}
}, null, 2);
const handleCopyText = (text: string) => {
navigator.clipboard.writeText(text);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
// Ansible Terminal simulation execution
const runAnsibleSimulation = () => {
if (isSimulating) return;
setIsSimulating(true);
setSimStep(1);
setSimulationLogs([
`[ansible-playbook -i localhost] Starting playbook: "Reset GhostGrid Infrastructure"`,
`[ansible-playbook] Configured target node list: ${ipList.join(', ') || 'None'}`
]);
setTimeout(() => {
setSimStep(2);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Audit out-of-band diagnostic link states] **********************`,
...mappedDevices.map(d => `ok: [${d.hostname} (${d.ip})] ping_state=SUCCESS latency=1.2ms`)
]);
}, 1000);
setTimeout(() => {
setSimStep(3);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Fetch designated golden config profile] *************************`,
`changed: [localhost] fetched golden profile for lab ID "${booking.labId}"`
]);
}, 2200);
setTimeout(() => {
setSimStep(4);
setSimulationLogs(prev => [
...prev,
`[localhost] TASK [Commit golden parameters & purge current stack] ******************`,
...mappedDevices.map(d => `changed: [${d.hostname}] configuration synced - cache invalidated - interfaces reset`),
`PLAY RECAP *************************************************************************`,
`localhost : ok=4 changed=2 unreachable=0 failed=0`
]);
setIsSimulating(false);
onAddLog({
type: 'maintenance',
message: `System Worker triggered an automated Ansible Golden Reset on reservation ${booking.id} (${lab?.name || 'Unknown'}). Checked ${mappedDevices.length} hosts.`
});
}, 3800);
};
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="bg-[#1E293B] border border-slate-705 w-full max-w-4xl rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150 flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="bg-slate-900 px-6 py-4 border-b border-slate-800 flex items-center justify-between font-sans">
<div className="flex items-center gap-3">
<div className="p-1.5 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-400">
<HardDrive className="w-5 h-5" />
</div>
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<span>Reservation Details</span>
<span className="text-slate-500 font-mono font-normal">#{booking.id}</span>
</h3>
<p className="text-[11px] text-slate-400">Inspect allocation status and diagnostic automation APIs</p>
</div>
</div>
<button
onClick={onClose}
className="text-slate-400 hover:text-white p-1 hover:bg-slate-800 rounded transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body Scroll Container */}
<div className="p-6 overflow-y-auto space-y-6 flex-1">
{/* Main info row: Split details and targets */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6">
{/* 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>
<span className="text-[10px] uppercase font-mono tracking-wider font-bold text-slate-500">Scheduled Blueprint</span>
<h4 className="text-base font-bold text-white mt-0.5">{lab?.name || 'Standalone Test Lab'}</h4>
<div className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-mono capitalize mt-2 font-semibold border"
style={{
backgroundColor:
booking.status === 'active' ? 'rgba(16,185,129,0.1)' :
booking.status === 'upcoming' ? 'rgba(99,102,241,0.1)' :
booking.status === 'completed' ? 'rgba(100,116,139,0.1)' : 'rgba(239,68,68,0.1)',
borderColor:
booking.status === 'active' ? 'rgba(16,185,129,0.5)' :
booking.status === 'upcoming' ? 'rgba(99,102,241,0.5)' :
booking.status === 'completed' ? 'rgba(100,116,139,0.5)' : 'rgba(239,68,68,0.5)',
color:
booking.status === 'active' ? '#10B981' :
booking.status === 'upcoming' ? '#818CF8' :
booking.status === 'completed' ? '#94A3B8' : '#F87171',
}}>
<span className={`w-1.5 h-1.5 rounded-full ${
booking.status === 'active' ? 'bg-emerald-400' :
booking.status === 'upcoming' ? 'bg-indigo-400' :
booking.status === 'completed' ? 'bg-slate-400' : 'bg-rose-400'
}`} />
{booking.status}
</div>
</div>
{/* Time blocks */}
<div className="space-y-2.5 font-sans">
<div className="flex gap-2.5 text-xs text-slate-300">
<Calendar className="w-4.5 h-4.5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Start Time</span>
<span className="font-mono text-slate-200">{startFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-slate-300">
<Clock className="w-4.5 h-4.5 text-indigo-400 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Terminations On</span>
<span className="font-mono text-slate-200">{endFormatted}</span>
</div>
</div>
<div className="flex gap-2.5 text-xs text-slate-300">
<UserIcon className="w-4.5 h-4.5 text-slate-500 shrink-0 mt-0.5" />
<div>
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider">Reserved Operator</span>
<span className="text-slate-200">{creator.name}</span>
</div>
</div>
</div>
{/* Operator Notes */}
<div className="pt-3 border-t border-slate-850/80 font-sans text-xs">
<span className="font-semibold block text-slate-400 text-[10px] uppercase tracking-wider mb-1">Testing Objective Notes</span>
<p className="text-slate-300 leading-relaxed italic bg-slate-900 border border-slate-850 p-2.5 rounded">
"{booking.notes || 'No objectives specified.'}"
</p>
</div>
</div>
{/* 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>
<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] text-slate-500 font-mono">Location: {lab?.location}</span>
</div>
{mappedDevices.length === 0 ? (
<p className="text-xs text-slate-400 italic py-6 text-center">No hardware nodes directly mapped to this scenario.</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-1">
{mappedDevices.map((device) => (
<div key={device.id} className="p-3 bg-slate-950/65 border border-slate-850 hover:border-slate-800 rounded-lg flex items-center justify-between font-sans">
<div className="flex items-center gap-2.5">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-400' : 'bg-rose-400'}`} />
<div>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] text-slate-400 mt-1 font-mono leading-none">{device.type} {device.location}</p>
</div>
</div>
<span className="text-xs font-mono font-bold text-emerald-400">{device.ip}</span>
</div>
))}
</div>
)}
</div>
{/* 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">
<HelpCircle className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<span>During the active slot, GhostGrid automatically binds these switches exclusively to your namespace and drops custom console-lines to open CLI pipelines.</span>
</div>
</div>
</div>
{/* BELOW BLOCK: Restful API & Automation Integration developer panel */}
<div className="border border-slate-800 rounded-xl overflow-hidden bg-slate-950 font-mono">
{/* Panel Tabs Header */}
<div className="bg-slate-900 border-b border-slate-850 px-4 py-2 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2">
<span className="text-[10px] uppercase tracking-wider font-bold text-indigo-400 font-sans flex items-center gap-2">
<Terminal className="w-4 h-4 text-emerald-400" />
Developer Restful API & Ansible Integration
</span>
<div className="flex gap-1">
<button
onClick={() => setActiveTab('ansible')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'ansible' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Cpu className="w-3 h-3" /> Ansible Playbook
</button>
<button
onClick={() => setActiveTab('terminal')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'terminal' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Play className="w-3 h-3" /> Reset-Simulator {isSimulating && <span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse ml-1" />}
</button>
<button
onClick={() => setActiveTab('rest')}
className={`px-2.5 py-1 text-xs rounded transition-all font-sans font-semibold flex items-center gap-1 ${
activeTab === 'rest' ? 'bg-emerald-950/80 border border-emerald-500/50 text-emerald-400' : 'text-slate-400 hover:text-white'
}`}
>
<Database className="w-3 h-3" /> JSON REST Response
</button>
</div>
</div>
{/* Panel Content Box */}
<div className="p-4 bg-slate-950 text-xs leading-normal font-mono relative overflow-x-auto min-h-[180px] max-h-[300px] overflow-y-auto">
{/* Copy Overlay button */}
{activeTab !== 'terminal' && (
<button
onClick={() => handleCopyText(activeTab === 'ansible' ? ansiblePlaybook : mockJsonResponse)}
className="absolute top-4 right-4 bg-slate-900 border border-slate-800 hover:border-slate-700 hover:bg-slate-850 p-1.5 text-slate-400 hover:text-white rounded flex items-center gap-1 text-[10px] font-sans transition-all"
>
{isCopied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
<span>{isCopied ? 'Copied' : 'Copy'}</span>
</button>
)}
{activeTab === 'ansible' && (
<div className="space-y-3">
<div className="text-[10px] text-slate-500 font-sans mb-1 uppercase tracking-wider">
Use this playbook in your local cron or Ansible Tower instance to automatically sync devices post-session:
</div>
<pre className="text-emerald-400/90 whitespace-pre text-[11px] leading-relaxed select-all">
{ansiblePlaybook}
</pre>
</div>
)}
{activeTab === 'rest' && (
<div className="space-y-3">
<div className="text-[10px] text-slate-500 font-sans mb-1 uppercase tracking-wider flex items-center justify-between">
<span>GET Endpoint: /api/bookings/{booking.id}</span>
<span className="text-indigo-400 bg-indigo-950 border border-indigo-900 px-1 py-0.5 rounded font-mono text-[9px]">application/json</span>
</div>
<pre className="text-slate-300 text-[11px] leading-relaxed select-all">
{mockJsonResponse}
</pre>
</div>
)}
{activeTab === 'terminal' && (
<div className="space-y-3 flex flex-col h-full justify-between">
<div className="text-[10px] text-slate-500 font-sans mb-2 uppercase tracking-wider flex justify-between items-center bg-slate-900/40 p-2 border border-slate-900 rounded">
<span>Manual trigger simulation to verify post-booking hardware reset tasks</span>
<button
onClick={runAnsibleSimulation}
disabled={isSimulating}
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 hover:cursor-pointer disabled:bg-slate-800 text-slate-950 font-sans font-bold text-[10px] rounded flex items-center gap-1 transition"
>
<Play className="w-3 h-3 fill-slate-950" />
<span>{isSimulating ? 'SIMULATING...' : 'RUN SIMULATOR'}</span>
</button>
</div>
<div className="bg-slate-1000 border border-slate-900 p-3 rounded-lg text-[11px] leading-6 space-y-1 font-mono text-slate-305 max-h-[180px] overflow-y-auto">
{simulationLogs.length === 0 ? (
<p className="text-slate-600 italic">Playbook simulator offline. Press "Run Simulator" above to run the automated Ansible pipeline check on the active SQLite nodes...</p>
) : (
simulationLogs.map((logLine, lIdx) => (
<div key={lIdx} className={`${
logLine.includes('failed=0') || logLine.includes('sync') ? 'text-emerald-400 font-semibold' :
logLine.includes('TASK') ? 'text-indigo-400 font-semibold mt-3' :
logLine.includes('Starting') ? 'text-slate-400' : 'text-slate-300'
}`}>
{logLine}
</div>
))
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* 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="flex gap-2">
{/* Delete button option */}
<button
onClick={() => {
if (confirm(`WARING: Do you want to permanently DELETE reservation blueprint #${booking.id}? This physical dataset will be purged forever from SQLite databases.`)) {
onDelete(booking.id);
onClose();
}
}}
className="px-3 py-1.5 bg-rose-950/40 border border-rose-900/60 text-rose-400 hover:bg-rose-900 hover:text-white rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all hover:cursor-pointer"
>
<Trash2 className="w-3.5 h-3.5" />
<span>Purge Entry (SQLite DELETE)</span>
</button>
{/* Cancel Status Toggle */}
{booking.status !== 'cancelled' && booking.status !== 'completed' && (
<button
onClick={() => {
if (confirm('Do you plan to release scheduled device holds? This will notify your colleagues.')) {
onCancel(booking.id);
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"
>
<Ban className="w-3.5 h-3.5" />
<span>Cancel Reservation</span>
</button>
)}
</div>
<button
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"
>
Acknowledge Specs
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,420 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useEffect } from 'react';
import { Booking, LabTemplate, Device, User, QuickLink } from '../types';
import {
Zap, Clock, PlayCircle, MapPin, ListTodo, Calendar,
Link as LinkIcon, ExternalLink, Globe, ArrowRight
} from 'lucide-react';
interface DashboardProps {
currentUser: User;
bookings: Booking[];
labs: LabTemplate[];
devices: Device[];
links: QuickLink[];
onCancelBooking: (id: string) => void;
onDeleteBooking: (id: string) => void;
onSelectBookingDetails: (booking: Booking) => void;
onNavigateToCalendar: () => void;
onNavigateToDevices: () => void;
onNavigateToLabs: () => void;
onNavigateToLinks: () => void;
}
const LINK_ACCENT: Record<string, string> = {
emerald: 'text-emerald-400', cyan: 'text-cyan-400', indigo: 'text-indigo-400',
amber: 'text-amber-400', rose: 'text-rose-400', violet: 'text-violet-400',
};
export default function Dashboard({
currentUser,
bookings,
labs,
devices,
links,
onCancelBooking,
onDeleteBooking,
onSelectBookingDetails,
onNavigateToCalendar,
onNavigateToDevices,
onNavigateToLabs,
onNavigateToLinks
}: DashboardProps) {
const [now, setNow] = useState(new Date());
useEffect(() => {
const timer = setInterval(() => setNow(new Date()), 1000);
return () => clearInterval(timer);
}, []);
const ONE_HOUR_MS = 60 * 60 * 1000;
const personalBookings = bookings.filter(b => b.userId === currentUser.id && b.status !== 'cancelled');
// "Active" = currently running, plus a 1h grace window after the end so
// freshly-finished sessions linger briefly instead of jumping to "Expired".
const activeBookings = personalBookings.filter(b => {
const start = new Date(b.startDateTime).getTime();
const end = new Date(b.endDateTime).getTime();
return start <= now.getTime() && end > now.getTime() - ONE_HOUR_MS;
});
const upcomingBookings = personalBookings.filter(b => b.status === 'upcoming' && new Date(b.startDateTime) > now);
// Quick state checklist for the user to mark items as done as they test their lab!
// (kept deliberately nerdy - morale is a critical infrastructure dependency)
const [todoList, setTodoList] = useState([
{ id: 't1', text: 'Ping 8.8.8.8 to confirm the Internet still exists', checked: false },
{ id: 't2', text: 'Coffee level nominal ☕ - packets route faster on caffeine', checked: true },
{ id: 't3', text: "Rule out DNS first (narrator: it was, in fact, always DNS)", checked: false },
{ id: 't4', text: 'Layer-1 check: is it actually plugged in? (PEBKAC defense)', checked: false }
]);
const toggleTodo = (id: string) => {
setTodoList(todoList.map(t => t.id === id ? { ...t, checked: !t.checked } : t));
};
const getRemainingTimeText = (endTimeStr: string) => {
const diffMs = new Date(endTimeStr).getTime() - now.getTime();
if (diffMs <= 0) {
// Within the 1h grace window - wrapping up rather than "expired".
const agoMin = Math.max(1, Math.ceil(-diffMs / (1000 * 60)));
return `Ended ${agoMin}m ago`;
}
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const mins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
const secs = Math.floor((diffMs % (1000 * 60)) / 1000);
return hours > 0 ? `${hours}h ${mins}m remaining` : `${mins}m ${secs}s remaining`;
};
return (
<div className="space-y-6" id="dashboard-cockpit-root">
{/* Banner Card Grid */}
<div className="grid grid-cols-1 md:grid-cols-12 gap-6 bg-gradient-to-br from-[#1E293B] to-[#111827] border border-slate-800 rounded-2xl p-6 shadow-sm overflow-hidden relative">
<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">
NET
</div>
<div className="md:col-span-8 space-y-4">
<h2 className="text-2xl font-bold tracking-tight text-white leading-tight font-sans">
Welcome back, <span className="text-emerald-400">{currentUser.name}</span>!
</h2>
<p className="text-xs text-slate-300 leading-relaxed font-sans max-w-xl">
Your lab cockpit. Grab some hardware, block a time slot, and keep the rescue runbooks one click away for when a switch decides to packet-storm itself at 16:59 on a Friday. root@ghostgrid:~# have fun, break things (in the lab).
</p>
<div className="pt-2 flex items-center gap-3">
<button
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"
>
<Zap className="w-4 h-4 text-slate-950 fill-slate-950" />
Book Your Lab
</button>
<button
onClick={onNavigateToDevices}
className="px-4 py-2 bg-slate-900 hover:bg-slate-800 text-slate-200 border border-slate-800 hover:border-slate-700 rounded-lg text-xs font-semibold transition-all flex items-center gap-1 hover:cursor-pointer"
>
Browse Inventory
</button>
</div>
</div>
<div className="md:col-span-4 bg-slate-950/60 p-4 rounded-xl border border-slate-850 flex flex-col justify-between font-sans">
<div>
<span className="text-[10px] font-mono uppercase tracking-widest text-slate-500 block">System Time</span>
<div className="text-2xl font-mono text-emerald-400 font-bold mt-1 tabular-nums">
{now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<p className="text-[10px] text-slate-400 font-sans mt-0.5">
{now.toLocaleDateString([], { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })}
</p>
</div>
<div className="mt-4 pt-4 border-t border-slate-850 grid grid-cols-2 gap-2 text-center text-[10px] text-slate-350">
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{devices.length}</span>
<span>Hardware Nodes</span>
</div>
<div className="bg-slate-900 p-2 rounded border border-slate-850">
<span className="block font-bold text-slate-100 font-mono">{labs.length}</span>
<span>Available Labs</span>
</div>
</div>
</div>
</div>
{/* Main Grid Content */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COMPONENT: Active / Upcoming Bookings */}
<div className="lg:col-span-8 space-y-6">
{/* Active Sessions */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-2 mb-4 font-sans justify-between">
<span className="flex items-center gap-2">
<Clock className="w-4.5 h-4.5 text-emerald-400" />
Active Reservations (your boxes, right now)
</span>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono font-bold text-emerald-400 bg-emerald-950/40 border border-emerald-900/50 rounded-full px-2.5 py-0.5">
<span className="relative flex h-1.5 w-1.5">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-emerald-500"></span>
</span>
LIVE
</span>
</h3>
{activeBookings.length === 0 ? (
<div className="text-center py-10 bg-slate-900/35 rounded-lg border border-slate-850 p-6 font-sans">
<PlayCircle className="w-8 h-8 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-xs text-slate-400">No boxes checked out right now. idle hands, idle hardware.</p>
<button
onClick={onNavigateToCalendar}
className="text-xs text-emerald-400 font-semibold underline mt-2 hover:text-emerald-300"
>
grab a slot -&gt;
</button>
</div>
) : (
<div className="space-y-4 font-sans">
{activeBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const startDate = new Date(booking.startDateTime);
const endDate = new Date(booking.endDateTime);
const sameDay = startDate.toDateString() === endDate.toDateString();
const dayFmt: Intl.DateTimeFormatOptions = { weekday: 'short', day: 'numeric', month: 'short' };
const timeFmt: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
const startF = `${startDate.toLocaleDateString('en-US', dayFmt)}, ${startDate.toLocaleTimeString('en-US', timeFmt)}`;
const endF = sameDay
? endDate.toLocaleTimeString('en-US', timeFmt)
: `${endDate.toLocaleDateString('en-US', dayFmt)}, ${endDate.toLocaleTimeString('en-US', timeFmt)}`;
return (
<div key={booking.id} className="p-4 bg-slate-950/60 border border-emerald-500/30 rounded-xl relative overflow-hidden">
<div className="absolute top-0 right-0 bottom-0 w-1 bg-emerald-500" />
<div className="flex justify-between items-start mb-2 gap-2">
<div>
<h4 className="text-sm font-bold text-white font-sans">{lab?.name}</h4>
<span className="text-[10px] text-slate-400 flex items-center gap-1 font-sans mt-0.5">
<MapPin className="w-3.5 h-3.5 text-slate-500" />
{lab?.location}
</span>
</div>
{/* 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">
{getRemainingTimeText(booking.endDateTime)}
</span>
</div>
<p className="text-xs text-slate-300 leading-relaxed font-sans mb-3 italic">
"{booking.notes || 'no notes - running blind'}"
</p>
<div className="pt-3 border-t border-slate-900/60 flex justify-between items-center text-[10px]">
<span className="font-mono text-slate-400">
Active window: {startF} - {endF}
</span>
<div className="flex gap-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 bg-emerald-950/80 border border-emerald-500/30 text-emerald-400 hover:text-emerald-300 rounded flex items-center gap-1 transition-all font-semibold hover:cursor-pointer"
>
Inspect Details (Rest / Ansible)
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to release these nodes early? Hardware holds will terminate immediately.')) {
onCancelBooking(booking.id);
}
}}
className="px-2.5 py-1 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 flex items-center gap-1 transition-all font-semibold hover:cursor-pointer"
>
Release
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Upcoming Sessions */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<Calendar className="w-4.5 h-4.5 text-indigo-400" />
Upcoming in the Queue ({upcomingBookings.length})
</h3>
{upcomingBookings.length === 0 ? (
<p className="text-xs text-slate-400 py-4 italic text-center">Queue is empty. crontab clean, nothing scheduled.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{upcomingBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const dayStr = new Date(booking.startDateTime).toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
const startF = new Date(booking.startDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const endF = new Date(booking.endDateTime).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
return (
<div key={booking.id} className="p-3 bg-slate-905/30 border border-slate-850 hover:border-slate-800 rounded-lg flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-1">
<span className="font-mono font-bold text-[10px] text-indigo-405 bg-indigo-950/50 border border-indigo-900 px-2 py-0.5 rounded">
{dayStr}
</span>
<span className="text-[10px] font-mono text-slate-500">
{startF} - {endF}
</span>
</div>
<h4 className="text-xs font-bold text-slate-200 mt-1 font-sans">{lab?.name}</h4>
<p className="text-[10px] text-slate-400 line-clamp-1 mt-0.5 leading-normal">
{booking.notes}
</p>
</div>
<div className="pt-2 mt-2 border-t border-slate-850 flex justify-end gap-1.5 pt-2">
<button
onClick={() => onSelectBookingDetails(booking)}
className="px-2.5 py-1 text-[9px] text-emerald-400 hover:text-emerald-350 bg-emerald-950/40 border border-emerald-990/30 rounded font-semibold transition hover:cursor-pointer"
>
Specs / REST API
</button>
<button
onClick={() => {
if (confirm('Cancel this upcoming reservation? Linked SMTP alerts will notify appropriate parties.')) {
onCancelBooking(booking.id);
}
}}
className="px-2 py-1 text-[9px] text-slate-400 hover:text-white hover:bg-slate-800 rounded border border-transparent hover:cursor-pointer"
>
Cancel Slot
</button>
<button
onClick={() => {
if (confirm('Are you sure you want to permanently delete this reservation from SQLite storage? This action cannot be reversed.')) {
onDeleteBooking(booking.id);
}
}}
className="px-2 py-1 text-[9px] text-rose-400 hover:text-rose-300 hover:bg-rose-950/30 rounded border border-transparent hover:cursor-pointer"
>
Purge SQLite
</button>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* RIGHT COLUMN: Checklist and simulated action panel */}
<div className="lg:col-span-4 space-y-6">
{/* Workflows Checklist */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5">
<ListTodo className="w-4.5 h-4.5 text-amber-500" />
Pre-Flight Checklist (before you blame the network)
</h3>
<div className="space-y-2.5">
{todoList.map((item) => (
<div
key={item.id}
onClick={() => toggleTodo(item.id)}
className="flex items-start gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg cursor-pointer transition-all border border-slate-850/60"
>
<input
type="checkbox"
checked={item.checked}
onChange={() => {}}
className="mt-0.5 rounded border-slate-800 text-emerald-500 focus:ring-emerald-450 w-3.5 h-3.5 shrink-0"
/>
<span className={`text-[11px] leading-tight ${item.checked ? 'text-slate-500 line-through' : 'text-slate-200'}`}>
{item.text}
</span>
</div>
))}
</div>
<div className="mt-4 pt-3 border-t border-slate-850 text-[10px] text-slate-450 text-center">
Works on my machine (TM). check the boxes anyway.
</div>
</div>
{/* Quick Links - shortcut into the shared tooling dashboard */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h3 className="font-bold text-sm text-slate-101 flex items-center gap-2 mb-3.5 justify-between">
<span className="flex items-center gap-2">
<LinkIcon className="w-4.5 h-4.5 text-cyan-400" />
Quick Links
</span>
<button
onClick={onNavigateToLinks}
className="text-[10px] text-cyan-400 hover:text-cyan-300 font-semibold flex items-center gap-0.5 hover:cursor-pointer"
>
Manage <ArrowRight className="w-3 h-3" />
</button>
</h3>
{links.length === 0 ? (
<div className="text-center py-6 bg-slate-900/35 rounded-lg border border-slate-850 p-5">
<Globe className="w-7 h-7 text-slate-700 mx-auto mb-2 opacity-50" />
<p className="text-[11px] text-slate-400">No shared links yet.</p>
<button
onClick={onNavigateToLinks}
className="text-[11px] text-cyan-400 font-semibold underline mt-1.5 hover:text-cyan-300 hover:cursor-pointer"
>
Add CheckMK, Semaphore & co.
</button>
</div>
) : (
<div className="space-y-1.5">
{links.slice(0, 6).map(link => {
let host = link.url;
try { host = new URL(link.url).host; } catch { /* keep raw */ }
const accent = LINK_ACCENT[link.color] ?? LINK_ACCENT.emerald;
return (
<a
key={link.id}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center gap-2.5 p-2 bg-slate-1000/40 hover:bg-slate-900 rounded-lg border border-slate-850/60 hover:border-slate-800 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}`}>
<Globe className="w-3.5 h-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-[11px] font-semibold text-slate-200 group-hover:text-white truncate">{link.title}</span>
<span className={`block text-[9px] font-mono truncate ${accent}`}>{host}</span>
</span>
<ExternalLink className="w-3.5 h-3.5 text-slate-600 group-hover:text-slate-300 shrink-0" />
</a>
);
})}
{links.length > 6 && (
<button
onClick={onNavigateToLinks}
className="w-full text-center text-[10px] text-slate-500 hover:text-cyan-400 pt-1.5 font-semibold hover:cursor-pointer"
>
+{links.length - 6} more links
</button>
)}
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,629 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { Device, DeviceType } from '../types';
import {
Server, Search, Plus, Trash, Edit2, MapPin, Info,
BookOpen, Save, X, ExternalLink, Gauge
} from 'lucide-react';
// Built-in device class presets shown in the dropdown.
const DEVICE_CLASS_PRESETS = ['Switch', 'Firewall', 'Access-Point', 'Controller'];
interface DeviceInventoryProps {
devices: Device[];
onAddDevice: (device: Omit<Device, 'id'>) => void;
onUpdateDevice: (device: Device) => void;
onDeleteDevice: (id: string) => void;
}
export default function DeviceInventory({
devices,
onAddDevice,
onUpdateDevice,
onDeleteDevice,
}: DeviceInventoryProps) {
// Filters & State
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [selectedDeviceId, setSelectedDeviceId] = useState<string | null>(devices[0]?.id || null);
// Always derived from prop so edits reflect immediately in the detail panel
const selectedDevice = useMemo(
() => devices.find(d => d.id === selectedDeviceId) ?? null,
[devices, selectedDeviceId]
);
// Create / Edit modal state
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// True when the user is defining a device class outside the presets.
const [isCustomType, setIsCustomType] = useState(false);
const [formData, setFormData] = useState<{
id?: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
emergencySheet: string;
checkMkUrl: string;
}>({
hostname: '',
ip: '',
location: '',
notes: '',
type: 'Switch',
emergencySheet: '',
checkMkUrl: ''
});
// Effective status: nothing is known until CheckMK is linked and reports a state.
const effectiveStatus = (d: Device): 'online' | 'offline' | 'unknown' =>
d.checkMkUrl ? (d.status === 'online' || d.status === 'offline' ? d.status : 'unknown') : '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 === 'offline') return { label: 'offline', badge: 'bg-rose-950/60 border-rose-900/80 text-rose-400', dot: 'bg-rose-400' };
return { label: 'unknown', badge: 'bg-slate-800/60 border-slate-700 text-slate-400', dot: 'bg-slate-500' };
};
// Filtered devices list
const filteredDevices = devices.filter(dev => {
const matchesSearch =
dev.hostname.toLowerCase().includes(searchTerm.toLowerCase()) ||
dev.ip.includes(searchTerm) ||
dev.location.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || dev.type === typeFilter;
return matchesSearch && matchesType;
});
const handleOpenAdd = () => {
setFormMode('add');
setIsCustomType(false);
setFormData({
hostname: '',
ip: '172.16.',
location: '',
notes: '',
type: 'Switch',
checkMkUrl: '',
emergencySheet: `### EMERGENCY MANUAL [HOSTNAME]
**Device Type:** [Enter Model]
**Serial Number:** [Enter Serial Number]
#### 1. Out-of-Band Console Connection
* **Baud Rate:** 115200
* **Data Bits:** 8
* **Parity:** None
* **Stop Bits:** 1
#### 2. Recovery / Hard Reset
1. Press and hold down the physical reset micro-button on the front panel.
2. Cycle power, wait 10 seconds, then release.`
});
setIsEditing(true);
};
const handleOpenEdit = (dev: Device) => {
setFormMode('edit');
setIsCustomType(!DEVICE_CLASS_PRESETS.includes(dev.type));
setFormData({
id: dev.id,
hostname: dev.hostname,
ip: dev.ip,
location: dev.location,
notes: dev.notes,
type: dev.type,
checkMkUrl: dev.checkMkUrl ?? '',
emergencySheet: dev.emergencySheet
});
setIsEditing(true);
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.hostname.trim() || !formData.ip.trim() || !formData.type.trim()) return;
if (formMode === 'add') {
onAddDevice({
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
status: 'unknown',
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
} else if (formMode === 'edit' && formData.id) {
const match = devices.find(d => d.id === formData.id);
if (match) {
onUpdateDevice({
...match,
hostname: formData.hostname,
ip: formData.ip,
location: formData.location,
notes: formData.notes,
type: formData.type,
emergencySheet: formData.emergencySheet,
checkMkUrl: formData.checkMkUrl
});
}
}
setIsEditing(false);
};
// Safe internal renderer for markdown headings and code blocks to support the emergency sheet visually
const renderEmergencySheetHtml = (text: string) => {
if (!text) return <p className="text-slate-400 italic text-xs font-sans">No emergency manual entry registered for this device node.</p>;
const lines = text.split('\n');
return lines.map((line, idx) => {
// Headers
if (line.startsWith('### ')) {
return <h4 key={idx} className="text-sm font-bold text-slate-100 mt-4 mb-2 border-b border-slate-800 pb-1 font-sans">{line.replace('### ', '')}</h4>;
}
if (line.startsWith('#### ')) {
return <h5 key={idx} className="text-xs font-bold text-emerald-400 mt-3 mb-1 font-sans">{line.replace('#### ', '')}</h5>;
}
if (line.startsWith('**') && line.endsWith('**')) {
return <p key={idx} className="text-xs font-semibold text-slate-200 mt-2 font-sans">{line.replace(/\*\*/g, '')}</p>;
}
// Bullet lists
if (line.startsWith('* ') || line.startsWith('- ')) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-500"></span>
<span>{line.replace(/^[\*\-]\s+/, '')}</span>
</div>;
}
// Numeric lists
if (/^\d+\s*\.\s/.test(line)) {
return <div key={idx} className="flex gap-2 text-xs text-slate-350 ml-4 font-sans align-top mb-1">
<span className="text-emerald-400 font-bold">{line.match(/^\d+/)?.[0]}.</span>
<span>{line.replace(/^\d+\s*\.\s+/, '')}</span>
</div>;
}
// Codeblocks
if (line.startsWith('`') && line.endsWith('`')) {
return <code key={idx} className="block bg-slate-950 p-2 rounded text-[10px] font-mono text-emerald-300 my-2 border border-slate-900 overflow-x-auto">{line.replace(/\`/g, '')}</code>;
}
if (line.trim() === '```bash' || line.trim() === '```') {
return null;
}
// Inline formatting fallback
if (line.includes('**')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('**').map((tok, ti) => {
return ti % 2 === 1 ? <strong key={ti} className="text-slate-100">{tok}</strong> : tok;
})}
</p>
);
}
if (line.includes('`')) {
return (
<p key={idx} className="text-xs text-slate-300 my-1 font-sans">
{line.split('`').map((tok, ti) => {
return ti % 2 === 1 ? <code key={ti} className="bg-slate-950 px-1 py-0.5 rounded text-[10px] text-emerald-300 font-mono">{tok}</code> : tok;
})}
</p>
);
}
return line.trim() === '' ? <div key={idx} className="h-2" /> : <p key={idx} className="text-xs text-slate-300 my-0.5 font-sans">{line}</p>;
});
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="device-inventory-root">
{/* LEFT COLUMN: Device List & Controls */}
<div className="lg:col-span-7 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="inventory-list-container">
{/* Title */}
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2 font-sans">
<Server className="w-5 h-5 text-emerald-400" />
Inventory
</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>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleOpenAdd}
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-sans font-semibold rounded-lg text-xs transition-colors hover:cursor-pointer"
id="btn-add-device"
>
<Plus className="w-4 h-4 text-slate-950 stroke-[3]" />
Add Device
</button>
</div>
</div>
{/* Filter Toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-800 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-400">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Search by hostname, IP address, rack location..."
value={searchTerm}
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"
/>
</div>
<div className="flex gap-1.5 shrink-0 font-sans text-xs">
{['all', 'Switch', 'Firewall', 'Access-Point', 'Controller'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : type}
</button>
))}
</div>
</div>
{/* Device Listing Card Table */}
<div className="flex-1 overflow-y-auto space-y-2.5 max-h-[600px] pr-1">
{filteredDevices.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-sans">
grep came back empty. no boxes match that filter.
</div>
) : (
filteredDevices.map((device) => {
const isSelected = selectedDevice?.id === device.id;
return (
<div
key={device.id}
onClick={() => setSelectedDeviceId(device.id)}
className={`p-4 border rounded-xl cursor-pointer transition-all flex justify-between items-center ${
isSelected
? 'bg-slate-900 border-emerald-500/80 shadow-[0_4px_12px_rgba(16,185,129,0.06)]'
: 'bg-slate-900/40 border-slate-800 hover:border-slate-700 hover:bg-slate-900/60'
}`}
>
<div className="flex items-start gap-3.5">
{/* Device Icon Circle */}
<div className={`p-2 rounded-lg border text-base ${
device.type === 'Firewall' ? 'bg-rose-950/20 border-rose-900/60 text-rose-400' :
device.type === 'Access-Point' ? 'bg-amber-950/20 border-amber-900/60 text-amber-400' :
device.type === 'Controller' ? 'bg-cyan-950/20 border-cyan-900/60 text-cyan-400' :
'bg-teal-950/20 border-teal-900/60 text-teal-400'
}`}>
<Server className="w-5 h-5" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-white text-sm">{device.hostname}</span>
<span className="text-[10px] px-2 py-0.5 font-sans rounded-full font-medium bg-slate-800 border border-slate-700 text-slate-400">{device.type}</span>
</div>
<div className="flex flex-col gap-0.5 mt-1 font-sans">
<span className="text-xs font-mono text-emerald-400">{device.ip}</span>
<span className="text-[10px] text-slate-400 flex items-center gap-1">
<MapPin className="w-3 h-3 text-slate-500" />
{device.location}
</span>
</div>
</div>
</div>
{/* Right: Actions and Status */}
<div className="flex items-center gap-3" onClick={(e) => e.stopPropagation()}>
{/* CheckMK Monitoring Badge */}
{(() => { const m = statusMeta(effectiveStatus(device)); return (
<div className="flex flex-col items-end gap-1 font-sans">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize ${m.badge}`}>
<span className={`w-1.5 h-1.5 rounded-full ${m.dot}`} />
{m.label}
</span>
<span className="text-[9px] font-mono text-slate-500">{device.checkMkUrl ? 'via CheckMK' : 'not linked'}</span>
</div>
); })()}
{/* Action Panel */}
<div className="flex items-center gap-1.5 border-l border-slate-800/80 pl-3">
{device.checkMkUrl && (
<a
href={device.checkMkUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-cyan-400 transition-colors"
title="Open in CheckMK"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
<button
onClick={() => handleOpenEdit(device)}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-indigo-400 transition-colors"
title="Edit specifications"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Are you sure you want to permanently delete device node "${device.hostname}" from the inventory? Existing topology mappings will become invalid.`)) {
onDeleteDevice(device.id);
}
}}
className="p-1 px-1.5 rounded hover:bg-slate-800 text-slate-400 hover:text-rose-400 transition-colors"
title="Delete device"
>
<Trash className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Notfallhandbuch & Technical Specs Details */}
<div className="lg:col-span-5 flex flex-col gap-6" id="inventory-details-container">
{selectedDevice ? (
<>
{/* Header Spec Block */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm">
<span className="text-[10px] font-mono uppercase bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded text-amber-400 font-semibold">
SPECS ID: {selectedDevice.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-slate-100 mt-2 font-mono flex items-center justify-between">
<span>{selectedDevice.hostname}</span>
<span className="text-xs font-sans text-slate-400 font-normal">Active Link State</span>
</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">
Hostname: <span className="text-slate-200">{selectedDevice.hostname}</span><br />
IP Address: <span className="text-emerald-400 font-bold">{selectedDevice.ip}</span><br />
Location: <span className="text-slate-200">{selectedDevice.location}</span><br />
Node Class: <span className="text-slate-200">{selectedDevice.type}</span>
</p>
<div className="mt-4 font-sans">
<h4 className="text-xs font-semibold text-slate-300">Description & Technical Notes:</h4>
<div className="mt-1 bg-slate-900/50 rounded p-2.5 border border-slate-800/80 text-xs text-slate-300 leading-relaxed">
{selectedDevice.notes || 'No description notes registered.'}
</div>
</div>
{/* CheckMK Monitoring Panel */}
<div className="mt-4 pt-4 border-t border-slate-800 space-y-2.5">
<div className="flex items-center justify-between">
<span className="text-xs text-slate-400 font-sans font-medium flex items-center gap-1.5">
<Gauge className="w-3.5 h-3.5 text-cyan-400" />
CheckMK Monitoring
</span>
{(() => { const m = statusMeta(effectiveStatus(selectedDevice)); return (
<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}`} />
{m.label}
</span>
); })()}
</div>
{selectedDevice.checkMkUrl ? (
<a
href={selectedDevice.checkMkUrl}
target="_blank"
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"
>
<ExternalLink className="w-3.5 h-3.5" />
Open host in CheckMK
</a>
) : (
<p className="text-[11px] text-slate-500 italic bg-slate-900/50 border border-slate-800 rounded p-2 leading-relaxed">
No CheckMK host linked yet. Add the host URL in the modify window - live status will be pulled from the CheckMK API.
</p>
)}
</div>
</div>
{/* Emergency rescue guidelines sheet */}
<div className="bg-[#1D2432] border border-amber-900/40 rounded-xl p-5 shadow-sm overflow-hidden relative">
<div className="absolute top-0 right-0 left-0 h-1 bg-gradient-to-r from-amber-500 to-rose-500" />
<div className="flex items-center justify-between border-b border-amber-900/30 pb-3 mb-4">
<div className="flex items-center gap-2">
<BookOpen className="w-5 h-5 text-amber-500" />
<h3 className="font-bold text-sm text-slate-100 font-sans">
Emergency Sheet & Disaster Recovery
</h3>
</div>
<span className="text-[9px] font-mono font-bold bg-amber-950 text-amber-300 px-2 py-0.5 rounded border border-amber-900/50">
RESCUE SHEET
</span>
</div>
{/* Markdown Content box */}
<div className="max-h-[350px] overflow-y-auto bg-slate-950/80 p-4 rounded-lg border border-slate-900 leading-relaxed font-sans scrollbar-thin">
{renderEmergencySheetHtml(selectedDevice.emergencySheet)}
</div>
<div className="mt-4 flex items-center gap-2 text-[10px] text-slate-400 bg-slate-900/60 p-2.5 rounded border border-slate-800">
<Info className="w-4 h-4 text-amber-400 shrink-0" />
<span>Console baud rates, break-glass logins and the reset-button dance - for when SSH is a distant memory.</span>
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-10 text-center text-slate-500 text-xs font-sans">
Pick a box from the list to see its specs and break-glass playbook.
</div>
)}
</div>
{/* FORM MODAL: Add / Edit Equipment */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-lg rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Server className="w-4 h-4 text-emerald-400" />
{formMode === 'add' ? 'Register New Hardware Node' : 'Modify Hardware Specifications'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Hostname</label>
<input
type="text"
required
value={formData.hostname}
onChange={(e) => setFormData({ ...formData, hostname: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="SW-CORE-03"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">IP Address</label>
<input
type="text"
required
value={formData.ip}
onChange={(e) => setFormData({ ...formData, ip: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono"
placeholder="172.16.x.x"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Server Room R02, Rack C4..."
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Device Class</label>
<select
value={isCustomType ? '__custom__' : formData.type}
onChange={(e) => {
if (e.target.value === '__custom__') {
setIsCustomType(true);
setFormData({ ...formData, type: '' });
} else {
setIsCustomType(false);
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"
>
<option value="Switch">Switch (L2/L3 Routing-Distribution)</option>
<option value="Firewall">Firewall / Security Appliance</option>
<option value="Access-Point">Access-Point (WLAN Node)</option>
<option value="Controller">Wireless Controller (WLC Engine)</option>
<option value="__custom__">+ Define new class</option>
</select>
{isCustomType && (
<input
type="text"
required
autoFocus
value={formData.type}
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"
placeholder="Enter new device class (e.g. Router, Load-Balancer)"
/>
)}
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Technical Notes / Patching Mappings</label>
<textarea
rows={2}
value={formData.notes}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Serial numbers, module slots, connected uplinks, license status..."
/>
</div>
{/* CheckMK Monitoring integration */}
<div className="bg-slate-900/60 border border-cyan-900/40 rounded-lg p-3.5 space-y-3">
<div className="flex items-center gap-2 text-cyan-400 font-semibold">
<Gauge className="w-4 h-4" />
CheckMK Monitoring
</div>
<p className="text-[11px] text-slate-400 leading-relaxed flex gap-1.5">
<Info className="w-3.5 h-3.5 text-cyan-400 shrink-0 mt-0.5" />
Link this host to CheckMK. Connectivity is monitored there and pulled in via the CheckMK API - no in-app ping checks.
</p>
<div>
<label className="block text-slate-300 font-semibold mb-1">CheckMK Host URL</label>
<input
type="text"
value={formData.checkMkUrl}
onChange={(e) => setFormData({ ...formData, checkMkUrl: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-cyan-500 font-mono"
placeholder="https://checkmk.internal/site/check_mk/index.py?host=SW-CORE-03"
/>
<p className="text-[10px] text-slate-500 mt-1.5 italic">Without a linked CheckMK host the status stays unknown.</p>
</div>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Disaster Recovery Guide (Markdown formatting supported)</label>
<textarea
rows={6}
value={formData.emergencySheet}
onChange={(e) => setFormData({ ...formData, emergencySheet: e.target.value })}
className="w-full bg-slate-950 text-slate-250 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500 font-mono text-[11px] leading-tight"
placeholder="### EMERGENCY DETAILS..."
/>
</div>
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center gap-1.5"
>
<Save className="w-3.5 h-3.5 text-slate-950" />
Save
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

249
src/components/Header.tsx Normal file
View File

@ -0,0 +1,249 @@
import React, { useState } from 'react';
import { User, Booking, LabTemplate } from '../types';
import { Mail, Bell, AlertTriangle, Sun, Moon, LogOut } from 'lucide-react';
interface HeaderProps {
currentUser: User;
bookings: Booking[];
labs: LabTemplate[];
notifications: string[];
onClearNotifications: () => void;
theme: 'dark' | 'light';
onThemeToggle: () => void;
onLogout: () => void;
}
export default function Header({
currentUser,
bookings,
labs,
notifications,
onClearNotifications,
theme,
onThemeToggle,
onLogout,
}: HeaderProps) {
const [showMailInbox, setShowMailInbox] = useState(false);
const [showBellDropdown, setShowBellDropdown] = useState(false);
const userBookings = bookings.filter(b => b.userId === currentUser.id && b.emailSent);
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">
{/* Brand Logo & Title */}
<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">
<GhostGridLogo className="w-10 h-10 animate-pulse" />
</div>
<div>
<h1 className="text-xl font-sans tracking-tight font-bold flex items-center gap-2 text-white">
GhostGrid
</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>
<div className="flex items-center gap-1 mt-1">
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirITSystems
</span>
</div>
</div>
</div>
{/* Header Actions */}
<div className="flex items-center gap-4">
{/* Theme Toggle */}
<button
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"
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" />}
</button>
{/* 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">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
<span>System: Online (Simulated)</span>
</div>
{/* Mail Inbox */}
<div className="relative">
<button
onClick={() => { setShowMailInbox(!showMailInbox); setShowBellDropdown(false); }}
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'
}`}
title="E-Mail Inbox (Booking Confirmations)"
>
<Mail className="w-5 h-5" />
{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">
{userBookings.length}
</span>
)}
</button>
{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="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-white flex items-center gap-2">
<Mail className="w-4 h-4 text-emerald-400" />
Mail Inbox: {currentUser.email}
</h3>
<p className="text-[10px] text-slate-400 font-sans">Automatic booking confirmations & dynamic alerts</p>
</div>
<button onClick={() => setShowMailInbox(false)} className="text-slate-400 hover:text-white text-xs font-sans">Close</button>
</div>
<div className="max-h-[360px] overflow-y-auto divide-y divide-slate-800 p-2 space-y-1">
{userBookings.length === 0 ? (
<div className="text-center py-8 text-slate-400 text-sm font-sans">
<Mail className="w-8 h-8 text-slate-600 mx-auto mb-2 opacity-50" />
No emails in inbox.
<p className="text-xs text-slate-500 mt-1">Book a lab to receive automated SMTP confirmations.</p>
</div>
) : (
userBookings.map((booking) => {
const lab = labs.find(l => l.id === booking.labId);
const formattedStart = new Date(booking.startDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
const formattedEnd = new Date(booking.endDateTime).toLocaleString('en-US', { dateStyle: 'short', timeStyle: 'short' });
return (
<div key={booking.id} className="p-3 bg-slate-900/40 rounded-lg hover:bg-slate-900/80 transition-colors">
<div className="flex justify-between items-start mb-1 gap-1">
<span className="text-[11px] font-mono text-emerald-400 font-semibold bg-emerald-950/80 px-2 py-0.5 rounded border border-emerald-900/50">SMTP INCOMING</span>
<span className="text-[10px] font-mono text-slate-400">Just now</span>
</div>
<h4 className="text-xs font-semibold text-white font-sans">Confirmation: Lab Booking for {lab?.name || 'Unknown'}</h4>
<div className="mt-2 text-[11px] text-slate-300 leading-relaxed space-y-1.5 font-sans border-l-2 border-emerald-500/50 pl-2">
<p>Hello <strong>{currentUser.name}</strong>,</p>
<p>Your booking request for lab <strong>{lab?.name}</strong> has been registered successfully.</p>
<div className="bg-slate-850 p-1.5 rounded font-mono text-[9px] text-slate-300 border border-slate-700/50">
<strong>Lab Location:</strong> {lab?.location}<br />
<strong>Start Time:</strong> {formattedStart}<br />
<strong>End Time:</strong> {formattedEnd}<br />
<strong>Notes:</strong> {booking.notes || 'None'}
</div>
<p className="text-[10px] text-slate-500 italic">GhostGrid Automation Mailbot</p>
</div>
</div>
);
})
)}
</div>
</div>
)}
</div>
{/* Notifications Bell */}
<div className="relative">
<button
onClick={() => { setShowBellDropdown(!showBellDropdown); setShowMailInbox(false); }}
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'
}`}
title="Interface & System Alerts"
>
<Bell className="w-5 h-5" />
{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">
{notifications.length}
</span>
)}
</button>
{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="bg-slate-900 px-4 py-3 border-b border-slate-700 flex items-center justify-between">
<div>
<h3 className="font-semibold text-sm text-white flex items-center gap-2 font-sans">
<Bell className="w-4 h-4 text-amber-400" />
Notifications ({notifications.length})
</h3>
<p className="text-[10px] text-slate-400 font-sans">Booking lifecycles & countdowns</p>
</div>
{notifications.length > 0 && (
<button onClick={onClearNotifications} className="text-amber-400 hover:text-amber-300 text-xs font-semibold font-sans">Clear All</button>
)}
</div>
<div className="max-h-[300px] overflow-y-auto divide-y divide-slate-800 p-2">
{notifications.length === 0 ? (
<div className="text-center py-6 text-slate-400 text-xs font-sans">No active system alerts.</div>
) : (
notifications.map((notif, index) => (
<div key={index} className="p-2.5 text-xs text-slate-200 flex gap-2 hover:bg-slate-905/40 rounded transition-colors mb-1 font-sans">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<p>{notif}</p>
</div>
))
)}
</div>
</div>
)}
</div>
{/* 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="hidden sm:block">
<div className="text-xs font-semibold leading-3 text-white max-w-[120px] truncate">{currentUser.name}</div>
<div className="text-[9px] text-cyan-400 font-mono tracking-wider mt-0.5 max-w-[125px] truncate">{currentUser.email}</div>
</div>
<button
onClick={onLogout}
title="Sign out"
className="ml-1 p-1.5 rounded-md text-slate-400 hover:text-red-400 hover:bg-red-950/40 transition-all"
>
<LogOut className="w-4 h-4" />
</button>
</div>
</div>
</header>
);
}
export function GhostGridLogo({ className = 'w-8 h-8' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="ghost-glow" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="3" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
<filter id="dot-glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
</filter>
</defs>
<path d="M 24,78 C 18,65 14,35 34,22 C 48,12 62,15 68,26" stroke="#06b6d4" strokeWidth="3.5" strokeLinecap="round" filter="url(#ghost-glow)" />
<path d="M 24,78 C 26,83 31,81 35,74 C 38,68 41,74 45,77 C 48,79 50,70 52,65" stroke="#06b6d4" strokeWidth="3.5" strokeLinecap="round" filter="url(#ghost-glow)" />
<rect x="38" y="32" width="6" height="13" rx="3" fill="#00f0ff" filter="url(#ghost-glow)" />
<rect x="52" y="32" width="6" height="13" rx="3" fill="#00f0ff" filter="url(#ghost-glow)" />
<line x1="48" y1="26" x2="80" y2="26" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" strokeDasharray="1 1" />
<line x1="56" y1="38" x2="88" y2="38" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="48" y1="50" x2="80" y2="50" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" />
<line x1="46" y1="62" x2="84" y2="62" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="48" y1="74" x2="74" y2="74" stroke="#0891b2" strokeWidth="2" strokeDasharray="1 1" />
<line x1="56" y1="20" x2="56" y2="80" stroke="#06b6d4" strokeWidth="2.5" filter="url(#ghost-glow)" />
<line x1="68" y1="15" x2="68" y2="76" stroke="#0891b2" strokeWidth="2" filter="url(#ghost-glow)" />
<line x1="80" y1="26" x2="80" y2="62" stroke="#06b6d4" strokeWidth="2" filter="url(#ghost-glow)" />
<circle cx="56" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="26" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="38" r="4.5" fill="#38bdf8" filter="url(#dot-glow)" />
<circle cx="88" cy="38" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="80" cy="50" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="62" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="62" r="4.5" fill="#38bdf8" filter="url(#dot-glow)" />
<circle cx="80" cy="62" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="84" cy="62" r="3" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="56" cy="74" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="68" cy="74" r="3.5" fill="#00f0ff" filter="url(#dot-glow)" />
<circle cx="74" cy="74" r="3" fill="#00f0ff" filter="url(#dot-glow)" />
</svg>
);
}

View File

@ -0,0 +1,515 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { LabTemplate, Device, TopologyLink } from '../types';
import TopologyPanel from './TopologyPanel';
import {
Server, Plus, Edit3, Trash, User, MapPin,
Layers, ChevronRight, Save, X, Check
} from 'lucide-react';
interface LabTemplatesProps {
labs: LabTemplate[];
devices: Device[];
onAddLab: (lab: Omit<LabTemplate, 'id'>) => void;
onUpdateLab: (lab: LabTemplate) => void;
onDeleteLab: (id: string) => void;
onOpenDeviceDetails: (device: Device) => void;
}
export default function LabTemplates({
labs,
devices,
onAddLab,
onUpdateLab,
onDeleteLab,
onOpenDeviceDetails
}: LabTemplatesProps) {
const [selectedLab, setSelectedLab] = useState<LabTemplate | null>(labs[0] || null);
const [isEditing, setIsEditing] = useState(false);
const [formMode, setFormMode] = useState<'add' | 'edit'>('add');
// Topology custom links helper state
const [tempLinks, setTempLinks] = useState<TopologyLink[]>([]);
const [linkFrom, setLinkFrom] = useState('');
const [linkTo, setLinkTo] = useState('');
const [linkType, setLinkType] = useState('Trunk Uplink');
const [formData, setFormData] = useState<{
id?: string;
name: string;
description: string;
contactPerson: string;
location: string;
deviceIds: string[];
}>({
name: '',
description: '',
contactPerson: '',
location: '',
deviceIds: []
});
// Calculate filtered devices associated with selected lab
const labDevices = selectedLab
? devices.filter(d => selectedLab.deviceIds.includes(d.id))
: [];
const handleOpenAdd = () => {
setFormMode('add');
setTempLinks([]);
setFormData({
name: '',
description: '',
contactPerson: '',
location: '',
deviceIds: []
});
setIsEditing(true);
};
const handleOpenEdit = (lab: LabTemplate) => {
setFormMode('edit');
setTempLinks([...lab.topology]);
setFormData({
id: lab.id,
name: lab.name,
description: lab.description,
contactPerson: lab.contactPerson,
location: lab.location,
deviceIds: [...lab.deviceIds]
});
setIsEditing(true);
};
// Toggle device association in form
const handleToggleDevice = (devId: string) => {
const isChosen = formData.deviceIds.includes(devId);
let newDevices = [];
if (isChosen) {
newDevices = formData.deviceIds.filter(id => id !== devId);
// Clean up invalid topology links referencing deleted devices
setTempLinks(tempLinks.filter(l => l.fromDevice !== devId && l.toDevice !== devId));
} else {
newDevices = [...formData.deviceIds, devId];
}
setFormData({ ...formData, deviceIds: newDevices });
};
// Add path link to list
const handleAddLink = () => {
if (!linkFrom || !linkTo || linkFrom === linkTo) return;
setTempLinks([...tempLinks, { fromDevice: linkFrom, toDevice: linkTo, type: linkType }]);
setLinkFrom('');
setLinkTo('');
};
const handleRemoveLink = (idx: number) => {
setTempLinks(tempLinks.filter((_, i) => i !== idx));
};
const handleSave = (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || formData.deviceIds.length === 0) {
alert('Please provide a descriptive template name and associate at least one hardware device node.');
return;
}
const savedLabData = {
name: formData.name,
description: formData.description,
contactPerson: formData.contactPerson,
location: formData.location,
deviceIds: formData.deviceIds,
topology: tempLinks
};
if (formMode === 'add') {
onAddLab(savedLabData);
} else if (formMode === 'edit' && formData.id) {
onUpdateLab({
...savedLabData,
id: formData.id
});
}
setIsEditing(false);
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6" id="lab-templates-container">
{/* LEFT COLUMN: Lab List */}
<div className="lg:col-span-4 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full font-sans" id="labs-list-section">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
Topology
</h2>
<p className="text-xs text-slate-400">Predefined architectural scenarios & wiring profiles.</p>
</div>
<button
onClick={handleOpenAdd}
className="p-1.5 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded-lg text-xs flex items-center gap-1.5 transition-colors"
title="Create new lab template"
>
<Plus className="w-4 h-4 text-slate-950" />
New
</button>
</div>
{/* Labs templates list */}
<div className="space-y-3 flex-1 overflow-y-auto max-h-[550px] pr-1">
{labs.map((lab) => {
const isSelected = selectedLab?.id === lab.id;
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()}>
<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">
<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>
{/* RIGHT COLUMN: Active Lab Details, Devices details, and TOPOLOGY MAP */}
<div className="lg:col-span-8 space-y-6" id="labs-view-section">
{selectedLab ? (
<>
{/* Template Card Meta */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-2 mb-4">
<div>
<span className="text-[9px] font-mono uppercase tracking-widest text-slate-400 bg-slate-900 border border-slate-800 px-2.5 py-0.5 rounded">
TEMPLATE ID: {selectedLab.id.toUpperCase()}
</span>
<h3 className="text-lg font-bold text-white mt-1.5">{selectedLab.name}</h3>
</div>
<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">
<User className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-slate-400 leading-none">Primary Contact</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.contactPerson}</p>
</div>
</div>
<div className="bg-slate-900 p-2 rounded-lg border border-slate-805 text-[10px] flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
<div>
<p className="text-slate-400 leading-none">Testing Location</p>
<p className="text-white font-semibold mt-0.5">{selectedLab.location}</p>
</div>
</div>
</div>
</div>
<p className="text-xs text-slate-300 leading-relaxed bg-slate-900/30 p-3 rounded-lg border border-slate-800">
{selectedLab.description}
</p>
</div>
{/* Interactive Visual Topology */}
<TopologyPanel
devices={labDevices}
links={selectedLab.topology}
onSelectDevice={onOpenDeviceDetails}
/>
{/* Sub-Devices components list */}
<div className="bg-[#1E293B] border border-slate-800 rounded-xl p-5 shadow-sm font-sans">
<h4 className="text-xs font-bold text-slate-300 uppercase tracking-widest font-mono mb-3">Associated Physical Hardware Map</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{labDevices.map((device) => (
<div
key={device.id}
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"
>
<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`}>
<Server className="w-4 h-4" />
</div>
<div>
<p className="text-xs font-mono font-bold text-white leading-none">{device.hostname}</p>
<p className="text-[9px] font-mono text-emerald-400 mt-1">{device.ip}</p>
</div>
</div>
<div className="flex items-center gap-2 font-mono">
<span className={`w-2 h-2 rounded-full ${device.status === 'online' ? 'bg-emerald-500' : 'bg-rose-500'}`} />
<span className="text-[10px] text-slate-400 capitalize">{device.status}</span>
</div>
</div>
))}
</div>
</div>
</>
) : (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-16 text-center text-slate-500 text-xs font-sans">
Select a lab scenario template from the left directory column to inspect active port topology connections.
</div>
)}
</div>
{/* FORM MODAL: Create or Edit Lab Template */}
{isEditing && (
<div className="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-[#1E293B] border border-slate-700 w-full max-w-2xl rounded-xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-150">
<div className="bg-slate-900 px-5 py-4 border-b border-slate-700 flex items-center justify-between font-sans overflow-x-auto">
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-5 h-5 text-emerald-400" />
{formMode === 'add' ? 'Design New Lab Template' : 'Modify Lab Template Configuration'}
</h3>
<button
onClick={() => setIsEditing(false)}
className="text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
</div>
<form onSubmit={handleSave} className="p-5 space-y-4 font-sans text-xs max-h-[85vh] overflow-y-auto">
{/* Name & Location */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Topology Name</label>
<input
type="text"
required
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Campus Core OSPF Backup Route"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Physical Location</label>
<input
type="text"
required
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Server Room R01, Cabinet B"
/>
</div>
</div>
{/* Description & Contact person */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-slate-300 font-semibold mb-1">Caretaker / Owner</label>
<input
type="text"
required
value={formData.contactPerson}
onChange={(e) => setFormData({ ...formData, contactPerson: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="e.g. Jane Doe"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<input
type="text"
required
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-500"
placeholder="Purpose, VLAN mappings, target device models..."
/>
</div>
</div>
{/* Hardware checklist */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">1. Assign Dedicated Hardware Nodes</label>
<p className="text-[10px] text-slate-400 mb-2">Select the devices from the active hardware inventory associated with this testing group.</p>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 bg-slate-950/60 p-3 rounded-lg border border-slate-855">
{devices.map((dev) => {
const isChecked = formData.deviceIds.includes(dev.id);
return (
<button
type="button"
key={dev.id}
onClick={() => handleToggleDevice(dev.id)}
className={`p-2 rounded text-left border flex items-center justify-between transition-colors ${
isChecked
? 'bg-emerald-900/20 border-emerald-500 text-slate-100'
: 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300'
}`}
>
<div className="truncate pr-1">
<p className="font-bold text-[10px] font-mono leading-none">{dev.hostname}</p>
<p className="text-[9px] font-mono text-slate-400 mt-1">{dev.ip}</p>
</div>
{isChecked && <Check className="w-3.5 h-3.5 text-emerald-400 shrink-0" />}
</button>
);
})}
</div>
</div>
{/* Physical/Logical topology builder link creator */}
<div className="border-t border-slate-800 pt-3">
<label className="block text-slate-300 font-bold mb-1.5">2. Define Ports & Link Connections</label>
<p className="text-[10px] text-slate-400 mb-2">Model logical and physical patch cabling among the selected devices (e.g. Trunk aggregation profiles, LACP uplinks).</p>
{/* Connection Inputs */}
<div className="grid grid-cols-1 sm:grid-cols-4 gap-2.5 p-2 bg-slate-1000 border border-slate-800 rounded-lg items-end mb-3">
<div>
<label className="block text-[10px] text-slate-400 mb-1">Source Node</label>
<select
value={linkFrom}
onChange={(e) => setLinkFrom(e.target.value)}
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
const d = devices.find(x => x.id === id);
return d ? <option key={id} value={id}>{d.hostname}</option> : null;
})}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1">Target Node</label>
<select
value={linkTo}
onChange={(e) => setLinkTo(e.target.value)}
className="w-full bg-slate-950 text-slate-250 border border-slate-805 rounded p-1 font-mono text-[11px]"
>
<option value="">-- Choose --</option>
{formData.deviceIds.map((id) => {
const d = devices.find(x => x.id === id);
return d ? <option key={id} value={id}>{d.hostname}</option> : null;
})}
</select>
</div>
<div>
<label className="block text-[10px] text-slate-400 mb-1">Link Identifier Description (Label)</label>
<input
type="text"
className="w-full bg-slate-950 text-slate-250 border border-slate-805 p-1 rounded font-mono text-[11px]"
placeholder="e.g. LACP Port-Channel 1"
value={linkType}
onChange={(e) => setLinkType(e.target.value)}
/>
</div>
<button
id="add-link-btn"
type="button"
onClick={handleAddLink}
className="w-full py-1.5 bg-indigo-600 hover:bg-indigo-500 rounded text-xs font-bold text-white transition-colors"
>
Add Link
</button>
</div>
{/* Listing added links list */}
{tempLinks.length > 0 ? (
<div className="space-y-1 max-h-[140px] overflow-y-auto pr-1">
{tempLinks.map((link, idx) => {
const fromDev = devices.find(d => d.id === link.fromDevice)?.hostname || link.fromDevice;
const toDev = devices.find(d => d.id === link.toDevice)?.hostname || link.toDevice;
return (
<div key={idx} className="flex justify-between items-center bg-slate-950 px-3 py-1.5 rounded border border-slate-855 font-mono text-[10px] hover:border-slate-700">
<span className="text-slate-300">
<strong>{fromDev}</strong> {link.type} <strong>{toDev}</strong>
</span>
<button
type="button"
onClick={() => handleRemoveLink(idx)}
className="text-rose-500 hover:text-rose-450 font-sans font-bold"
>
Remove
</button>
</div>
);
})}
</div>
) : (
<p className="text-[10px] text-slate-500 italic">No interface connections formulated yet.</p>
)}
</div>
{/* Form submit handlers */}
<div className="pt-3 border-t border-slate-800 flex justify-end gap-2.5">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded font-semibold text-xs animate-none"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs animate-none"
>
Save Lab Template
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,367 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { QuickLink, User } from '../types';
import {
LinkIcon, Plus, ExternalLink, Pencil, Trash2, Save, X,
Search, Globe, FolderOpen, Star
} from 'lucide-react';
interface LinkDashboardProps {
links: QuickLink[];
currentUser: User;
onAddLink: (link: Omit<QuickLink, 'id' | 'createdAt' | 'createdBy'>) => void;
onUpdateLink: (link: QuickLink) => void;
onDeleteLink: (id: string) => void;
}
// Accent palette - keys are stored in the DB so they survive reloads.
const ACCENTS: Record<string, { ring: string; text: string; bg: string; dot: string; bar: string }> = {
emerald: { ring: 'hover:border-emerald-500/60', text: 'text-emerald-400', bg: 'bg-emerald-950/40', dot: 'bg-emerald-500', bar: 'bg-emerald-500' },
cyan: { ring: 'hover:border-cyan-500/60', text: 'text-cyan-400', bg: 'bg-cyan-950/40', dot: 'bg-cyan-500', bar: 'bg-cyan-500' },
indigo: { ring: 'hover:border-indigo-500/60', text: 'text-indigo-400', bg: 'bg-indigo-950/40', dot: 'bg-indigo-500', bar: 'bg-indigo-500' },
amber: { ring: 'hover:border-amber-500/60', text: 'text-amber-400', bg: 'bg-amber-950/40', dot: 'bg-amber-500', bar: 'bg-amber-500' },
rose: { ring: 'hover:border-rose-500/60', text: 'text-rose-400', bg: 'bg-rose-950/40', dot: 'bg-rose-500', bar: 'bg-rose-500' },
violet: { ring: 'hover:border-violet-500/60', text: 'text-violet-400', bg: 'bg-violet-950/40', dot: 'bg-violet-500', bar: 'bg-violet-500' },
};
const ACCENT_KEYS = Object.keys(ACCENTS);
const accent = (key: string) => ACCENTS[key] ?? ACCENTS.emerald;
function hostOf(url: string): string {
try { return new URL(url).host; } catch { return url.replace(/^https?:\/\//, '').split('/')[0]; }
}
type Draft = { title: string; url: string; description: string; category: string; color: string };
const EMPTY_DRAFT: Draft = { title: '', url: '', description: '', category: '', color: 'emerald' };
export default function LinkDashboard({ links, currentUser, onAddLink, onUpdateLink, onDeleteLink }: LinkDashboardProps) {
const [search, setSearch] = useState('');
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
const [activeCategory, setActiveCategory] = useState<string>('all');
const categories = useMemo(() => {
const set = new Set<string>();
links.forEach(l => { if (l.category?.trim()) set.add(l.category.trim()); });
return Array.from(set).sort();
}, [links]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
return links.filter(l => {
const matchesSearch = !q ||
l.title.toLowerCase().includes(q) ||
l.description.toLowerCase().includes(q) ||
l.url.toLowerCase().includes(q) ||
l.category.toLowerCase().includes(q);
const matchesCat = activeCategory === 'all'
|| (activeCategory === '__uncat' ? !l.category?.trim() : l.category === activeCategory);
return matchesSearch && matchesCat;
});
}, [links, search, activeCategory]);
// Group filtered links by category for a tidy board layout
const grouped = useMemo(() => {
const map = new Map<string, QuickLink[]>();
filtered.forEach(l => {
const key = l.category?.trim() || 'Uncategorized';
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(l);
});
return Array.from(map.entries()).sort((a, b) => {
if (a[0] === 'Uncategorized') return 1;
if (b[0] === 'Uncategorized') return -1;
return a[0].localeCompare(b[0]);
});
}, [filtered]);
const openAdd = () => {
setEditingId(null);
setDraft({ ...EMPTY_DRAFT, category: activeCategory !== 'all' && activeCategory !== '__uncat' ? activeCategory : '' });
setShowForm(true);
};
const openEdit = (link: QuickLink) => {
setEditingId(link.id);
setDraft({ title: link.title, url: link.url, description: link.description, category: link.category, color: link.color });
setShowForm(true);
};
const closeForm = () => {
setShowForm(false);
setEditingId(null);
setDraft(EMPTY_DRAFT);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const title = draft.title.trim();
let url = draft.url.trim();
if (!title || !url) return;
// Be forgiving - assume https:// if no scheme was typed.
if (!/^https?:\/\//i.test(url)) url = `https://${url}`;
const payload = {
title,
url,
description: draft.description.trim(),
category: draft.category.trim(),
color: draft.color,
};
if (editingId) {
const original = links.find(l => l.id === editingId);
if (original) onUpdateLink({ ...original, ...payload });
} else {
onAddLink(payload);
}
closeForm();
};
return (
<div className="space-y-6 font-sans" id="link-dashboard-root">
{/* 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="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
LINKS
</div>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4 relative">
<div className="space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<LinkIcon className="w-6 h-6 text-emerald-400" />
Tooling & Quick Links
</h2>
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
The team's bookmark bar that doesn't live in one person's browser. CheckMK, Semaphore, Grafana, that one wiki page nobody can ever find again. Stored in SQLite, shared with everyone.
</p>
</div>
<button
onClick={openAdd}
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg transition-colors flex items-center gap-1.5 shrink-0 hover:cursor-pointer"
id="btn-add-link">
<Plus className="w-4 h-4" />
Add Link
</button>
</div>
</div>
{/* 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="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search links by name, host, category…"
value={search}
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"
/>
</div>
<div className="flex gap-1 flex-wrap shrink-0">
<button
onClick={() => setActiveCategory('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${activeCategory === 'all' ? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400' : 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'}`}
>
All
</button>
{categories.map(cat => (
<button
key={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'}`}
>
{cat}
</button>
))}
</div>
</div>
{/* Empty state */}
{links.length === 0 ? (
<div className="text-center py-16 bg-[#1E293B] border border-dashed border-slate-700 rounded-2xl">
<Globe className="w-10 h-10 text-slate-600 mx-auto mb-3 opacity-60" />
<h3 className="text-sm font-bold text-slate-200">404: links not found</h3>
<p className="text-xs text-slate-400 mt-1 max-w-sm mx-auto">
Drop in the daily-driver tools - monitoring, automation, ticketing - so nobody has to ask "what's the URL for CheckMK again?" for the 40th time.
</p>
<button
onClick={openAdd}
className="mt-4 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold text-xs rounded-lg inline-flex items-center gap-1.5 hover:cursor-pointer"
>
<Plus className="w-4 h-4" /> Add your first link
</button>
</div>
) : filtered.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">No links match your search.</p>
) : (
<div className="space-y-8">
{grouped.map(([category, items]) => (
<section key={category}>
<div className="flex items-center gap-2 mb-3">
<FolderOpen className="w-4 h-4 text-slate-500" />
<h3 className="text-xs font-bold uppercase tracking-wider text-slate-400 font-mono">{category}</h3>
<span className="text-[10px] text-slate-600 font-mono">({items.length})</span>
<div className="flex-1 h-px bg-slate-850 ml-2" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{items.map(link => {
const a = accent(link.color);
return (
<div
key={link.id}
className={`group relative bg-[#1E293B] border border-slate-800 rounded-xl p-4 transition-all ${a.ring} hover:shadow-lg`}
>
<span className={`absolute top-0 left-0 bottom-0 w-1 rounded-l-xl ${a.bar} opacity-70`} />
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${a.bg} border border-slate-800 flex items-center justify-center shrink-0 overflow-hidden`}>
<Globe className={`w-5 h-5 ${a.text}`} />
</div>
<div className="flex-1 min-w-0">
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-sm font-bold text-white hover:underline flex items-center gap-1.5 truncate"
title={link.title}
>
<span className="truncate">{link.title}</span>
<ExternalLink className={`w-3.5 h-3.5 shrink-0 ${a.text}`} />
</a>
<p className={`text-[10px] font-mono ${a.text} truncate mt-0.5`}>{hostOf(link.url)}</p>
</div>
</div>
{link.description && (
<p className="text-[11px] text-slate-400 leading-relaxed mt-3 line-clamp-2">{link.description}</p>
)}
{/* Hover actions */}
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEdit(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"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => {
if (confirm(`Remove "${link.title}" from the shared link dashboard?`)) onDeleteLink(link.id);
}}
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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</section>
))}
</div>
)}
{/* Add / Edit modal */}
{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="w-full max-w-md bg-[#1E293B] border border-slate-700 rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-150"
onClick={(e) => e.stopPropagation()}
>
<div className="bg-slate-900 px-5 py-3.5 border-b border-slate-800 flex items-center justify-between">
<h3 className="font-bold text-sm text-white flex items-center gap-2">
<Star className="w-4 h-4 text-emerald-400" />
{editingId ? 'Edit Link' : 'New Quick Link'}
</h3>
<button onClick={closeForm} className="text-slate-400 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Title *</label>
<input
required autoFocus
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
placeholder="e.g. CheckMK Monitoring"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">URL *</label>
<input
required
value={draft.url}
onChange={(e) => setDraft({ ...draft, url: e.target.value })}
placeholder="https://checkmk.internal"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 font-mono"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Category</label>
<input
list="link-categories"
value={draft.category}
onChange={(e) => setDraft({ ...draft, category: e.target.value })}
placeholder="e.g. Monitoring, Automation, Docs"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600"
/>
<datalist id="link-categories">
{categories.map(c => <option key={c} value={c} />)}
</datalist>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Description</label>
<textarea
rows={2}
value={draft.description}
onChange={(e) => setDraft({ ...draft, description: e.target.value })}
placeholder="What is this tool for?"
className="w-full bg-slate-950 text-slate-100 border border-slate-800 rounded p-2 focus:outline-none focus:border-emerald-600 resize-none"
/>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1.5">Accent</label>
<div className="flex gap-2">
{ACCENT_KEYS.map(key => (
<button
type="button"
key={key}
onClick={() => setDraft({ ...draft, color: key })}
className={`w-7 h-7 rounded-full ${accent(key).dot} transition-all hover:cursor-pointer ${draft.color === key ? 'ring-2 ring-offset-2 ring-offset-[#1E293B] ring-white scale-110' : 'opacity-70 hover:opacity-100'}`}
title={key}
/>
))}
</div>
</div>
<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">
Cancel
</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">
<Save className="w-3.5 h-3.5" />
{editingId ? 'Save Changes' : 'Add Link'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

270
src/components/Logbook.tsx Normal file
View File

@ -0,0 +1,270 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { LogEntry, Device, User } from '../types';
import {
History, Search, Plus, Hammer, UserIcon, Server,
Info, Save, ChevronRight
} from 'lucide-react';
interface LogbookProps {
logs: LogEntry[];
devices: Device[];
users: User[];
currentUser: User;
onAddLog: (log: Omit<LogEntry, 'id' | 'timestamp'>) => void;
}
export default function Logbook({ logs, devices, users, currentUser, onAddLog }: LogbookProps) {
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
// Custom Maintenance Log state
const [showAddLog, setShowAddLog] = useState(false);
const [targetDeviceId, setTargetDeviceId] = useState('');
const [logMessage, setLogMessage] = useState('');
// Sorted list: latest logs first
const sortedLogs = [...logs].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
// Filter logs
const filteredLogs = sortedLogs.filter(log => {
const matchesSearch = log.message.toLowerCase().includes(searchTerm.toLowerCase());
const matchesType = typeFilter === 'all' || log.type === typeFilter;
return matchesSearch && matchesType;
});
const handleSubmitLog = (e: React.FormEvent) => {
e.preventDefault();
if (!logMessage.trim()) return;
let finalMsg = logMessage;
if (targetDeviceId) {
const dev = devices.find(x => x.id === targetDeviceId);
if (dev) {
finalMsg = `[Maintenance on ${dev.hostname}] ${logMessage}`;
}
}
onAddLog({
type: 'maintenance',
message: `${currentUser.name} registered the following maintenance update: ${finalMsg}`,
deviceId: targetDeviceId || undefined,
userId: currentUser.id
});
setLogMessage('');
setTargetDeviceId('');
setShowAddLog(false);
};
const getLogTypeBadge = (type: string) => {
switch (type) {
case 'maintenance':
return 'bg-amber-950 border border-amber-900/60 text-amber-400';
case 'booking':
return 'bg-emerald-950 border border-emerald-900/60 text-emerald-400';
case 'status':
return 'bg-cyan-950 border border-cyan-900/60 text-cyan-400';
case 'system':
default:
return 'bg-slate-900 border border-slate-800 text-slate-350';
}
};
const getLogTypeLabel = (type: string) => {
switch (type) {
case 'maintenance': return 'Maintenance';
case 'booking': return 'Booking';
case 'status': return 'Status';
case 'system': default: return 'System';
}
};
return (
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 font-sans" id="logbook-dashboard">
{/* LEFT COLUMN: Chronological Log List */}
<div className="lg:col-span-8 bg-[#1E293B] border border-slate-800 rounded-xl p-5 flex flex-col h-full" id="logbook-ledger-card">
<div className="flex justify-between items-center mb-4">
<div>
<h2 className="text-base font-bold text-white flex items-center gap-2">
<History className="w-5 h-5 text-emerald-400" />
Audit Log & Maintenance Journal
</h2>
<p className="text-xs text-slate-400">Append-only history of who touched what. git blame, but for the lab.</p>
</div>
<button
onClick={() => setShowAddLog(!showAddLog)}
className="px-3 py-1.5 bg-slate-900 border border-slate-800 text-slate-200 hover:text-emerald-400 hover:border-emerald-500 rounded-lg text-xs font-semibold flex items-center gap-1.5 transition-all font-sans hover:cursor-pointer"
id="btn-toggle-add-log"
>
<Plus className="w-4 h-4 text-emerald-400" />
File Maintenance Report
</button>
</div>
{/* Search and Filters toolbar */}
<div className="flex flex-col sm:flex-row gap-3 mb-4 p-3 bg-slate-900/60 border border-slate-850 rounded-lg">
<div className="relative flex-1">
<span className="absolute left-3 top-2.5 text-slate-550">
<Search className="w-4 h-4" />
</span>
<input
type="text"
placeholder="Filter audit log entries..."
value={searchTerm}
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"
/>
</div>
<div className="flex gap-1 shrink-0 text-xs font-medium">
{['all', 'booking', 'maintenance'].map((type) => (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium capitalize transition-all ${
typeFilter === type
? 'bg-emerald-500/15 border border-emerald-500 text-emerald-400'
: 'bg-slate-950 text-slate-400 border border-slate-850 hover:bg-slate-850 hover:text-white'
}`}
>
{type === 'all' ? 'All' : getLogTypeLabel(type)}
</button>
))}
</div>
</div>
{/* Audit Log Sheet */}
<div className="flex-1 overflow-y-auto max-h-[550px] space-y-2 pr-1">
{filteredLogs.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">
No audit records match the selected filtering rules.
</p>
) : (
filteredLogs.map((log) => {
const dev = devices.find(d => d.id === log.deviceId);
const user = users.find(u => u.id === log.userId);
const timestampFormatted = new Date(log.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'medium'
});
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 className="flex flex-col items-center gap-1.5 shrink-0 pt-0.5 min-w-[80px]">
<span className={`text-[9px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded font-sans scale-90 text-center block w-full ${getLogTypeBadge(log.type)}`}>
{getLogTypeLabel(log.type)}
</span>
<span className="text-[9px] font-mono text-slate-500 leading-none">
{new Date(log.timestamp).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className="flex-1">
<p className="text-xs text-slate-200 leading-relaxed font-sans">{log.message}</p>
<div className="mt-1.5 flex flex-wrap items-center gap-3 text-[10px] font-mono text-slate-500 pt-1.5 border-t border-slate-900/40">
<span>Calendar Time: {timestampFormatted}</span>
{user && (
<span className="flex items-center gap-1 text-slate-400">
<UserIcon className="w-3 h-3 text-slate-500" />
Operator: {user.name}
</span>
)}
{dev && (
<span className="flex items-center gap-1 text-emerald-450 font-semibold">
<Server className="w-3 h-3 text-slate-500" />
Node: {dev.hostname}
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</div>
{/* RIGHT COLUMN: Interactive Maintenance Addition Form */}
<div className="lg:col-span-4" id="logbook-forms-side">
{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="flex items-center justify-between pb-2 border-b border-slate-800">
<h3 className="font-bold text-sm text-slate-100 flex items-center gap-1.5">
<Hammer className="w-4 h-4 text-amber-500" />
Journal Maintenance Work
</h3>
<button
onClick={() => setShowAddLog(false)}
className="text-slate-400 hover:text-white"
>
Cancel
</button>
</div>
<form onSubmit={handleSubmitLog} className="space-y-4 text-xs">
<div>
<label className="block text-slate-300 font-semibold mb-1">Target Network Host (Optional)</label>
<select
value={targetDeviceId}
onChange={(e) => setTargetDeviceId(e.target.value)}
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
>
<option value="">-- Complete Lab Cluster / General Event --</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{d.hostname} ({d.ip})
</option>
))}
</select>
</div>
<div>
<label className="block text-slate-300 font-semibold mb-1">Documented Actions / Findings</label>
<textarea
required
rows={4}
value={logMessage}
onChange={(e) => setLogMessage(e.target.value)}
className="w-full bg-slate-950 text-slate-200 border border-slate-800 rounded p-2 focus:outline-none"
placeholder="Describe SFP fiber replacements, port trunking alterations, routing table diagnostic evaluations..."
/>
</div>
<div className="bg-slate-900 border border-slate-800 p-2.5 rounded text-[11px] text-slate-450 leading-normal flex gap-2">
<Info className="w-4 h-4 text-emerald-400 shrink-0 mt-0.5" />
<span>This writes straight to the shared ledger - everyone on the team sees it, so spell it like the next on-call has to read it at 3am.</span>
</div>
<button
type="submit"
className="w-full py-2 bg-emerald-600 hover:bg-emerald-500 text-slate-950 font-bold rounded text-xs flex items-center justify-center gap-1.5 transition-colors"
>
<Save className="w-3.5 h-3.5" />
Publish to Shared Log Book
</button>
</form>
</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">
<h3 className="font-bold text-white mb-2 text-sm flex items-center gap-2">
<ChevronRight className="w-4 h-4 text-emerald-400 shrink-0" />
Shared Audit & Fault Logging
</h3>
<p>
Labs drift. If you hit an STP block, a bridging loop or that one port that mysteriously err-disables itself, write down how you dug out. future-you (and the next on-call) will thank you.
</p>
<div className="mt-4 p-3 bg-amber-950/20 border border-amber-900/40 rounded-lg text-amber-300 font-mono text-[10px]">
Tip: hit "File Maintenance Report" up top to log a patch, a swap or a magic-smoke incident.
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,153 @@
import React, { useState } from 'react';
import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types';
import { LogIn, Eye, EyeOff, AlertCircle } from 'lucide-react';
interface LoginPageProps {
onLogin: (user: User) => void;
onNavigateToRegister: () => void;
}
export default function LoginPage({ onLogin, onNavigateToRegister }: LoginPageProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const res = await authFetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Login failed.');
return;
}
saveSession(data.token, data.user);
onLogin(data.user);
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<div className="text-center space-y-3">
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<div className="flex items-center justify-center gap-1.5 mt-2">
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirITSystems
</span>
</div>
</div>
</div>
{/* Login Card */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Sign in</h2>
<p className="text-xs text-slate-400 mt-1">Enter your credentials to access the platform.</p>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="email">
Email address
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
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"
placeholder="user@airit.rocks"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="password">
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
required
value={password}
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"
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<button
type="submit"
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"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<LogIn className="w-4 h-4" />
)}
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="text-center text-xs text-slate-400">
No account yet?{' '}
<button
onClick={onNavigateToRegister}
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Create one
</button>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,208 @@
import React, { useState } from 'react';
import { GhostGridLogo } from './Header';
import { authFetch, saveSession } from '../lib/auth';
import { User } from '../types';
import { UserPlus, Eye, EyeOff, AlertCircle, CheckCircle2 } from 'lucide-react';
interface RegisterPageProps {
onLogin: (user: User) => void;
onNavigateToLogin: () => void;
}
export default function RegisterPage({ onLogin, onNavigateToLogin }: RegisterPageProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const passwordStrong = password.length >= 8;
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
if (!passwordStrong) {
setError('Password must be at least 8 characters.');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match.');
return;
}
setLoading(true);
try {
const res = await authFetch('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Registration failed.');
return;
}
saveSession(data.token, data.user);
onLogin(data.user);
} catch {
setError('Could not reach the server. Please try again.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-[#0B0F19] text-slate-100 font-sans flex items-center justify-center p-4">
<div className="w-full max-w-md space-y-8">
{/* Logo & Brand */}
<div className="text-center space-y-3">
<div className="inline-flex p-3 bg-slate-950/80 border border-slate-800 rounded-2xl shadow-[0_0_40px_rgba(6,182,212,0.15)]">
<GhostGridLogo className="w-14 h-14" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight text-white">GhostGrid</h1>
<p className="text-[10px] font-mono text-cyan-400 tracking-widest uppercase mt-0.5">
BUILD AND CONTROL INVISIBLE INFRASTRUCTURE
</p>
<div className="flex items-center justify-center gap-1.5 mt-2">
<span className="text-[9px] text-slate-500 font-sans">A product by</span>
<span className="airit-badge inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[8px] font-sans font-semibold">
<span className="w-1.5 h-1.5 rounded-sm bg-white/70 inline-block" />
AirIT Systems
</span>
</div>
</div>
</div>
{/* Register Card */}
<div className="bg-[#0F172A] border border-[#1E293B] rounded-2xl p-8 shadow-2xl space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Create account</h2>
<p className="text-xs text-slate-400 mt-1">Register to gain access to the platform.</p>
</div>
{error && (
<div className="flex items-center gap-2 bg-red-950/60 border border-red-800/60 rounded-lg px-3 py-2.5 text-xs text-red-300">
<AlertCircle className="w-4 h-4 shrink-0 text-red-400" />
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-name">
Full name
</label>
<input
id="reg-name"
type="text"
autoComplete="name"
required
value={name}
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"
placeholder="Max Mustermann"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-email">
Email address
</label>
<input
id="reg-email"
type="email"
autoComplete="email"
required
value={email}
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"
placeholder="you@example.com"
/>
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-password">
Password
</label>
<div className="relative">
<input
id="reg-password"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={password}
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"
placeholder="Min. 8 characters"
/>
<button
type="button"
onClick={() => setShowPassword(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-200 transition-colors"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{password.length > 0 && (
<div className={`flex items-center gap-1.5 text-[11px] ${passwordStrong ? 'text-emerald-400' : 'text-amber-400'}`}>
<CheckCircle2 className="w-3 h-3" />
{passwordStrong ? 'Password meets requirements' : 'At least 8 characters required'}
</div>
)}
</div>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300" htmlFor="reg-confirm">
Confirm password
</label>
<input
id="reg-confirm"
type={showPassword ? 'text' : 'password'}
autoComplete="new-password"
required
value={confirmPassword}
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 ${
confirmPassword.length > 0 && confirmPassword !== password
? 'border-red-700 focus:ring-red-500/50'
: 'border-slate-700 focus:ring-cyan-500/50 focus:border-cyan-500/50'
}`}
placeholder="Repeat password"
/>
</div>
<button
type="submit"
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"
>
{loading ? (
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<UserPlus className="w-4 h-4" />
)}
{loading ? 'Creating account…' : 'Create account'}
</button>
</form>
<p className="text-center text-xs text-slate-400">
Already have an account?{' '}
<button
onClick={onNavigateToLogin}
className="text-cyan-400 hover:text-cyan-300 font-semibold transition-colors"
>
Sign in
</button>
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,300 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState } from 'react';
import { Device, TopologyLink } from '../types';
import { Activity, Shield, Wifi, Server, Cpu } from 'lucide-react';
interface TopologyPanelProps {
devices: Device[];
links: TopologyLink[];
onSelectDevice?: (device: Device) => void;
}
export default function TopologyPanel({ devices, links, onSelectDevice }: TopologyPanelProps) {
const [hoveredLink, setHoveredLink] = useState<TopologyLink | null>(null);
// Layout calculations for nodes in a circle/nice grid layout inside an SVG viewport of 800x400
const width = 800;
const height = 400;
const cx = width / 2;
const cy = height / 2;
const radius = 130;
// Let's map each device ID to a constant (x, y) coordinate so we get beautiful layouts
const getCoordinates = (index: number, total: number) => {
if (total === 1) {
return { x: cx, y: cy };
}
if (total === 2) {
return index === 0 ? { x: cx - 180, y: cy } : { x: cx + 180, y: cy };
}
if (total === 3) {
if (index === 0) return { x: cx, y: cy - 90 };
if (index === 1) return { x: cx - 180, y: cy + 90 };
return { x: cx + 180, y: cy + 90 };
}
const angle = (index * 2 * Math.PI) / total - Math.PI / 2;
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
};
};
const nodePositions = devices.reduce((acc, dev, idx) => {
acc[dev.id] = getCoordinates(idx, devices.length);
return acc;
}, {} as Record<string, { x: number; y: number }>);
// Multiple links between the same device pair would otherwise stack on one
// straight line. We fan them out into separate quadratic-Bézier curves with a
// symmetric perpendicular offset, and place each badge on its curve's apex.
const pairKey = (a: string, b: string) => [a, b].sort().join('::');
const parallelSpread = 26; // px between adjacent parallel links
const linkPairCounts: Record<string, number> = {};
links.forEach((l) => {
const k = pairKey(l.fromDevice, l.toDevice);
linkPairCounts[k] = (linkPairCounts[k] || 0) + 1;
});
const pairSeen: Record<string, number> = {};
const linkLayout = links.map((link) => {
const start = nodePositions[link.fromDevice];
const end = nodePositions[link.toDevice];
if (!start || !end) return null;
const k = pairKey(link.fromDevice, link.toDevice);
const total = linkPairCounts[k];
const indexInPair = pairSeen[k] ?? 0;
pairSeen[k] = indexInPair + 1;
// Signed offset symmetric around the direct line: …, -1, 0, +1, …
const offset = total > 1 ? parallelSpread * (indexInPair - (total - 1) / 2) : 0;
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
// Perpendicular from a canonical endpoint order, so every link in the same
// pair bows consistently regardless of its from/to direction.
const [firstId, secondId] = [link.fromDevice, link.toDevice].sort();
const p1 = nodePositions[firstId];
const p2 = nodePositions[secondId];
const len = Math.hypot(p2.x - p1.x, p2.y - p1.y) || 1;
const perpX = -(p2.y - p1.y) / len;
const perpY = (p2.x - p1.x) / len;
// Control point at 2×offset puts the curve apex (t=0.5) exactly at offset.
const cpX = midX + perpX * offset * 2;
const cpY = midY + perpY * offset * 2;
return {
path: `M ${start.x} ${start.y} Q ${cpX} ${cpY} ${end.x} ${end.y}`,
apexX: midX + perpX * offset,
apexY: midY + perpY * offset,
};
});
const getDeviceIcon = (type: string) => {
switch (type) {
case 'Firewall':
return <Shield className="w-5 h-5 text-rose-400" />;
case 'Access-Point':
return <Wifi className="w-5 h-5 text-amber-400" />;
case 'Controller':
return <Cpu className="w-5 h-5 text-cyan-400" />;
case 'Switch':
default:
return <Server className="w-5 h-5 text-teal-400" />;
}
};
const getDeviceColorClass = (type: string) => {
switch (type) {
case 'Firewall':
return 'border-rose-500/60 bg-rose-950/20 text-rose-200';
case 'Access-Point':
return 'border-amber-500/60 bg-amber-950/20 text-amber-200';
case 'Controller':
return 'border-cyan-500/60 bg-cyan-950/20 text-cyan-200';
case 'Switch':
default:
return 'border-teal-500/60 bg-teal-950/20 text-teal-200';
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-xl p-4 shadow-inner" id="topology-panel">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-slate-100 flex items-center gap-2 font-sans">
<Activity className="w-4 h-4 text-emerald-400 animate-pulse" />
Interactive Topology Diagram (Physical & Logical Links)
</h3>
<p className="text-[11px] text-slate-400 font-sans">Hover links to inspect port details. Click host nodes to view operational recovery playbooks.</p>
</div>
<div className="flex gap-2 text-[10px] font-mono">
<span className="flex items-center gap-1 text-teal-400 bg-teal-950/40 px-2 py-0.5 rounded border border-teal-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-500"></span> Switch
</span>
<span className="flex items-center gap-1 text-rose-400 bg-rose-950/40 px-2 py-0.5 rounded border border-rose-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span> Firewall
</span>
<span className="flex items-center gap-1 text-amber-400 bg-amber-950/40 px-2 py-0.5 rounded border border-amber-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span> AP
</span>
<span className="flex items-center gap-1 text-cyan-400 bg-cyan-950/40 px-2 py-0.5 rounded border border-cyan-900/50">
<span className="w-1.5 h-1.5 rounded-full bg-cyan-500"></span> WLC
</span>
</div>
</div>
<div className="relative overflow-auto border border-slate-800 rounded-lg bg-slate-950/60 flex justify-center items-center">
{devices.length === 0 ? (
<div className="py-20 text-center text-slate-500 text-xs font-sans">
No active hardware nodes assigned. Build or update a lab template to associate physical equipment.
</div>
) : (
<div className="min-w-[800px] h-[340px] relative">
{/* SVG Link lines */}
<svg className="absolute inset-0 w-full h-full pointer-events-none z-10">
<defs>
<marker
id="arrow"
viewBox="0 0 10 10"
refX="6"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#475569" />
</marker>
{/* Glow effects for active physical links */}
<filter id="glow-teal" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="4" result="glow" />
<feComposite in="SourceGraphic" in2="glow" operator="over" />
</filter>
</defs>
{links.map((link, idx) => {
const layout = linkLayout[idx];
if (!layout) return null;
const isHovered = hoveredLink === link;
const isTrunk = link.type.toLowerCase().includes('trunk');
return (
<g key={`link-${idx}`} className="pointer-events-auto cursor-pointer">
{/* Hover hotspot path */}
<path
d={layout.path}
fill="none"
stroke="transparent"
strokeWidth="15"
onMouseEnter={() => setHoveredLink(link)}
onMouseLeave={() => setHoveredLink(null)}
/>
{/* Background stroke path */}
<path
d={layout.path}
fill="none"
stroke={isHovered ? '#10b981' : '#334155'}
strokeWidth={isHovered ? '3' : '2'}
strokeDasharray={isTrunk ? '0' : '4 4'}
className="transition-all duration-200"
/>
{/* Tiny animated flow dots for trunk links */}
{isTrunk && (
<path
d={layout.path}
fill="none"
stroke="#10B981"
strokeWidth="1.5"
strokeDasharray="8 30"
style={{ animation: 'dash 4s linear infinite' }}
/>
)}
</g>
);
})}
</svg>
{/* Custom SVG styling to support animated dots along lines */}
<style>{`
@keyframes dash {
to {
stroke-dashoffset: -100;
}
}
`}</style>
{/* Link Description Badges overlay (placed on each curve's apex so
parallel links between the same pair don't overlap) */}
{links.map((link, idx) => {
const layout = linkLayout[idx];
if (!layout) return null;
const isHovered = hoveredLink === link;
return (
<div
key={`badge-${idx}`}
className={`absolute -translate-x-1/2 -translate-y-1/2 px-2 py-0.5 rounded text-[9px] font-mono transition-all z-20 shadow pointer-events-none ${
isHovered
? 'bg-emerald-500 text-slate-950 scale-110 font-bold border border-emerald-400 z-30'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}
style={{ left: layout.apexX, top: layout.apexY }}
>
{link.type}
</div>
);
})}
{/* Virtual Device Nodes */}
{devices.map((device) => {
const pos = nodePositions[device.id];
if (!pos) return null;
return (
<button
key={device.id}
onClick={() => onSelectDevice && onSelectDevice(device)}
className={`absolute group -translate-x-1/2 -translate-y-1/2 p-3 border-2 rounded-xl flex flex-col items-center gap-1.5 transition-all text-center z-20 ${getDeviceColorClass(device.type)} hover:bg-slate-900/90 hover:scale-105 hover:border-emerald-500/80 hover:shadow-[0_0_15px_rgba(16,185,129,0.15)]`}
style={{ left: pos.x, top: pos.y }}
title={`${device.hostname} (${device.ip}) - Click for troubleshooting details`}
>
{/* Status Indicator (sourced from CheckMK; grey = unknown) */}
<span className={`absolute -top-1 -right-1 w-3 h-3 rounded-full border-2 border-slate-950 ${
!device.checkMkUrl ? 'bg-slate-500' :
device.status === 'online' ? 'bg-emerald-500' :
device.status === 'offline' ? 'bg-rose-500' : 'bg-slate-500'
}`} />
<div className="p-1.5 bg-slate-950/60 rounded-lg border border-slate-800/40">
{getDeviceIcon(device.type)}
</div>
<div className="leading-none">
<p className="text-[11px] font-mono font-bold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
{device.hostname}
</p>
<p className="text-[9px] font-mono text-slate-400 group-hover:text-slate-300 mt-0.5">
{device.ip}
</p>
</div>
</button>
);
})}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,169 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { User, Booking } from '../types';
import { Users, Search, Mail, Calendar, Activity } from 'lucide-react';
interface UserDirectoryProps {
users: User[];
currentUser: User;
bookings: Booking[];
}
// Deterministic accent so a given user always renders the same colour.
const AVATAR_COLORS = [
'from-emerald-500 to-teal-600',
'from-cyan-500 to-blue-600',
'from-indigo-500 to-violet-600',
'from-amber-500 to-orange-600',
'from-rose-500 to-pink-600',
'from-fuchsia-500 to-purple-600',
];
function colorFor(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
}
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 UserDirectory({ users, currentUser, bookings }: UserDirectoryProps) {
const [search, setSearch] = useState('');
const bookingCount = useMemo(() => {
const map = new Map<string, number>();
bookings.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const activeCount = useMemo(() => {
const map = new Map<string, number>();
bookings
.filter(b => b.status === 'active' || b.status === 'upcoming')
.forEach(b => map.set(b.userId, (map.get(b.userId) ?? 0) + 1));
return map;
}, [bookings]);
const filtered = useMemo(() => {
const q = search.toLowerCase();
const sorted = [...users].sort((a, b) => a.name.localeCompare(b.name));
if (!q) return sorted;
return sorted.filter(u =>
u.name.toLowerCase().includes(q) ||
u.email.toLowerCase().includes(q) ||
u.role.toLowerCase().includes(q)
);
}, [users, search]);
return (
<div className="space-y-6 font-sans" id="user-directory-root">
{/* 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="absolute top-0 right-0 p-8 text-slate-800 pointer-events-none select-none font-mono text-9xl font-extrabold opacity-10">
TEAM
</div>
<div className="relative space-y-1.5">
<h2 className="text-2xl font-bold tracking-tight text-white flex items-center gap-2.5">
<Users className="w-6 h-6 text-emerald-400" />
Registered Operators
</h2>
<p className="text-xs text-slate-300 max-w-xl leading-relaxed">
Everyone with an account on this box. booking counts come straight from the shared reservation pool - no shadow IT here.
</p>
<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">
<Users className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{users.length}</strong> registered
</span>
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Calendar className="w-3.5 h-3.5 text-indigo-400" />
<strong className="text-white font-mono">{bookings.length}</strong> total bookings
</span>
<span className="inline-flex items-center gap-1.5 bg-slate-950/60 border border-slate-800 rounded-lg px-3 py-1.5 text-[11px] text-slate-300">
<Activity className="w-3.5 h-3.5 text-emerald-400" />
<strong className="text-white font-mono">{bookings.filter(b => b.status === 'active' || b.status === 'upcoming').length}</strong> active / upcoming
</span>
</div>
</div>
</div>
{/* Search */}
<div className="relative">
<span className="absolute left-3 top-2.5 text-slate-550"><Search className="w-4 h-4" /></span>
<input
type="text"
placeholder="Search operators by name, email or role…"
value={search}
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"
/>
</div>
{/* User grid */}
{filtered.length === 0 ? (
<p className="text-center py-16 text-slate-500 text-xs">No operators match your search.</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map(user => {
const isMe = user.id === currentUser.id;
const total = bookingCount.get(user.id) ?? 0;
const active = activeCount.get(user.id) ?? 0;
return (
<div
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'}`}
>
{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">
You
</span>
)}
<div className="flex items-center gap-3.5">
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${colorFor(user.id)} flex items-center justify-center text-white font-bold text-sm shrink-0 shadow-inner`}>
{initials(user.name)}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-bold text-white truncate">{user.name}</h3>
<a
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"
>
<Mail className="w-3 h-3 shrink-0" />
<span className="truncate">{user.email}</span>
</a>
</div>
</div>
<div className="mt-4 pt-3 border-t border-slate-850 flex items-center justify-between">
<span className="text-[10px] font-mono text-slate-500 uppercase tracking-wider">Operator</span>
<div className="flex items-center gap-3 text-[10px] font-mono text-slate-400">
<span className="flex items-center gap-1" title="Total bookings">
<Calendar className="w-3 h-3 text-indigo-400" />
{total}
</span>
<span className="flex items-center gap-1" title="Active / upcoming bookings">
<Activity className="w-3 h-3 text-emerald-400" />
{active}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

559
src/index.css Normal file
View File

@ -0,0 +1,559 @@
/* Fonts are self-hosted via @fontsource (imported in main.tsx) - no external CDN. */
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
}
/* ── AirIT brand tokens ────────────────────────────────────────── */
:root {
--airit-navy: #003A70;
--airit-navy-dark: #002B55;
--airit-blue: #005AA0;
--airit-gray: #6F7478;
--airit-text: #1F2933;
--airit-bg: #FFFFFF;
--airit-bg-soft: #F3F5F7;
--airit-border: #D6DADF;
}
/* ── CSS custom properties ─────────────────────────────────────── */
:root {
--bg: #0b0f19;
--bg-header: #0f172a;
--bg-card: #1e293b;
--bg-inner: #090d16;
--bg-input: #020408;
--border: #1e293b;
--border-muted:#334155;
--text: #f1f5f9;
--text-muted: #94a3b8;
--text-label: #cbd5e1;
}
:root.light {
--bg: #f1f5f9;
--bg-header: #ffffff;
--bg-card: #ffffff;
--bg-inner: #f8fafc;
--bg-input: #ffffff;
--border: #e2e8f0;
--border-muted:#cbd5e1;
--text: #0f172a;
--text-muted: #475569;
--text-label: #334155;
}
/* ── LIGHT MODE OVERRIDES ─────────────────────────────────────── */
/* Root / body */
:root.light body,
:root.light #main-root {
background-color: var(--bg) !important;
color: var(--text) !important;
}
/* ── Backgrounds: all dark hex variants → card/inner */
:root.light .bg-\[\#0B0F19\],
:root.light .bg-\[\#0b0f19\] {
background-color: var(--bg) !important;
}
:root.light .bg-\[\#0F172A\],
:root.light .bg-\[\#0f172a\] {
background-color: var(--bg-header) !important;
border-color: var(--border) !important;
}
:root.light .bg-\[\#1E293B\],
:root.light .bg-\[\#1e293b\] {
background-color: var(--bg-card) !important;
border-color: var(--border) !important;
}
/* Amber-tinted warning/safety card used in Dashboard */
:root.light .bg-\[\#1D2535\],
:root.light .bg-\[\#1d2535\] {
background-color: #fffbeb !important;
border-color: #fde68a !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\/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 banner gradient ────────────────────────────────── */
:root.light .bg-gradient-to-br {
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-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 .divide-slate-800 > *,
:root.light .divide-slate-850 > * {
border-color: var(--border) !important;
}
/* ── Text colours ─────────────────────────────────────────────── */
:root.light .text-white,
: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-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;
}
/* ── AirIT badge - always white text on navy, regardless of theme ─ */
.airit-badge {
color: #ffffff !important;
background-color: var(--airit-navy) !important;
}
/* ── Text selection ───────────────────────────────────────────── */
:root.light ::selection {
background-color: rgba(5, 150, 105, 0.2) !important;
color: #047857 !important;
}
/* ── Reservation Details Modal (BookingDetailsModal) ──────────── */
/* Device node cards inside right panel */
:root.light #booking-details-modal .bg-slate-950\/65 {
background-color: #f8fafc !important;
border-color: var(--border) !important;
}
/* Developer panel wrapper - restore dark terminal feel */
:root.light #booking-details-modal .font-mono.bg-slate-950 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
}
/* Terminal output area bg-slate-1000 */
:root.light #booking-details-modal .bg-slate-1000 {
background-color: #0d1117 !important;
color: #c9d1d9 !important;
border-color: #30363d !important;
}
/* Ansible/terminal button trigger row bg-slate-900/40 - keep readable */
:root.light #booking-details-modal .bg-slate-900\/40 {
background-color: #f1f5f9 !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
/* ── Reserve Slot selects & date pickers (BookingCalendar) ───── */
:root.light #booking-actions-card select,
:root.light #booking-actions-card input[type="text"],
:root.light #booking-actions-card input[type="date"],
:root.light #booking-actions-card textarea {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
color: var(--text) !important;
}
/* Date picker calendar icon - invert to dark in light mode */
:root.light input[type="date"]::-webkit-calendar-picker-indicator {
filter: invert(0.6);
cursor: pointer;
}
/* ── Define Ports dropdowns & Link Builder (LabTemplates modal) ── */
/* Non-standard text/border classes used in the link builder */
:root.light .text-slate-250 {
color: var(--text) !important;
}
:root.light .border-slate-805 {
border-color: var(--border) !important;
}
/* Selects and inputs inside any fixed modal overlay */
:root.light .fixed.inset-0 select,
:root.light .fixed.inset-0 input[type="text"],
:root.light .fixed.inset-0 input[type="email"],
:root.light .fixed.inset-0 input[type="password"],
:root.light .fixed.inset-0 input[type="date"],
:root.light .fixed.inset-0 textarea {
background-color: #ffffff !important;
border-color: var(--border-muted) !important;
color: var(--text) !important;
}
/* Link row items inside modal */
:root.light .fixed.inset-0 .bg-slate-950.font-mono {
background-color: #f8fafc !important;
border-color: var(--border) !important;
color: var(--text) !important;
}
/* "Add Link" button - keep readable white label on indigo in light mode
(the global :root.light .text-white override would otherwise darken it) */
:root.light #add-link-btn {
background-color: #4f46e5 !important;
color: #ffffff !important;
}
:root.light #add-link-btn:hover {
background-color: #6366f1 !important;
}

35
src/lib/auth.ts Normal file
View File

@ -0,0 +1,35 @@
import { User } from '../types';
const TOKEN_KEY = 'ghostgrid_token';
const USER_KEY = 'ghostgrid_user';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function getStoredUser(): User | null {
const raw = localStorage.getItem(USER_KEY);
return raw ? JSON.parse(raw) : null;
}
export function saveSession(token: string, user: User): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(USER_KEY, JSON.stringify(user));
}
export function clearSession(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_KEY);
}
export async function authFetch(input: RequestInfo, init: RequestInit = {}): Promise<Response> {
const token = getToken();
return fetch(input, {
...init,
headers: {
'Content-Type': 'application/json',
...(init.headers as Record<string, string>),
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
}

18
src/main.tsx Normal file
View File

@ -0,0 +1,18 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
// Self-hosted fonts - bundled locally so the app works fully offline (no Google Fonts CDN).
import '@fontsource/inter/400.css';
import '@fontsource/inter/500.css';
import '@fontsource/inter/600.css';
import '@fontsource/inter/700.css';
import '@fontsource/jetbrains-mono/400.css';
import '@fontsource/jetbrains-mono/500.css';
import '@fontsource/jetbrains-mono/700.css';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

76
src/types.ts Normal file
View File

@ -0,0 +1,76 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
// Known presets plus any custom class the user defines.
// The `(string & {})` keeps literal autocomplete while allowing arbitrary values.
export type DeviceType = 'Switch' | 'Access-Point' | 'Firewall' | 'Controller' | (string & {});
export interface Device {
id: string;
hostname: string;
ip: string;
location: string;
notes: string;
type: DeviceType;
status: 'online' | 'offline' | 'unknown'; // 'unknown' until CheckMK reports a state
emergencySheet: string; // Markdown text
checkMkUrl: string; // Link to this host in CheckMK; live status comes from the CheckMK API
lastCheckedAt?: string;
}
export interface TopologyLink {
fromDevice: string;
toDevice: string;
type: string; // e.g. "LACP-Trunk", "Uplink", "OOB-Management", "VLAN-Core"
}
export interface LabTemplate {
id: string;
name: string;
description: string;
contactPerson: string;
location: string;
deviceIds: string[];
topology: TopologyLink[];
}
export interface Booking {
id: string;
labId: string;
userId: string;
startDateTime: string;
endDateTime: string;
notes: string;
status: 'active' | 'upcoming' | 'completed' | 'cancelled';
notified: boolean;
emailSent?: boolean;
}
export interface LogEntry {
id: string;
timestamp: string;
type: 'maintenance' | 'booking' | 'status' | 'system';
message: string;
deviceId?: string;
userId?: string;
}
export interface User {
id: string;
name: string;
role: string;
email: string;
}
export interface QuickLink {
id: string;
title: string;
url: string;
description: string;
category: string;
color: string; // accent key, e.g. 'emerald' | 'cyan' | 'amber' | ...
createdBy?: string;
createdAt: string;
}