Initial commit
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user