98 lines
8.8 KiB
TypeScript
98 lines
8.8 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Activity, Cable, CirclePower, Cpu, Lightbulb, ShieldAlert, Sparkles, Wifi } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import { api, mutationId } from "../api/client";
|
|
import type { Device, Page, Scene, SystemStatus } from "../api/types";
|
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
|
import { useToast } from "../components/Toast";
|
|
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader, Stat, StatusBadge } from "../components/ui";
|
|
import { useI18n } from "../lib/i18n";
|
|
import { formatDate, hexToColor } from "../lib/utils";
|
|
|
|
export function OverviewPage() {
|
|
const { text } = useI18n();
|
|
const queryClient = useQueryClient();
|
|
const { notify } = useToast();
|
|
const [color, setColor] = useState("#5660ff");
|
|
const [confirmEmergency, setConfirmEmergency] = useState(false);
|
|
const system = useQuery({ queryKey: ["system"], queryFn: () => api<SystemStatus>("/api/v1/system") });
|
|
const devices = useQuery({ queryKey: ["devices", "online"], queryFn: () => api<Page<Device>>("/api/v1/devices?online=true&limit=500") });
|
|
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=6") });
|
|
const quickColor = useMutation({
|
|
mutationFn: async () => {
|
|
const targets = devices.data?.items.filter((device) => device.capabilities.rgb && !device.read_only && !device.blocked) ?? [];
|
|
return Promise.allSettled(targets.map((device) => api(`/api/v1/devices/${device.id}/state`, {
|
|
method: "POST",
|
|
headers: { "Idempotency-Key": mutationId() },
|
|
body: JSON.stringify({ state: { power: true, colors: [hexToColor(color)] } }),
|
|
})));
|
|
},
|
|
onSuccess: (results) => {
|
|
const failed = results.filter((result) => result.status === "rejected").length;
|
|
notify(failed ? `${results.length - failed} apparaten bijgewerkt; ${failed} mislukt.` : "Kleur toegepast op alle geschikte apparaten.", failed ? "danger" : "success");
|
|
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
|
},
|
|
});
|
|
const allOff = useMutation({
|
|
mutationFn: (emergency: boolean) => api(`/api/v1/commands/${emergency ? "emergency-stop" : "all-off"}`, { method: "POST" }),
|
|
onSuccess: (_, emergency) => { notify(emergency ? "Noodstop geactiveerd." : "Alles-uitopdracht voltooid."); void queryClient.invalidateQueries(); },
|
|
});
|
|
const applyScene = useMutation({
|
|
mutationFn: (id: string) => api(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
|
|
onSuccess: () => { notify("Scène toegepast."); void queryClient.invalidateQueries(); },
|
|
onError: (error) => notify(error instanceof Error ? error.message : "Scène mislukt.", "danger"),
|
|
});
|
|
|
|
if (system.isLoading) return <><PageHeader title={text("Overzicht", "Overview")} /><LoadingGrid /></>;
|
|
if (system.error || !system.data) return <ErrorPanel error={system.error} retry={() => void system.refetch()} />;
|
|
const status = system.data;
|
|
const openrgb = status.health.components["openrgb-local"] ?? status.health.components["openrgb-mock"];
|
|
|
|
return (
|
|
<>
|
|
<PageHeader eyebrow={text("Lokaal controlecentrum", "Local control center")} title={text("Goedenavond, Jens", "Good evening, Jens")} description={text("Je verlichting en OpenRGB-hardware in één rustig overzicht.", "Your lighting and OpenRGB hardware in one calm overview.")} actions={<><Button variant="secondary" onClick={() => allOff.mutate(false)} busy={allOff.isPending}><CirclePower size={16} /> {text("Alles uit", "All off")}</Button><Button variant="danger" onClick={() => setConfirmEmergency(true)}><ShieldAlert size={16} /> {text("Noodstop", "Emergency stop")}</Button></>} />
|
|
{status.emergency_stop ? <div className="alert alert--danger"><ShieldAlert size={20} /><div><strong>Globale noodstop actief</strong><p>Nieuwe hardwareopdrachten worden geblokkeerd totdat je de noodstop in Diagnostiek vrijgeeft.</p></div></div> : null}
|
|
<section className="grid grid--stats" aria-label="Systeemstatistieken">
|
|
<Stat label="Systeemstatus" value={<StatusBadge status={status.health.status} />} detail={`Gecontroleerd ${formatDate(status.health.checked_at)}`} icon={Activity} />
|
|
<Stat label="Apparaten online" value={`${status.devices.online} / ${status.devices.total}`} detail={`${status.devices.offline} offline`} icon={Cpu} />
|
|
<Stat label="OpenRGB Core" value={openrgb ? <StatusBadge status={openrgb.status} /> : "—"} detail={`1.0rc3 · SDK ${status.sdk_protocol}`} icon={Lightbulb} />
|
|
<Stat label="Connectoren" value={Object.keys(status.health.components).filter((key) => key.startsWith("openrgb")).length} detail="Actieve beheerkanalen" icon={Cable} />
|
|
</section>
|
|
<section className="dashboard-grid">
|
|
<Card className="quick-control">
|
|
<CardHeader title="Snelle bediening" description="Veilige statische kleur op alle geschikte apparaten." />
|
|
<div className="color-control">
|
|
<input aria-label="Snelle kleur" type="color" value={color} onChange={(event) => setColor(event.target.value)} />
|
|
<div><strong>{color.toUpperCase()}</strong><span>{devices.data?.items.length ?? 0} online doelen</span></div>
|
|
<Button onClick={() => quickColor.mutate()} busy={quickColor.isPending}><Sparkles size={16} /> Kleur toepassen</Button>
|
|
</div>
|
|
<div className="color-swatches" aria-label="Kleursuggesties">
|
|
{["#5660ff", "#00c2a8", "#ff7a59", "#f5c451", "#f7f7ff"].map((item) => <button key={item} aria-label={`Kies ${item}`} className={color === item ? "selected" : ""} style={{ background: item }} onClick={() => setColor(item)} />)}
|
|
</div>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader title="Actieve scène" description="De laatst succesvol toegepaste LumaOps-scène." />
|
|
{status.active_scene ? <div className="active-scene"><span className="scene-orb" /><div><strong>{status.active_scene.name}</strong><span>{formatDate(status.active_scene.last_applied_at)}</span></div><Badge tone="accent">Actief</Badge></div> : <p className="muted">Nog geen scène toegepast.</p>}
|
|
</Card>
|
|
</section>
|
|
<section className="section-block">
|
|
<div className="section-heading"><div><h2>Laatst gebruikte scènes</h2><p>Start een sfeer zonder de pagina te verlaten.</p></div><Link className="text-link" to="/scenes">Alle scènes</Link></div>
|
|
<div className="grid grid--cards">
|
|
{scenes.data?.items.map((scene, index) => <Card key={scene.id} className="scene-card"><div className={`scene-gradient scene-gradient--${index % 4}`}><Sparkles size={20} /></div><div className="scene-card__body"><div><h3>{scene.name}</h3><p>{scene.item_count ?? 0} onderdelen · v{scene.version}</p></div><Button variant="secondary" onClick={() => applyScene.mutate(scene.id)} busy={applyScene.isPending}>Start</Button></div></Card>)}
|
|
{!scenes.isLoading && !scenes.data?.items.length ? <Card><p className="muted">Leg je huidige toestand vast als eerste scène.</p></Card> : null}
|
|
</div>
|
|
</section>
|
|
<section className="dashboard-grid">
|
|
<Card><CardHeader title="Recente opdrachten" description="Laatste wijzigingen via LumaOps." action={<Link to="/activity" className="text-link">Alles bekijken</Link>} />
|
|
<div className="timeline">{status.recent_commands.slice(0, 5).map((command) => <div className="timeline__item" key={command.id}><span className={`timeline__dot timeline__dot--${command.status}`} /><div><strong>{command.action === "set_state" ? "Apparaattoestand gewijzigd" : command.action}</strong><span>{formatDate(command.created_at)}</span></div><Badge tone={command.status === "succeeded" ? "success" : command.status === "failed" ? "danger" : "neutral"}>{command.status}</Badge></div>)}{!status.recent_commands.length ? <p className="muted">Nog geen opdrachten uitgevoerd.</p> : null}</div>
|
|
</Card>
|
|
<Card><CardHeader title="Waarschuwingen" description="Aandachtspunten met concrete herstelacties." action={<Wifi size={18} />} />
|
|
{status.warnings.length ? status.warnings.map((warning) => <div className="warning-row" key={warning.id}><span /><div><strong>{warning.title}</strong><p>{warning.message}</p></div></div>) : <div className="all-clear"><span><Lightbulb size={20} /></span><div><strong>Alles rustig</strong><p>Er zijn geen open waarschuwingen.</p></div></div>}
|
|
</Card>
|
|
</section>
|
|
<ConfirmDialog open={confirmEmergency} title="Globale noodstop activeren?" description="LumaOps probeert alle geschikte apparaten één keer uit te schakelen en blokkeert daarna nieuwe hardwareopdrachten." confirmLabel="Noodstop activeren" danger onClose={() => setConfirmEmergency(false)} onConfirm={() => { setConfirmEmergency(false); allOff.mutate(true); }} />
|
|
</>
|
|
);
|
|
}
|