feat: allow booking offline devices, keep reachability warning
This commit is contained in:
@ -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
|
||||
|
||||
Reference in New Issue
Block a user