first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

@ -0,0 +1,281 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { FlaskConical, Play, RotateCcw, ChevronDown, Clock, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { fetchScenarios, triggerScenario, type ScenarioInfo } from "@/lib/api";
// ── Small select for target override ─────────────────────────────────────────
function TargetSelect({
targets,
value,
onChange,
}: {
targets: string[];
value: string;
onChange: (v: string) => void;
}) {
if (targets.length <= 1) return null;
return (
<div className="relative inline-flex items-center">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="appearance-none text-xs bg-muted border border-border rounded px-2 py-1 pr-6 text-foreground cursor-pointer focus:outline-none focus:ring-1 focus:ring-ring"
>
{targets.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
</div>
);
}
// ── Single scenario card ──────────────────────────────────────────────────────
function ScenarioCard({
scenario,
active,
onRun,
}: {
scenario: ScenarioInfo;
active: boolean;
onRun: (name: string, target?: string) => void;
}) {
const [target, setTarget] = useState(scenario.default_target ?? "");
return (
<div
className={`rounded-lg border px-4 py-3 flex flex-col gap-2 transition-colors ${
active
? "border-amber-500/60 bg-amber-500/5"
: "border-border bg-card"
}`}
>
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground leading-tight">
{scenario.label}
</span>
{scenario.compound && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
compound
</Badge>
)}
{active && (
<Badge className="text-[10px] px-1.5 py-0 bg-amber-500 text-white animate-pulse">
running
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!scenario.compound && (
<TargetSelect
targets={scenario.targets}
value={target}
onChange={setTarget}
/>
)}
<Button
size="sm"
variant={active ? "secondary" : "default"}
className="h-7 px-2.5 text-xs gap-1"
onClick={() => onRun(scenario.name, scenario.compound ? undefined : target || undefined)}
>
<Play className="w-3 h-3" />
Run
</Button>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{scenario.description}
</p>
{/* Footer */}
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="w-3 h-3" />
{scenario.duration}
</div>
</div>
);
}
// ── Main panel ────────────────────────────────────────────────────────────────
export function SimulatorPanel() {
const [open, setOpen] = useState(false);
const [scenarios, setScenarios] = useState<ScenarioInfo[]>([]);
const [loading, setLoading] = useState(false);
const [activeScenario, setActiveScenario] = useState<string | null>(null);
const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null);
// Load scenario list once when panel first opens
useEffect(() => {
if (!open || scenarios.length > 0) return;
setLoading(true);
fetchScenarios()
.then(setScenarios)
.catch(() => setStatus({ message: "Failed to load scenarios", ok: false }))
.finally(() => setLoading(false));
}, [open, scenarios.length]);
const showStatus = useCallback((message: string, ok: boolean) => {
setStatus({ message, ok });
setTimeout(() => setStatus(null), 3000);
}, []);
const handleRun = useCallback(
async (name: string, target?: string) => {
try {
await triggerScenario(name, target);
setActiveScenario(name);
showStatus(
`${name}${target ? `${target}` : ""} triggered`,
true
);
} catch {
showStatus("Failed to trigger scenario", false);
}
},
[showStatus]
);
const handleReset = useCallback(async () => {
try {
await triggerScenario("RESET");
setActiveScenario(null);
showStatus("All scenarios reset", true);
} catch {
showStatus("Failed to reset", false);
}
}, [showStatus]);
const compound = scenarios.filter((s) => s.compound);
const single = scenarios.filter((s) => !s.compound);
return (
<Sheet open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Scenario simulator"
>
<FlaskConical className="w-4 h-4" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Scenario Simulator</TooltipContent>
</Tooltip>
<SheetContent
side="right"
className="w-[420px] sm:w-[480px] flex flex-col gap-0 p-0"
>
{/* Header */}
<SheetHeader className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-muted-foreground" />
<SheetTitle className="text-base">Scenario Simulator</SheetTitle>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
demo only
</Badge>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
Inject realistic fault scenarios into the live simulator. Changes are reflected immediately across all dashboard pages.
</p>
</SheetHeader>
{/* Reset bar */}
<div className="px-5 py-3 border-b border-border shrink-0 flex items-center justify-between gap-3">
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3.5 h-3.5" />
Reset All
</Button>
{status && (
<span
className={`text-xs transition-opacity ${
status.ok ? "text-emerald-500" : "text-destructive"
}`}
>
{status.message}
</span>
)}
</div>
{/* Scrollable scenario list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{loading && (
<p className="text-xs text-muted-foreground text-center py-8">
Loading scenarios
</p>
)}
{!loading && compound.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Compound Scenarios
</h3>
</div>
<p className="text-[11px] text-muted-foreground -mt-1">
Multi-device, time-sequenced chains fires automatically across the site.
</p>
<div className="space-y-2">
{compound.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
{!loading && single.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Play className="w-3.5 h-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Single Fault Scenarios
</h3>
</div>
<div className="space-y-2">
{single.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
</div>
</SheetContent>
</Sheet>
);
}