first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { fetchFireStatus, type FireZoneStatus } from "@/lib/api";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Flame, RefreshCw, CheckCircle2, AlertTriangle, Zap, Wind, Activity } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
const LEVEL_CONFIG: Record<string, {
|
||||
label: string; bg: string; border: string; text: string; icon: React.ElementType; pulsing: boolean;
|
||||
}> = {
|
||||
normal: {
|
||||
label: "Normal",
|
||||
bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-400",
|
||||
icon: CheckCircle2, pulsing: false,
|
||||
},
|
||||
alert: {
|
||||
label: "Alert",
|
||||
bg: "bg-amber-500/10", border: "border-amber-500/40", text: "text-amber-400",
|
||||
icon: AlertTriangle, pulsing: false,
|
||||
},
|
||||
action: {
|
||||
label: "Action",
|
||||
bg: "bg-orange-500/10", border: "border-orange-500/40", text: "text-orange-400",
|
||||
icon: AlertTriangle, pulsing: true,
|
||||
},
|
||||
fire: {
|
||||
label: "FIRE",
|
||||
bg: "bg-destructive/10", border: "border-destructive/60", text: "text-destructive",
|
||||
icon: Flame, pulsing: true,
|
||||
},
|
||||
};
|
||||
|
||||
function ObscurationBar({ value }: { value: number | null }) {
|
||||
if (value == null) return null;
|
||||
const pct = Math.min(100, value * 20); // 0–5 %/m mapped to 0–100%
|
||||
const color = value > 3 ? "#ef4444" : value > 1.5 ? "#f59e0b" : "#94a3b8";
|
||||
return (
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Obscuration</span>
|
||||
<span className="font-mono font-semibold text-xs" style={{ color }}>{value.toFixed(2)} %/m</span>
|
||||
</div>
|
||||
<div className="rounded-full bg-muted overflow-hidden h-1.5">
|
||||
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIndicator({ label, ok, icon: Icon }: {
|
||||
label: string; ok: boolean; icon: React.ElementType;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"rounded-lg px-2.5 py-2 flex items-center gap-2 text-xs",
|
||||
ok ? "bg-green-500/10" : "bg-destructive/10",
|
||||
)}>
|
||||
<Icon className={cn("w-3.5 h-3.5 shrink-0", ok ? "text-green-400" : "text-destructive")} />
|
||||
<div>
|
||||
<p className="text-muted-foreground text-[10px]">{label}</p>
|
||||
<p className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "OK" : "Fault"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VesdaCard({ zone }: { zone: FireZoneStatus }) {
|
||||
const level = zone.level;
|
||||
const cfg = LEVEL_CONFIG[level] ?? LEVEL_CONFIG.normal;
|
||||
const Icon = cfg.icon;
|
||||
const isAlarm = level !== "normal";
|
||||
|
||||
return (
|
||||
<Card className={cn(isAlarm ? "border-2" : "border", cfg.border, isAlarm && cfg.bg, level === "fire" && "bg-red-950/30")}>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<Icon className={cn("w-4 h-4", cfg.text, cfg.pulsing && "animate-pulse")} />
|
||||
{zone.zone_id.toUpperCase()}
|
||||
</CardTitle>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wide border",
|
||||
cfg.bg, cfg.border, cfg.text,
|
||||
)}>
|
||||
<Icon className={cn("w-3 h-3", cfg.pulsing && "animate-pulse")} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{level === "fire" && (
|
||||
<div className="rounded-lg border border-destructive/60 bg-destructive/15 px-3 py-3 text-xs text-destructive font-semibold animate-pulse">
|
||||
FIRE ALARM — Initiate evacuation and contact emergency services immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "action" && (
|
||||
<div className="rounded-lg border border-orange-500/40 bg-orange-500/10 px-3 py-2.5 text-xs text-orange-400 font-medium">
|
||||
Action threshold reached — investigate smoke source immediately
|
||||
</div>
|
||||
)}
|
||||
{level === "alert" && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-400">
|
||||
Alert level — elevated smoke particles detected, monitor closely
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ObscurationBar value={zone.obscuration_pct_m} />
|
||||
|
||||
{/* Detector status */}
|
||||
<div className="space-y-1.5">
|
||||
{[
|
||||
{ label: "Detector 1", ok: zone.detector_1_ok },
|
||||
{ label: "Detector 2", ok: zone.detector_2_ok },
|
||||
].map(({ label, ok }) => (
|
||||
<div key={label} className="flex items-center justify-between text-xs">
|
||||
<span className="flex items-center gap-1.5 text-muted-foreground">
|
||||
{ok ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <AlertTriangle className="w-3.5 h-3.5 text-destructive" />}
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
|
||||
{ok ? "Online" : "Fault"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* System status */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatusIndicator label="Power supply" ok={zone.power_ok} icon={Zap} />
|
||||
<StatusIndicator label="Airflow" ok={zone.flow_ok} icon={Wind} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FireSafetyPage() {
|
||||
const [zones, setZones] = useState<FireZoneStatus[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setZones(await fetchFireStatus(SITE_ID));
|
||||
} catch { toast.error("Failed to load fire safety data"); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const id = setInterval(load, 10_000);
|
||||
return () => clearInterval(id);
|
||||
}, [load]);
|
||||
|
||||
const fireZones = zones.filter((z) => z.level === "fire");
|
||||
const actionZones = zones.filter((z) => z.level === "action");
|
||||
const alertZones = zones.filter((z) => z.level === "alert");
|
||||
const normalZones = zones.filter((z) => z.level === "normal");
|
||||
const anyAlarm = fireZones.length + actionZones.length + alertZones.length > 0;
|
||||
|
||||
const worstLevel =
|
||||
fireZones.length > 0 ? "fire" :
|
||||
actionZones.length > 0 ? "action" :
|
||||
alertZones.length > 0 ? "alert" : "normal";
|
||||
const worstCfg = LEVEL_CONFIG[worstLevel];
|
||||
const WIcon = worstCfg.icon;
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Fire & Life Safety</h1>
|
||||
<p className="text-sm text-muted-foreground">Singapore DC01 — VESDA aspirating detector network · refreshes every 10s</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{!loading && (
|
||||
<span className={cn(
|
||||
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full border",
|
||||
worstCfg.bg, worstCfg.border, worstCfg.text,
|
||||
anyAlarm && "animate-pulse",
|
||||
)}>
|
||||
<WIcon className="w-3.5 h-3.5" />
|
||||
{anyAlarm
|
||||
? `${fireZones.length + actionZones.length + alertZones.length} zone${fireZones.length + actionZones.length + alertZones.length > 1 ? "s" : ""} in alarm`
|
||||
: `All ${zones.length} zones normal`}
|
||||
</span>
|
||||
)}
|
||||
<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" /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System summary bar */}
|
||||
{!loading && (
|
||||
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-primary" />
|
||||
<span className="text-muted-foreground">VESDA zones monitored:</span>
|
||||
<strong>{zones.length}</strong>
|
||||
</div>
|
||||
{[
|
||||
{ label: "Fire", count: fireZones.length, cls: "text-destructive" },
|
||||
{ label: "Action", count: actionZones.length, cls: "text-orange-400" },
|
||||
{ label: "Alert", count: alertZones.length, cls: "text-amber-400" },
|
||||
{ label: "Normal", count: normalZones.length, cls: "text-green-400" },
|
||||
].map(({ label, count, cls }) => (
|
||||
<div key={label} className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">{label}:</span>
|
||||
<strong className={cls}>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
<div className="ml-auto text-xs text-muted-foreground">
|
||||
All detectors use VESDA aspirating smoke detection technology
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fire alarm banner */}
|
||||
{!loading && fireZones.length > 0 && (
|
||||
<div className="rounded-xl border-2 border-destructive bg-destructive/10 px-5 py-4 animate-pulse">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Flame className="w-6 h-6 text-destructive shrink-0" />
|
||||
<p className="text-base font-bold text-destructive">
|
||||
FIRE ALARM ACTIVE — {fireZones.length} zone{fireZones.length > 1 ? "s" : ""}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-destructive/80">
|
||||
Initiate building evacuation. Contact SCDF (995). Do not re-enter until cleared by fire services.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zone cards — alarms first */}
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
|
||||
</div>
|
||||
) : zones.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No VESDA zone data available</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{[...fireZones, ...actionZones, ...alertZones, ...normalZones].map((zone) => (
|
||||
<VesdaCard key={zone.zone_id} zone={zone} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Legend */}
|
||||
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4">
|
||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-3">VESDA Alert Levels</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
|
||||
{Object.entries(LEVEL_CONFIG).map(([key, cfg]) => {
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<div key={key} className={cn("rounded-lg border px-3 py-2.5", cfg.bg, cfg.border)}>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Icon className={cn("w-3.5 h-3.5", cfg.text)} />
|
||||
<span className={cn("font-bold uppercase", cfg.text)}>{cfg.label}</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
{key === "normal" ? "No smoke detected, system clear" :
|
||||
key === "alert" ? "Trace smoke particles, monitor" :
|
||||
key === "action" ? "Significant smoke, investigate now" :
|
||||
"Confirmed fire, evacuate immediately"}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue