440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback } from "react";
|
|
import { toast } from "sonner";
|
|
import {
|
|
fetchMaintenanceWindows, createMaintenanceWindow, deleteMaintenanceWindow,
|
|
type MaintenanceWindow,
|
|
} from "@/lib/api";
|
|
import { PageShell } from "@/components/layout/page-shell";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
CalendarClock, Plus, Trash2, CheckCircle2, Clock, AlertTriangle,
|
|
BellOff, RefreshCw, X,
|
|
} from "lucide-react";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
const SITE_ID = "sg-01";
|
|
|
|
const TARGET_GROUPS = [
|
|
{
|
|
label: "Site",
|
|
targets: [{ value: "all", label: "Entire Site" }],
|
|
},
|
|
{
|
|
label: "Halls",
|
|
targets: [
|
|
{ value: "hall-a", label: "Hall A" },
|
|
{ value: "hall-b", label: "Hall B" },
|
|
],
|
|
},
|
|
{
|
|
label: "Racks — Hall A",
|
|
targets: [
|
|
{ value: "rack-A01", label: "Rack A01" },
|
|
{ value: "rack-A02", label: "Rack A02" },
|
|
{ value: "rack-A03", label: "Rack A03" },
|
|
{ value: "rack-A04", label: "Rack A04" },
|
|
{ value: "rack-A05", label: "Rack A05" },
|
|
],
|
|
},
|
|
{
|
|
label: "Racks — Hall B",
|
|
targets: [
|
|
{ value: "rack-B01", label: "Rack B01" },
|
|
{ value: "rack-B02", label: "Rack B02" },
|
|
{ value: "rack-B03", label: "Rack B03" },
|
|
{ value: "rack-B04", label: "Rack B04" },
|
|
{ value: "rack-B05", label: "Rack B05" },
|
|
],
|
|
},
|
|
{
|
|
label: "CRAC Units",
|
|
targets: [
|
|
{ value: "crac-01", label: "CRAC-01" },
|
|
{ value: "crac-02", label: "CRAC-02" },
|
|
],
|
|
},
|
|
{
|
|
label: "UPS",
|
|
targets: [
|
|
{ value: "ups-01", label: "UPS-01" },
|
|
{ value: "ups-02", label: "UPS-02" },
|
|
],
|
|
},
|
|
{
|
|
label: "Generator",
|
|
targets: [
|
|
{ value: "gen-01", label: "Generator GEN-01" },
|
|
],
|
|
},
|
|
];
|
|
|
|
// Flat list for looking up labels
|
|
const TARGETS_FLAT = TARGET_GROUPS.flatMap(g => g.targets);
|
|
|
|
const statusCfg = {
|
|
active: { label: "Active", cls: "bg-green-500/10 text-green-400 border-green-500/20", icon: CheckCircle2 },
|
|
scheduled: { label: "Scheduled", cls: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Clock },
|
|
expired: { label: "Expired", cls: "bg-muted/50 text-muted-foreground border-border", icon: AlertTriangle },
|
|
};
|
|
|
|
function StatusChip({ status }: { status: MaintenanceWindow["status"] }) {
|
|
const cfg = statusCfg[status];
|
|
const Icon = cfg.icon;
|
|
return (
|
|
<span className={cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border uppercase tracking-wide", cfg.cls)}>
|
|
<Icon className="w-3 h-3" /> {cfg.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function formatDt(iso: string): string {
|
|
return new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
|
|
}
|
|
|
|
// 7-day timeline strip
|
|
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
|
|
function TimelineStrip({ windows }: { windows: MaintenanceWindow[] }) {
|
|
const relevant = windows.filter(w => w.status === "active" || w.status === "scheduled");
|
|
if (relevant.length === 0) return null;
|
|
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const totalMs = 7 * 24 * 3600_000;
|
|
|
|
// Day labels
|
|
const days = Array.from({ length: 7 }, (_, i) => {
|
|
const d = new Date(today.getTime() + i * 24 * 3600_000);
|
|
return DAY_LABELS[d.getDay()];
|
|
});
|
|
|
|
return (
|
|
<div className="rounded-lg border border-border bg-muted/10 p-4 space-y-3">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
|
7-Day Maintenance Timeline
|
|
</p>
|
|
|
|
{/* Day column labels */}
|
|
<div className="relative">
|
|
<div className="flex text-[10px] text-muted-foreground mb-1">
|
|
{days.map((day, i) => (
|
|
<div key={i} className="flex-1 text-center">{day}</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Grid lines */}
|
|
<div className="relative h-auto">
|
|
<div className="absolute inset-0 flex pointer-events-none">
|
|
{Array.from({ length: 8 }, (_, i) => (
|
|
<div key={i} className="flex-1 border-l border-border/30 last:border-r" />
|
|
))}
|
|
</div>
|
|
|
|
{/* Window bars */}
|
|
<div className="space-y-1.5 pt-1 pb-1">
|
|
{relevant.map(w => {
|
|
const startMs = Math.max(0, new Date(w.start_dt).getTime() - today.getTime());
|
|
const endMs = Math.min(totalMs, new Date(w.end_dt).getTime() - today.getTime());
|
|
if (endMs <= 0 || startMs >= totalMs) return null;
|
|
|
|
const leftPct = (startMs / totalMs) * 100;
|
|
const widthPct = ((endMs - startMs) / totalMs) * 100;
|
|
|
|
const barCls = w.status === "active"
|
|
? "bg-green-500/30 border-green-500/50 text-green-300"
|
|
: "bg-blue-500/20 border-blue-500/40 text-blue-300";
|
|
|
|
return (
|
|
<div key={w.id} className="relative h-6">
|
|
<div
|
|
className={cn("absolute h-full rounded border text-[10px] font-medium flex items-center px-1.5 overflow-hidden", barCls)}
|
|
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "4px" }}
|
|
>
|
|
<span className="truncate">{w.title}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Defaults for new window form: now → +2h
|
|
function defaultStart() {
|
|
const d = new Date();
|
|
d.setSeconds(0, 0);
|
|
return d.toISOString().slice(0, 16);
|
|
}
|
|
function defaultEnd() {
|
|
const d = new Date(Date.now() + 2 * 3600_000);
|
|
d.setSeconds(0, 0);
|
|
return d.toISOString().slice(0, 16);
|
|
}
|
|
|
|
export default function MaintenancePage() {
|
|
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showForm, setShowForm] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [deleting, setDeleting] = useState<string | null>(null);
|
|
|
|
// Form state
|
|
const [title, setTitle] = useState("");
|
|
const [target, setTarget] = useState("all");
|
|
const [startDt, setStartDt] = useState(defaultStart);
|
|
const [endDt, setEndDt] = useState(defaultEnd);
|
|
const [suppress, setSuppress] = useState(true);
|
|
const [notes, setNotes] = useState("");
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const data = await fetchMaintenanceWindows(SITE_ID);
|
|
setWindows(data);
|
|
} catch { toast.error("Failed to load maintenance windows"); }
|
|
finally { setLoading(false); }
|
|
}, []);
|
|
|
|
useEffect(() => { load(); }, [load]);
|
|
|
|
async function handleCreate(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!title.trim()) return;
|
|
setSubmitting(true);
|
|
try {
|
|
const targetLabel = TARGETS_FLAT.find(t => t.value === target)?.label ?? target;
|
|
await createMaintenanceWindow({
|
|
site_id: SITE_ID,
|
|
title: title.trim(),
|
|
target,
|
|
target_label: targetLabel,
|
|
start_dt: new Date(startDt).toISOString(),
|
|
end_dt: new Date(endDt).toISOString(),
|
|
suppress_alarms: suppress,
|
|
notes: notes.trim(),
|
|
});
|
|
await load();
|
|
toast.success("Maintenance window created");
|
|
setShowForm(false);
|
|
setTitle(""); setNotes(""); setStartDt(defaultStart()); setEndDt(defaultEnd());
|
|
} catch { toast.error("Failed to create maintenance window"); }
|
|
finally { setSubmitting(false); }
|
|
}
|
|
|
|
async function handleDelete(id: string) {
|
|
setDeleting(id);
|
|
try { await deleteMaintenanceWindow(id); toast.success("Maintenance window deleted"); await load(); }
|
|
catch { toast.error("Failed to delete maintenance window"); }
|
|
finally { setDeleting(null); }
|
|
}
|
|
|
|
const active = windows.filter(w => w.status === "active").length;
|
|
const scheduled = windows.filter(w => w.status === "scheduled").length;
|
|
|
|
return (
|
|
<PageShell className="p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Maintenance Windows</h1>
|
|
<p className="text-sm text-muted-foreground">Singapore DC01 — planned outages & alarm suppression</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={load} className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
<Button size="sm" onClick={() => { setShowForm(true); setStartDt(defaultStart()); setEndDt(defaultEnd()); }} className="flex items-center gap-1.5">
|
|
<Plus className="w-3.5 h-3.5" /> New Window
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary */}
|
|
{!loading && (
|
|
<div className="flex items-center gap-4 text-sm">
|
|
{active > 0 && (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
|
<span className="font-semibold text-green-400">{active}</span>
|
|
<span className="text-muted-foreground">active</span>
|
|
</span>
|
|
)}
|
|
{scheduled > 0 && (
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="w-2 h-2 rounded-full bg-blue-500" />
|
|
<span className="font-semibold">{scheduled}</span>
|
|
<span className="text-muted-foreground">scheduled</span>
|
|
</span>
|
|
)}
|
|
{active === 0 && scheduled === 0 && (
|
|
<span className="text-muted-foreground text-xs">No active or scheduled maintenance</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Create form */}
|
|
{showForm && (
|
|
<Card className="border-primary/30">
|
|
<CardHeader className="pb-3">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
<CalendarClock className="w-4 h-4 text-primary" /> New Maintenance Window
|
|
</CardTitle>
|
|
<button onClick={() => setShowForm(false)} className="text-muted-foreground hover:text-foreground transition-colors">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleCreate} className="space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="sm:col-span-2 space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">Title *</label>
|
|
<input
|
|
required
|
|
value={title}
|
|
onChange={e => setTitle(e.target.value)}
|
|
placeholder="e.g. UPS-01 firmware update"
|
|
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">Target</label>
|
|
<select
|
|
value={target}
|
|
onChange={e => setTarget(e.target.value)}
|
|
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
>
|
|
{TARGET_GROUPS.map(group => (
|
|
<optgroup key={group.label} label={group.label}>
|
|
{group.targets.map(t => (
|
|
<option key={t.value} value={t.value}>{t.label}</option>
|
|
))}
|
|
</optgroup>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1 flex items-end gap-3">
|
|
<label className="flex items-center gap-2 cursor-pointer text-sm mb-1">
|
|
<input
|
|
type="checkbox"
|
|
checked={suppress}
|
|
onChange={e => setSuppress(e.target.checked)}
|
|
className="rounded"
|
|
/>
|
|
<span className="text-xs font-medium">Suppress alarms</span>
|
|
</label>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">Start</label>
|
|
<input
|
|
type="datetime-local"
|
|
required
|
|
value={startDt}
|
|
onChange={e => setStartDt(e.target.value)}
|
|
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">End</label>
|
|
<input
|
|
type="datetime-local"
|
|
required
|
|
value={endDt}
|
|
onChange={e => setEndDt(e.target.value)}
|
|
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
|
|
/>
|
|
</div>
|
|
<div className="sm:col-span-2 space-y-1">
|
|
<label className="text-xs font-medium text-muted-foreground">Notes (optional)</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={e => setNotes(e.target.value)}
|
|
rows={2}
|
|
placeholder="Reason, affected systems, contacts…"
|
|
className="w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-none"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center justify-end gap-2">
|
|
<Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
|
|
<Button type="submit" size="sm" disabled={submitting}>
|
|
{submitting ? "Creating…" : "Create Window"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Windows list */}
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
|
|
</div>
|
|
) : windows.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
|
|
<CalendarClock className="w-8 h-8 opacity-30" />
|
|
<p className="text-sm">No maintenance windows</p>
|
|
<p className="text-xs">Click "New Window" to schedule planned downtime</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* 7-day timeline strip */}
|
|
<TimelineStrip windows={windows} />
|
|
|
|
<div className="space-y-3">
|
|
{[...windows].sort((a, b) => {
|
|
const order = { active: 0, scheduled: 1, expired: 2 };
|
|
return (order[a.status] ?? 9) - (order[b.status] ?? 9) || a.start_dt.localeCompare(b.start_dt);
|
|
}).map(w => (
|
|
<Card key={w.id} className={cn(
|
|
"border",
|
|
w.status === "active" && "border-green-500/30",
|
|
w.status === "scheduled" && "border-blue-500/20",
|
|
w.status === "expired" && "opacity-60",
|
|
)}>
|
|
<CardContent className="p-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="space-y-1 min-w-0">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<span className="font-semibold text-sm truncate">{w.title}</span>
|
|
<StatusChip status={w.status} />
|
|
{w.suppress_alarms && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
|
|
<BellOff className="w-3 h-3" /> Alarms suppressed
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
|
|
<span>Target: <strong className="text-foreground">{w.target_label}</strong></span>
|
|
<span>{formatDt(w.start_dt)} → {formatDt(w.end_dt)}</span>
|
|
</div>
|
|
{w.notes && (
|
|
<p className="text-xs text-muted-foreground mt-1">{w.notes}</p>
|
|
)}
|
|
</div>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-destructive shrink-0"
|
|
disabled={deleting === w.id}
|
|
onClick={() => handleDelete(w.id)}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|