feat: allow booking offline devices, keep reachability warning

This commit is contained in:
Brückner
2026-06-04 13:40:32 +02:00
parent 97e1b1a665
commit b7a3d2086d

View File

@ -5,11 +5,10 @@ import {
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 {
function isOnline(d: Device): boolean {
return effectiveStatus(d) === 'online';
}
@ -156,11 +155,11 @@ export default function BookingCalendar({
return { hasConflict: false };
}
// Devices in the current selection that CheckMK does not report as online - these block the booking.
function blockingDevices(deviceIds: string[]): Device[] {
// Devices in the current selection that CheckMK does not report as online - shown as a warning only.
function offlineDevices(deviceIds: string[]): Device[] {
return deviceIds
.map(id => devices.find(d => d.id === id))
.filter((d): d is Device => !!d && !isBookable(d));
.filter((d): d is Device => !!d && !isOnline(d));
}
// ── available-now helpers for Quick Booking ────────────────────────────
@ -171,17 +170,14 @@ export default function BookingCalendar({
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.
// A lab is quick-bookable when every device is free (regardless of online status).
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);
})
lab.deviceIds.every(dId => !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)
!isDeviceBooked(dev.id, quickWindow.startMs, quickWindow.endMs)
), [devices, bookings, quickWindow]);
// ── booking actions ────────────────────────────────────────────────────
@ -191,12 +187,6 @@ export default function BookingCalendar({
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; }
@ -225,10 +215,6 @@ export default function BookingCalendar({
};
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({
@ -312,16 +298,22 @@ export default function BookingCalendar({
<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>
<p className="text-xs text-slate-400 text-center py-6 italic">Nothing free for {quickDuration}h right now. All slots leased.</p>
) : (
availableLabs.map(lab => {
const labDevices = lab.deviceIds.map(id => devices.find(d => d.id === id)).filter(Boolean) as Device[];
const offlineCount = labDevices.filter(d => !isOnline(d)).length;
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>
{offlineCount > 0 && (
<p className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono mt-0.5">
<AlertTriangle className="w-2.5 h-2.5 shrink-0" />{offlineCount} device{offlineCount !== 1 ? 's' : ''} not reachable
</p>
)}
</div>
<button
onClick={() => handleQuickBookLab(lab)}
@ -338,30 +330,30 @@ export default function BookingCalendar({
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'
free ? '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>
{!online && free && (
<span className="flex items-center gap-0.5 text-[9px] text-amber-400 font-mono" title="Not reachable in CheckMK">
<AlertTriangle className="w-2.5 h-2.5" />{status}
</span>
)}
</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 ? (
{free ? (
<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>
)}
@ -597,7 +589,7 @@ export default function BookingCalendar({
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{isBookable(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
{isOnline(d) ? '🟢' : ''} {d.hostname} · {d.type} ({d.ip})
</option>
))}
</select>
@ -671,28 +663,31 @@ export default function BookingCalendar({
{(() => {
const deviceIds = targetDeviceIds();
const blocked = blockingDevices(deviceIds);
const offline = offlineDevices(deviceIds);
const conflict = checkConflict(deviceIds, startDate, startTime, endDate, endTime);
if (blocked.length > 0) {
if (conflict.hasConflict) {
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>
) : (
);
}
if (offline.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>
Warning {offline.map(d => `${d.hostname} (${effectiveStatus(d)})`).join(', ')} {offline.length === 1 ? 'is' : 'are'} not reachable in CheckMK. Booking will still be created.
</span>
</div>
);
}
return (
<div className="bg-emerald-950/20 p-2.5 rounded border border-emerald-900/40 flex gap-2 text-emerald-300 text-[11px] leading-normal">
<CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
<span>Online & free. Timeframe is available.</span>
<span>Timeframe is available.</span>
</div>
);
})()}
@ -700,7 +695,6 @@ export default function BookingCalendar({
{(() => {
const deviceIds = targetDeviceIds();
const disabled = deviceIds.length === 0
|| blockingDevices(deviceIds).length > 0
|| checkConflict(deviceIds, startDate, startTime, endDate, endTime).hasConflict;
return (
<button