Files
LumaOps-Public/lumaops/frontend/src/pages/SetupPage.tsx
T
2026-09-30 00:26:45 +00:00

84 lines
12 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ChevronRight, CircleCheck, Cpu, Database, HardDrive, Lightbulb, Network, ScanSearch, Server, ShieldCheck, Usb, Wrench } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { api } from "../api/client";
import type { ComponentHealth, SetupState } from "../api/types";
import { useToast } from "../components/Toast";
import { Badge, Button, EmptyState, ErrorPanel, StatusBadge } from "../components/ui";
interface PathStatus { path: string; exists: boolean; readable: boolean; writable: boolean }
interface NodeStatus { path: string; readable: boolean; writable: boolean }
interface Recovery { area: string; message: string }
interface SetupReport {
storage: PathStatus[];
openrgb: ComponentHealth | null;
usb_devices: NodeStatus[];
i2c_devices: NodeStatus[];
serial_devices: NodeStatus[];
recovery: Recovery[];
checked_at: string;
openrgb_inventory?: Record<string, unknown>;
}
const steps = [
["Welkom", Lightbulb], ["Opslagrechten", HardDrive], ["OpenRGB-proces", Server], ["SDK-verbinding", Network],
["USB-inventory", Usb], ["I²C-inventory", Cpu], ["OpenRGB-detectie", ScanSearch], ["Toegankelijkheid", ShieldCheck],
["Netwerkdiscovery", Network], ["Appdata & back-up", Database], ["Systeemrapport", CircleCheck],
] as const;
export function SetupPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const { notify } = useToast();
const [active, setActive] = useState(0);
const [networkDiscovery, setNetworkDiscovery] = useState(false);
const [appdata, setAppdata] = useState(false);
const [backup, setBackup] = useState(false);
const state = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup") });
const inspect = useMutation({ mutationFn: () => api<SetupReport>(`/api/v1/setup/inspect?include_network=${networkDiscovery}`, { method: "POST" }), onSuccess: () => { notify("Systeemcontrole afgerond."); setActive(10); }, onError: (error) => notify(error instanceof Error ? error.message : "Controle mislukt.", "danger") });
const complete = useMutation({ mutationFn: () => api<SetupState>("/api/v1/setup/complete", { method: "POST", body: JSON.stringify({ appdata_confirmed: appdata, backup_location_confirmed: backup }) }), onSuccess: (result) => { queryClient.setQueryData(["setup"], result); notify("LumaOps is klaar voor gebruik."); void navigate("/", { replace: true }); }, onError: (error) => notify(error instanceof Error ? error.message : "Setup afronden mislukt.", "danger") });
const report = normalizeSetupReport(inspect.data ?? state.data?.report);
if (state.error) return <ErrorPanel error={state.error} retry={() => void state.refetch()} />;
return <div className="setup-shell">
<aside className="setup-sidebar"><div className="brand"><span className="brand__mark"><Lightbulb size={20} /></span><div className="brand__text"><strong>LumaOps</strong><span>Eerste configuratie</span></div></div><ol>{steps.map(([label, Icon], index) => <li key={label} className={active === index ? "active" : index < active ? "complete" : ""}><button onClick={() => setActive(index)}><span>{index < active ? <Check size={15} /> : <Icon size={16} />}</span><div><small>Stap {index + 1}</small><strong>{label}</strong></div></button></li>)}</ol></aside>
<main className="setup-main">
<div className="setup-progress"><span>Stap {active + 1} van {steps.length}</span><div><i style={{ width: `${((active + 1) / steps.length) * 100}%` }} /></div></div>
{active === 0 ? <Welcome onNext={() => setActive(1)} /> : null}
{active >= 1 && active <= 8 ? <CheckStep index={active} report={report} networkDiscovery={networkDiscovery} setNetworkDiscovery={setNetworkDiscovery} busy={inspect.isPending} onInspect={() => inspect.mutate()} onPrevious={() => setActive((value) => Math.max(0, value - 1))} onNext={() => setActive((value) => Math.min(10, value + 1))} /> : null}
{active === 9 ? <StorageConfirm appdata={appdata} backup={backup} onAppdata={setAppdata} onBackup={setBackup} onPrevious={() => setActive(8)} onNext={() => setActive(10)} /> : null}
{active === 10 ? <Report report={report} appdata={appdata} backup={backup} busy={complete.isPending} onInspect={() => inspect.mutate()} onComplete={() => complete.mutate()} /> : null}
</main>
</div>;
}
function normalizeSetupReport(value: unknown): SetupReport | undefined {
if (!value || typeof value !== "object") return undefined;
const candidate = value as Partial<SetupReport>;
return {
storage: Array.isArray(candidate.storage) ? candidate.storage : [],
openrgb: candidate.openrgb ?? null,
usb_devices: Array.isArray(candidate.usb_devices) ? candidate.usb_devices : [],
i2c_devices: Array.isArray(candidate.i2c_devices) ? candidate.i2c_devices : [],
serial_devices: Array.isArray(candidate.serial_devices) ? candidate.serial_devices : [],
recovery: Array.isArray(candidate.recovery) ? candidate.recovery : [],
checked_at: typeof candidate.checked_at === "string" ? candidate.checked_at : "",
openrgb_inventory: candidate.openrgb_inventory,
};
}
function Welcome({ onNext }: { onNext: () => void }) { return <div className="setup-panel setup-welcome"><span className="setup-hero-icon"><Lightbulb size={34} /></span><Badge tone="accent">OpenRGB 1.0rc3 · SDK 5</Badge><h1>Welkom bij LumaOps</h1><p>We controleren eerst veilig je container, opslag en OpenRGB-verbinding. De wizard voert geen wijzigende hardwareopdrachten uit.</p><div className="setup-promises"><div><ShieldCheck size={19} /><span><strong>Loopback-only SDK</strong>Poort 6742 blijft intern.</span></div><div><Wrench size={19} /><span><strong>Concrete herstelstappen</strong>Geen vage foutmeldingen.</span></div><div><Database size={19} /><span><strong>Persistente appdata</strong>Back-up vóór risicovolle wijzigingen.</span></div></div><Button onClick={onNext}>Configuratie starten <ChevronRight size={16} /></Button></div>; }
function CheckStep({ index, report, networkDiscovery, setNetworkDiscovery, busy, onInspect, onPrevious, onNext }: { index: number; report?: SetupReport; networkDiscovery: boolean; setNetworkDiscovery: (value: boolean) => void; busy: boolean; onInspect: () => void; onPrevious: () => void; onNext: () => void }) {
const [title, Icon] = steps[index]!;
const contents = index === 1 ? <StatusList items={report?.storage.map((item) => ({ name: item.path, ok: item.writable, detail: item.writable ? "Schrijfbaar" : "Niet schrijfbaar" })) ?? []} empty="Voer de systeemcontrole uit om mountrechten te testen." /> : index === 2 || index === 3 ? report?.openrgb ? <div className="setup-health"><StatusBadge status={report.openrgb.status} /><strong>{report.openrgb.message}</strong><p>De SDK moet exact protocol 5 rapporteren en op loopback bereikbaar zijn.</p></div> : <EmptyState icon={Server} title="Nog niet gecontroleerd" description="Start de veilige systeemcontrole." /> : index === 4 ? <NodeList title="USB-nodes" nodes={report?.usb_devices ?? []} /> : index === 5 ? <NodeList title="I²C-nodes" nodes={report?.i2c_devices ?? []} /> : index === 6 ? <StatusList items={report?.openrgb_inventory ? Object.entries(report.openrgb_inventory).map(([name, value]) => ({ name, ok: (value as { status?: string }).status === "completed", detail: JSON.stringify(value) })) : []} empty="Na de SDK-test wordt de OpenRGB-inventory read-only gesynchroniseerd." /> : index === 7 ? <div>{report?.recovery.length ? report.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>) : <EmptyState icon={ShieldCheck} title="Geen herstelacties nodig" description="Alle uitgevoerde controles zijn geslaagd." />}</div> : <label className="choice-card"><input type="checkbox" checked={networkDiscovery} onChange={(event) => setNetworkDiscovery(event.target.checked)} /><span><Network size={20} /></span><div><strong>Optionele netwerkdiscovery</strong><p>Broadcast, multicast en mDNS kunnen host networking vereisen. Bridge blijft de veilige standaard.</p></div></label>;
return <div className="setup-panel"><span className="eyebrow">Stap {index + 1}</span><div className="setup-title"><span><Icon size={24} /></span><div><h1>{title}</h1><p>Deze controle is read-only en verandert geen apparaattoestand.</p></div></div><div className="setup-content">{contents}</div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button variant="secondary" onClick={onInspect} busy={busy}><ScanSearch size={16} /> Systeemcontrole</Button><Button onClick={onNext}>Volgende <ChevronRight size={16} /></Button></div></div>;
}
function StorageConfirm({ appdata, backup, onAppdata, onBackup, onPrevious, onNext }: { appdata: boolean; backup: boolean; onAppdata: (value: boolean) => void; onBackup: (value: boolean) => void; onPrevious: () => void; onNext: () => void }) { return <div className="setup-panel"><span className="eyebrow">Stap 10</span><div className="setup-title"><span><Database size={24} /></span><div><h1>Appdata & back-up</h1><p>Bevestig dat de persistentie buiten de container is gekoppeld.</p></div></div><div className="setup-content"><label className="choice-card"><input type="checkbox" checked={appdata} onChange={(event) => onAppdata(event.target.checked)} /><span><HardDrive size={20} /></span><div><strong>Appdata is persistent</strong><p>/config/openrgb, /config/lumaops, /data en /logs zijn aan Unraid gekoppeld.</p></div></label><label className="choice-card"><input type="checkbox" checked={backup} onChange={(event) => onBackup(event.target.checked)} /><span><Database size={20} /></span><div><strong>Back-uplocatie is bevestigd</strong><p>Database, OpenRGB-config en secret.key worden samen extern geback-upt.</p></div></label></div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button onClick={onNext} disabled={!appdata || !backup}>Rapport bekijken <ChevronRight size={16} /></Button></div></div>; }
function Report({ report, appdata, backup, busy, onInspect, onComplete }: { report?: SetupReport; appdata: boolean; backup: boolean; busy: boolean; onInspect: () => void; onComplete: () => void }) { const ready = Boolean(report?.openrgb?.connected && report.storage.every((item) => item.writable) && appdata && backup); return <div className="setup-panel"><span className="setup-hero-icon"><CircleCheck size={34} /></span><Badge tone={ready ? "success" : "warning"}>{ready ? "Klaar voor gebruik" : "Aandacht vereist"}</Badge><h1>Systeemstatusrapport</h1><p>{ready ? "De container, opslag en SDK-verbinding voldoen aan de veilige MVP-voorwaarden." : "LumaOps kan starten, maar los onderstaande herstelpunten op voor volledige hardwarebediening."}</p><div className="report-summary"><div><strong>{report?.storage.filter((item) => item.writable).length ?? 0}/{report?.storage.length ?? 4}</strong><span>opslagpaden</span></div><div><strong>{report?.usb_devices.length ?? 0}</strong><span>USB-nodes</span></div><div><strong>{report?.i2c_devices.length ?? 0}</strong><span>I²C-nodes</span></div><div><strong>{report?.openrgb?.connected ? "SDK 5" : "Offline"}</strong><span>OpenRGB</span></div></div>{report?.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>)}<div className="setup-actions"><Button variant="secondary" onClick={onInspect}>Opnieuw controleren</Button><Button onClick={onComplete} busy={busy} disabled={!appdata || !backup}>LumaOps openen <ChevronRight size={16} /></Button></div></div>; }
function StatusList({ items, empty }: { items: Array<{ name: string; ok: boolean; detail: string }>; empty: string }) { if (!items.length) return <p className="muted">{empty}</p>; return <div className="status-list">{items.map((item) => <div key={item.name}><span className={item.ok ? "ok" : "bad"}>{item.ok ? <Check size={14} /> : "!"}</span><div><strong>{item.name}</strong><p>{item.detail}</p></div></div>)}</div>; }
function NodeList({ title, nodes }: { title: string; nodes: NodeStatus[] }) { return nodes.length ? <div><h3>{title}</h3><StatusList items={nodes.map((node) => ({ name: node.path, ok: node.readable && node.writable, detail: node.writable ? "Lees- en schrijfbaar" : node.readable ? "Alleen leesbaar" : "Niet toegankelijk" }))} empty="" /></div> : <EmptyState icon={Usb} title={`Geen ${title.toLowerCase()}`} description="Dit is normaal als de betreffende hardware niet is doorgemapt." />; }