Initial commit
This commit is contained in:
582
src/App.tsx
Normal file
582
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
811
src/components/BookingCalendar.tsx
Normal file
811
src/components/BookingCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
457
src/components/BookingDetailsModal.tsx
Normal file
457
src/components/BookingDetailsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
420
src/components/Dashboard.tsx
Normal file
420
src/components/Dashboard.tsx
Normal 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 ->
|
||||
</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>
|
||||
);
|
||||
}
|
||||
629
src/components/DeviceInventory.tsx
Normal file
629
src/components/DeviceInventory.tsx
Normal 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
249
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
515
src/components/LabTemplates.tsx
Normal file
515
src/components/LabTemplates.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
367
src/components/LinkDashboard.tsx
Normal file
367
src/components/LinkDashboard.tsx
Normal 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
270
src/components/Logbook.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
153
src/components/LoginPage.tsx
Normal file
153
src/components/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
src/components/RegisterPage.tsx
Normal file
208
src/components/RegisterPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
300
src/components/TopologyPanel.tsx
Normal file
300
src/components/TopologyPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/components/UserDirectory.tsx
Normal file
169
src/components/UserDirectory.tsx
Normal 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
559
src/index.css
Normal 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
35
src/lib/auth.ts
Normal 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
18
src/main.tsx
Normal 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
76
src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user