Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e

This commit is contained in:
Public Source Publisher committed 2026-10-01 19:39:31 +00:00
1 parent c61fbfd87f
commit 1a54836efc
101 files changed
+4973 -1298

No files matched your search

+22 -8
View File
@@ -9,7 +9,9 @@ import { getSetupBannerKind, type SetupBannerKind } from "@/lib/setupBannerView"
import { getRouteContentWidthClass } from "@/lib/routeContentWidth";
import { OperatorReadOnlyBanner } from "./OperatorReadOnlyBanner";
import { getRouteDocumentTitle } from "@/lib/routeTitle";
import { setOperatorSessionStatus } from "@/api/operatorSession";
import { setUserSessionStatus } from "@/api/userSession";
import { useUserSession } from "@/hooks/useUserSession";
import { SignInPanel } from "./SignInPanel";
interface AppShellProps {
children: ReactNode;
@@ -17,19 +19,29 @@ interface AppShellProps {
export const AppShell = ({ children }: AppShellProps) => {
const location = useLocation();
const session = useUserSession();
const [isMaintenance, setIsMaintenance] = useState(false);
const [showBanner, setShowBanner] = useState(true);
const [setupBannerKind, setSetupBannerKind] = useState<SetupBannerKind>("hidden");
const contentWidthClass = getRouteContentWidthClass(location.pathname);
useEffect(() => {
document.title = getRouteDocumentTitle(location.pathname);
}, [location.pathname]);
document.title = session.authenticated ? getRouteDocumentTitle(location.pathname) : 'Aanmelden · AppOps Gateway';
}, [location.pathname, session.authenticated]);
useEffect(() => {
appopsClient.getOperatorSession().then(session => {
setOperatorSessionStatus(session);
}).catch(() => setOperatorSessionStatus({ authorized: false, mode: "locked", identity: null }));
let active = true;
const refresh = () => appopsClient.getUserSession().then(status => {
if (active) setUserSessionStatus(status);
}).catch(() => { if (active) setUserSessionStatus({ authenticated: false, user: null, permissions: [] }); });
void refresh();
const interval = window.setInterval(() => void refresh(), 60000);
window.addEventListener('focus', refresh);
return () => { active = false; window.clearInterval(interval); window.removeEventListener('focus', refresh); };
}, []);
useEffect(() => {
if (!session.authenticated) return;
appopsClient.getSystemSummary().then(s => {
setIsMaintenance(s.maintenanceMode);
}).catch(() => setIsMaintenance(false));
@@ -38,7 +50,7 @@ export const AppShell = ({ children }: AppShellProps) => {
}).catch(() => {
setSetupBannerKind("hidden");
});
}, []);
}, [session.authenticated]);
const setupIncomplete = setupBannerKind === "setup_incomplete";
const coveragePartial = setupBannerKind === "coverage_partial";
@@ -46,6 +58,8 @@ export const AppShell = ({ children }: AppShellProps) => {
? "overview"
: location.pathname.split("/").filter(Boolean).join("-");
if (!session.authenticated) return <SignInPanel checkingSession={!session.ready} />;
return (
<div className="app-shell flex min-h-screen text-slate-200">
<a href="#main-content" className="app-skip-link">Ga naar hoofdinhoud</a>
@@ -122,7 +136,7 @@ export const AppShell = ({ children }: AppShellProps) => {
<main id="main-content" tabIndex={-1} className="flex-1 overflow-y-auto px-4 py-5 md:px-6 md:py-6 2xl:px-8 2xl:py-7">
<div className={`mx-auto w-full ${contentWidthClass}`}>
<div className="app-route" data-route={routeName}>
{children}
{children}
</div>
</div>
</main>
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from "react";
import { appopsClient } from "@/api/appopsClient";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { formatOperatorError } from "@/lib/operatorError";
import { useUserSession } from "@/hooks/useUserSession";
import type { ExposurePreferences } from "@/types/appops";
export function ExposureSettings() {
const session = useUserSession();
const [preferences, setPreferences] = useState<ExposurePreferences | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const load = () => {
setError("");
void appopsClient.getExposurePreferences().then(setPreferences).catch(err => setError(formatOperatorError(err, "Exposure-instellingen laden mislukt.")));
};
useEffect(load, []);
const save = async (enabled: boolean) => {
setBusy(true);
setError("");
setNotice("");
try {
setPreferences(await appopsClient.updateExposurePreferences({ nginxAccessListChecksEnabled: enabled }));
setNotice("Opgeslagen. Exposure Center en Safety Center gebruiken deze instelling bij hun volgende refresh.");
} catch (err) {
setError(formatOperatorError(err, "Exposure-instellingen opslaan mislukt."));
} finally {
setBusy(false);
}
};
return (
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-950 p-4">
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="nginx-access-list-checks" className="font-bold text-slate-200">Nginx access-list-controle</Label>
<p className="text-xs text-slate-400">Controleer of publieke proxyhosts een Nginx access list hebben. Schakel dit uit als je setup geen access lists gebruikt.</p>
</div>
<Switch id="nginx-access-list-checks" checked={preferences?.nginxAccessListChecksEnabled ?? false} disabled={!preferences || busy || session.user?.role !== "admin"} onCheckedChange={enabled => void save(enabled)} />
</div>
<p className="text-xs text-slate-500">Deze instelling beheert controles en weergave. Authentik, certificaten en risico’s van onbeschermde publieke hosts worden steeds gecontroleerd.</p>
{!preferences && !error && <p className="text-xs text-slate-400">Instelling laden…</p>}
{notice && <p role="status" className="text-xs text-emerald-300">{notice}</p>}
{error && <div role="alert" className="text-xs text-rose-300">{error} {!preferences && <Button variant="link" onClick={load}>Opnieuw proberen</Button>}</div>}
</div>
);
}
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
import { Loader2, Save, Send } from "lucide-react";
import type { NotificationChannel } from "@/types/appops";
import { appopsClient } from "@/api/appopsClient";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -59,7 +59,7 @@ const ExternalChannelCard = ({ type, channel, working, run, reload }: {
reload: () => Promise<void>;
}) => {
const definition = definitions[type];
const unlocked = useOperatorSession();
const unlocked = usePermission("notifications.manage");
const [form, setForm] = useState<FormState>(() => initialForm(channel));
useEffect(() => setForm(initialForm(channel)), [channel]);
const update = (key: string, value: string | boolean) => setForm(current => ({ ...current, [key]: value }));
+103
View File
@@ -0,0 +1,103 @@
import { useCallback, useEffect, useState } from "react";
import { appopsClient } from "@/api/appopsClient";
import { useUserSession } from "@/hooks/useUserSession";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { formatOperatorError } from "@/lib/operatorError";
import { ManagedAiStatus, ManagedAiInference } from "@/types/appops";
import { RefreshCw, Sparkles } from "lucide-react";
const stateLabels: Record<ManagedAiStatus['state'], string> = {
ready: "Capability beschikbaar",
not_configured: "AI niet ingeschakeld of geconfigureerd",
not_deployed: "Nog geen inzetbaar model in ModelForge",
unverified: "Credential geldig; modelgereedheid niet bevestigd",
unavailable: "ModelForge niet beschikbaar",
};
export function ManagedAiSettings() {
const session = useUserSession();
const admin = session.user?.role === "admin";
const [status, setStatus] = useState<ManagedAiStatus | null>(null);
const [gatewayUrl, setGatewayUrl] = useState("");
const [enabled, setEnabled] = useState(false);
const [credential, setCredential] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [notice, setNotice] = useState("");
const [test, setTest] = useState<ManagedAiInference | null>(null);
const load = useCallback(async () => {
setError("");
try {
const result = await appopsClient.getManagedAiStatus();
setStatus(result);
setGatewayUrl(result.gatewayUrl);
setEnabled(result.enabled);
} catch (err) {
setError(formatOperatorError(err, "AI-configuratie laden mislukt."));
}
}, []);
useEffect(() => { void load(); }, [load]);
const save = async () => {
setBusy(true);
setError("");
setNotice("");
setTest(null);
try {
await appopsClient.updateManagedAiConfig({ enabled, gatewayUrl: gatewayUrl.trim(), ...(credential ? { credential } : {}) });
setCredential("");
await load();
setNotice("AI-configuratie opgeslagen.");
} catch (err) {
setError(formatOperatorError(err, "AI-configuratie opslaan mislukt."));
} finally {
setBusy(false);
}
};
const testInference = async () => {
setBusy(true);
setError("");
setTest(null);
try {
setTest(await appopsClient.testManagedAiInference());
await load();
} catch (err) {
setError(formatOperatorError(err, "ModelForge-verbindingstest mislukt."));
} finally {
setBusy(false);
}
};
return (
<div className="space-y-5 rounded-lg border border-slate-800 bg-slate-950 p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="flex items-center gap-2 font-bold text-slate-200"><Sparkles className="h-4 w-4 text-blue-400" /> AI via ModelForge</h3>
<p className="mt-1 max-w-3xl text-sm text-slate-400">ModelForge beheert modelversies, serving en upgrades. AppOps gebruikt de vaste capability assistant.ops@1 voor tekstadvies met geredigeerde evidence.</p>
</div>
<Button variant="outline" disabled={busy} onClick={() => void load()}><RefreshCw className="mr-2 h-4 w-4" /> Status vernieuwen</Button>
</div>
{status && <div className="space-y-1 rounded-md border border-slate-800 p-3 text-xs text-slate-400">
<p className={status.ready ? "font-bold text-emerald-300" : "font-bold text-amber-300"}>{stateLabels[status.state]}</p>
<p>Credential: {status.credentialConfigured ? "geconfigureerd" : "ontbreekt"}{status.projectKey ? ` · Project: ${status.projectKey}` : ""}</p>
{status.model && <p className="break-words">Model in ModelForge: {status.model} · {status.revision}</p>}
{status.errorCode && <p>Foutcode: {status.errorCode}{status.correlationId ? ` · Referentie: ${status.correlationId}` : ""}</p>}
{status.lastInference && <p className="break-words">Laatste geslaagde inferentie: {status.lastInference.model} · {status.lastInference.revision} · {status.lastInference.observedAt ? new Date(status.lastInference.observedAt).toLocaleString("nl-BE") : "tijd onbekend"}</p>}
</div>}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2"><Label htmlFor="managed-ai-url">ModelForge gateway</Label><Input id="managed-ai-url" value={gatewayUrl} disabled={!admin || busy || !status} onChange={event => setGatewayUrl(event.target.value)} placeholder="https://modelforge.example.internal" /></div>
{admin && <div className="space-y-2"><Label htmlFor="managed-ai-credential">Servicecredential {status?.credentialConfigured ? "vervangen (optioneel)" : "instellen"}</Label><Input id="managed-ai-credential" type="password" autoComplete="new-password" value={credential} disabled={busy || !status} onChange={event => setCredential(event.target.value)} placeholder="Scoped credential uit ModelForge" /></div>}
</div>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"><Switch id="managed-ai-enabled" checked={enabled} disabled={!admin || busy || !status} onCheckedChange={setEnabled} /><Label htmlFor="managed-ai-enabled">ModelForge-advies inschakelen</Label></div>
{admin && <div className="flex flex-wrap gap-2"><Button disabled={busy || !status} onClick={() => void save()}>Opslaan</Button><Button variant="outline" disabled={busy || !status?.enabled} onClick={() => void testInference()}>Echte inferentie testen</Button></div>}
</div>
<p className="text-xs text-slate-500">Beheer projectbindings en het model in ModelForge. Chat en analyses blijven read-only. Security-aanbevelingen en remediationplannen zijn gebaseerd op vaste controles en het bestaande policybeleid.</p>
{busy && <p role="status" className="text-sm text-slate-400">Bezig… een eerste modelstart kan langer duren.</p>}
{notice && <p role="status" className="text-sm text-emerald-300">{notice}</p>}
{test && <div role="status" className="space-y-1 rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-xs text-emerald-200"><p className="font-bold">Model heeft geantwoord: {test.model}</p><p>{test.content}</p><p className="break-all">Revisie: {test.revision} · Request: {test.requestId}</p></div>}
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
</div>
);
}
+172
View File
@@ -0,0 +1,172 @@
import { useId, useMemo } from "react";
import { Link } from "react-router-dom";
import { ArrowRight, ArrowUpRight, AudioLines, Download, Film, GitBranch, Library, ListFilter, Search, Workflow } from "lucide-react";
import type { MediaPipelineFlow, MediaPipelineNode } from "@/types/appops";
import { buildMediaPipelineFlowLayout } from "@/lib/mediaPipelineFlowLayout";
import { cn } from "@/lib/utils";
type FlowTone = "healthy" | "warning" | "broken" | "unknown";
const tones: Record<FlowTone, { label: string; color: string; dash?: string; chip: string; border: string }> = {
healthy: { label: "Gezond", color: "#34d399", chip: "border-emerald-400/25 bg-emerald-400/10 text-emerald-300", border: "border-emerald-400/25" },
warning: { label: "Waarschuwing", color: "#fbbf24", dash: "8 5", chip: "border-amber-400/25 bg-amber-400/10 text-amber-200", border: "border-amber-400/30" },
broken: { label: "Onderbroken", color: "#fb7185", dash: "3 6", chip: "border-rose-400/25 bg-rose-400/10 text-rose-200", border: "border-rose-400/30" },
unknown: { label: "Onbekend", color: "#94a3b8", dash: "2 7", chip: "border-slate-500/35 bg-slate-400/10 text-slate-300", border: "border-slate-700" },
};
const toneFor = (status: string): FlowTone => {
if (["healthy", "connected", "running", "success"].includes(status)) return "healthy";
if (["warning", "partial", "missing_config", "starting"].includes(status)) return "warning";
if (["broken", "critical", "error", "auth_failed", "unreachable", "failed", "unhealthy"].includes(status)) return "broken";
return "unknown";
};
const statusLabels: Record<string, string> = {
connected: "Verbonden", missing_config: "Config ontbreekt", partial: "Gedeeltelijk",
critical: "Kritiek", error: "Fout", auth_failed: "Authenticatie fout", unreachable: "Onbereikbaar",
running: "Actief", starting: "Starten", stopped: "Gestopt", failed: "Gefaald", unhealthy: "Ongezond",
};
const nodeKinds: Record<string, { label: string; icon: typeof Workflow }> = {
wanted: { label: "Gezochte media", icon: Film },
indexers: { label: "Indexers & zoekopdrachten", icon: Search },
downloads: { label: "Downloads", icon: Download },
processing: { label: "Mediaverwerking", icon: Workflow },
library: { label: "Bibliotheek & playback", icon: Library },
analytics: { label: "Playback-analyses", icon: AudioLines },
};
const metricLabels: Record<string, string> = {
wanted: "Gezocht", queue: "Wachtrij", indexers: "Indexers", failing: "Falende indexers", apps: "Apps",
active: "Actief", stalled: "Vastgelopen", paused: "Gepauzeerd", failed: "Gefaald",
libraries: "Bibliotheken", streams: "Streams", transcodes: "Transcodes", plays: "Afgespeeld",
};
const edgeLabels: Record<string, string> = {
"indexer sync": "Indexer-sync", "release search": "Releases zoeken",
"media processing": "Mediaverwerking", "playback analytics": "Playback-analyses",
};
const humanize = (value: string) => value.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/[_-]/g, " ");
const statusLabel = (status: string) => statusLabels[status] || (status in tones ? tones[status as FlowTone].label : humanize(status));
const edgeLabel = (label: string) => edgeLabels[label] || label || "Verbinding";
const metricValue = (value: number | string | boolean | null) => value === null ? "Onbekend" : typeof value === "boolean" ? (value ? "Ja" : "Nee") : typeof value === "number" ? value.toLocaleString("nl-BE") : value;
function FlowNode({ node, x, y, width, height }: { node: MediaPipelineNode; x: number; y: number; width: number; height: number }) {
const tone = tones[toneFor(node.status)];
const kind = nodeKinds[node.type] || { label: humanize(node.type), icon: GitBranch };
const Icon = kind.icon;
const metrics = Object.entries(node.metrics);
return (
<Link
to={`/apps/${encodeURIComponent(node.id)}`}
aria-label={`${node.label} openen — ${statusLabel(node.status)}`}
className={cn("absolute flex flex-col rounded-xl border bg-slate-950 p-3 shadow-lg shadow-black/20 transition-colors hover:border-sky-400/70 hover:bg-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950", tone.border)}
style={{ left: x, top: y, width, height }}
>
<div className="flex items-center justify-between gap-2">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-sky-400/20 bg-sky-400/10 text-sky-300"><Icon className="h-4 w-4" aria-hidden="true" /></span>
<span title={node.status} className={cn("max-w-[132px] rounded border px-2 py-1 text-[9px] font-semibold leading-3", tone.chip)}>{statusLabel(node.status)}</span>
</div>
<h3 className="mt-2 truncate text-sm font-bold text-slate-100" title={node.label}>{node.label}</h3>
<p className="truncate text-[10px] text-slate-400" title={kind.label}>{kind.label}</p>
<dl className="mt-3 space-y-1">
{metrics.slice(0, 3).map(([key, value]) => (
<div key={key} className="flex items-center justify-between gap-2 text-[10px] leading-3">
<dt className="truncate text-slate-400" title={metricLabels[key] || humanize(key)}>{metricLabels[key] || humanize(key)}</dt>
<dd className="max-w-[90px] truncate font-mono font-semibold tabular-nums text-slate-200" title={String(metricValue(value))}>{metricValue(value)}</dd>
</div>
))}
</dl>
<span className="mt-auto flex items-center justify-between pt-2 text-[9px] text-sky-300">
<span>{metrics.length > 3 ? `Open app · +${metrics.length - 3} signalen` : "Open app"}</span><ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</span>
</Link>
);
}
export function MediaPipelineFlowDiagram({ flow }: { flow: MediaPipelineFlow | null }) {
const diagramId = useId().replace(/:/g, "");
const nodes = flow?.nodes;
const edges = flow?.edges;
const layout = useMemo(() => buildMediaPipelineFlowLayout(nodes || [], edges || []), [nodes, edges]);
const nodeById = new Map((nodes || []).map(node => [node.id, node]));
const unresolved = new Set(layout.unresolvedEdges);
const lanes = layout.lanes.map(lane => {
const kinds = lane.nodeIds.map(id => nodeById.get(id)?.type);
const label = lane.nodeIds.length === 1 ? "Losse connector" : kinds.includes("downloads") || kinds.includes("wanted") || kinds.includes("indexers") ? "Zoeken & downloaden" : kinds.includes("processing") || kinds.includes("library") || kinds.includes("analytics") ? "Verwerken & afspelen" : "Verbonden apps";
return { ...lane, label };
});
return (
<section aria-labelledby={`${diagramId}-title`} className="min-w-0 overflow-hidden rounded-xl border border-slate-800 bg-slate-900/50">
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-slate-800 px-5 py-4">
<div>
<h2 id={`${diagramId}-title`} className="flex items-center gap-2 text-sm font-bold text-slate-100"><Workflow className="h-4 w-4 text-sky-300" aria-hidden="true" /> Pipeline-flow</h2>
<p id={`${diagramId}-description`} className="mt-1 max-w-xl text-xs leading-5 text-slate-400">Volg de pijlen tussen de ingestelde connectors. De kleuren tonen hun bereikbaarheid en waarschuwingen. Bestandsverplaatsingen en actuele doorvoer worden hier niet gemeten.</p>
</div>
<span className="rounded-full border border-slate-700 bg-slate-950 px-3 py-1 text-[10px] text-slate-400">{layout.nodes.length} apps · {layout.edges.length} verbindingen</span>
</div>
{!layout.nodes.length ? (
<div className="px-5 py-10 text-center text-xs leading-6 text-slate-400">{flow ? "De backend heeft geen pipeline-nodes aangeleverd." : "Pipeline-flow is nog niet beschikbaar. Vernieuw om opnieuw te laden."}</div>
) : (
<>
<p className="px-5 pt-3 text-[10px] text-slate-500">Open een app voor details. Op een smal scherm kun je horizontaal door het diagram scrollen.</p>
<div tabIndex={0} role="region" aria-label="Pipeline-diagram, horizontaal scrollbaar" aria-describedby={`${diagramId}-description`} className="max-w-full overflow-x-auto overscroll-x-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-400">
<div className="relative mx-auto" style={{ width: layout.width, height: layout.height, backgroundImage: "radial-gradient(circle, rgb(51 65 85 / .4) 1px, transparent 1px)", backgroundSize: "18px 18px" }}>
{lanes.map((lane, index) => (
<div key={lane.nodeIds.join("|")} className="pointer-events-none absolute rounded-xl border border-slate-800/70 bg-slate-950/25" style={{ left: 10, top: lane.y, width: layout.width - 20, height: lane.height }}>
<div className="flex items-center gap-2 px-4 pt-3 text-[10px] font-semibold uppercase tracking-wider text-slate-500"><span className="flex h-5 w-5 items-center justify-center rounded border border-slate-700 text-[9px]">{String(index + 1).padStart(2, "0")}</span>{lane.label}</div>
</div>
))}
<svg aria-hidden="true" focusable="false" className="pointer-events-none absolute inset-0" width={layout.width} height={layout.height} viewBox={`0 0 ${layout.width} ${layout.height}`}>
<defs>
{(Object.entries(tones) as [FlowTone, typeof tones[FlowTone]][]).map(([key, tone]) => (
<marker key={key} id={`${diagramId}-${key}`} viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 1 1 L 9 5 L 1 9 z" fill={tone.color} /></marker>
))}
</defs>
{layout.edges.map(item => {
const tone = tones[toneFor(item.edge.status)];
return (
<g key={item.index}>
<path d={item.path} fill="none" stroke={tone.color} strokeWidth="8" strokeOpacity=".06" />
<path d={item.path} fill="none" stroke={tone.color} strokeWidth="2" strokeDasharray={tone.dash} strokeLinecap="round" markerEnd={`url(#${diagramId}-${toneFor(item.edge.status)})`} />
</g>
);
})}
{layout.edges.map(item => {
const tone = tones[toneFor(item.edge.status)];
const label = edgeLabel(item.edge.label);
return (
<g key={item.index} transform={`translate(${item.labelX}, ${item.labelY})`}>
<rect x="-65" y="-13" width="130" height="26" rx="6" fill="#020617" stroke={tone.color} strokeOpacity=".22" />
<text textAnchor="middle" dominantBaseline="central" fill={tone.color} fontSize="10" fontFamily="inherit">{label.length > 23 ? `${label.slice(0, 21)}…` : label}</text>
</g>
);
})}
</svg>
{layout.nodes.map(item => <FlowNode key={item.node.id} {...item} width={layout.nodeWidth} height={layout.nodeHeight} />)}
</div>
</div>
</>
)}
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-slate-800 bg-slate-950/40 px-5 py-3" aria-label="Legenda van verbindingsstatussen">
{(Object.entries(tones) as [FlowTone, typeof tones[FlowTone]][]).map(([key, tone]) => (
<span key={key} className="flex items-center gap-2 text-[10px] text-slate-400"><svg width="30" height="10" aria-hidden="true"><path d="M 0 5 H 30" stroke={tone.color} strokeWidth="2" strokeDasharray={tone.dash} /></svg>{tone.label}</span>
))}
</div>
{!!edges?.length && (
<details className="border-t border-slate-800 px-5 py-3">
<summary className="cursor-pointer text-xs font-semibold text-slate-300 focus-visible:rounded focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400"><span className="ml-1 inline-flex items-center gap-2"><ListFilter className="h-3.5 w-3.5" aria-hidden="true" /> Alle verbindingen als tekst{layout.unresolvedEdges.length > 0 && ` · ${layout.unresolvedEdges.length} zonder volledige nodes`}</span></summary>
<ul className="mt-3 grid gap-2 md:grid-cols-2">
{edges.map((edge, index) => (
<li key={index} className="rounded-lg border border-slate-800 bg-slate-950 p-3 text-xs">
<div className="flex flex-wrap items-center gap-2 font-semibold text-slate-200"><span>{nodeById.get(edge.from)?.label || edge.from}</span><ArrowRight className="h-3 w-3 text-slate-500" aria-hidden="true" /><span className="sr-only">naar</span><span>{nodeById.get(edge.to)?.label || edge.to}</span></div>
<p className="mt-1 text-[11px] text-slate-400">{edgeLabel(edge.label)} · <span style={{ color: tones[toneFor(edge.status)].color }}>{statusLabel(edge.status)}</span></p>
{unresolved.has(edge) && <p className="mt-1 text-[10px] text-amber-300">Een endpoint ontbreekt in de aangeleverde nodes. Deze relatie wordt alleen als tekst getoond.</p>}
</li>
))}
</ul>
</details>
)}
</section>
);
}
+4 -8
View File
@@ -1,21 +1,17 @@
import { LockKeyhole } from "lucide-react";
import { Link } from "react-router-dom";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { useUserSession } from "@/hooks/useUserSession";
export const OperatorReadOnlyBanner = () => {
const unlocked = useOperatorSession();
if (unlocked) return null;
const session = useUserSession();
if (!session.authenticated || session.permissions.some(permission => permission !== 'read')) return null;
return (
<div className="border-b border-amber-500/25 bg-amber-950/80 px-4 py-2 text-amber-100" role="status" aria-live="polite">
<div className="mx-auto flex max-w-[1800px] flex-col gap-2 text-xs sm:flex-row sm:items-center sm:justify-between">
<span className="flex items-center gap-2">
<LockKeyhole className="h-3.5 w-3.5 shrink-0 text-amber-300" aria-hidden="true" />
Read-only modus: status en auditinformatie blijven zichtbaar; mutaties en operatorintegraties zijn vergrendeld.
Alleen lezen: je account kan status en auditinformatie bekijken. Je beheerder kan extra rechten toekennen.
</span>
<Link className="font-semibold text-amber-200 underline-offset-4 hover:underline" to="/settings?tab=security">
Operatorsessie ontgrendelen
</Link>
</div>
</div>
);
+4 -4
View File
@@ -8,7 +8,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/co
import { RiskBadge } from "@/components/RiskBadge";
import { getPolicyActionButtonLabel, getPolicyActionState } from "@/lib/policyActionState";
import { showError } from "@/utils/toast";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
interface PolicyBadgeProps {
decision: PolicyDecision | string;
@@ -71,7 +71,7 @@ export const ActionButton = ({
title,
...props
}: ActionButtonProps) => {
const operatorUnlocked = useOperatorSession();
const operatorUnlocked = usePermission("actions.execute");
const d = decision.toLowerCase();
const state = getPolicyActionState(decision, risk, Boolean(onExecute), Boolean(onRequestApproval), explicitOverride);
@@ -109,7 +109,7 @@ export const ActionButton = ({
return (
<Button
disabled={!operatorUnlocked || disabled}
title={!operatorUnlocked ? "Ontgrendel de operatorsessie om een approval aan te vragen." : title}
title={!operatorUnlocked ? "Je account mag geen goedkeuring voor deze actie aanvragen." : title}
onClick={() => {
if (onRequestApproval) {
onRequestApproval();
@@ -128,7 +128,7 @@ export const ActionButton = ({
return (
<Button
disabled={!operatorUnlocked || disabled}
title={!operatorUnlocked ? "Ontgrendel de operatorsessie om deze actie uit te voeren." : title}
title={!operatorUnlocked ? "Je account heeft geen rechten voor deze actie." : title}
onClick={() => {
if (onExecute) {
onExecute();
+79
View File
@@ -0,0 +1,79 @@
.signin-page { min-height: 100svh; color: #e8eef1; background: #0b1118; }
.signin-layout { min-height: 100svh; display: grid; grid-template-columns: minmax(0, 1.2fr) minmax(420px, 1fr); }
.signin-story { position: relative; display: flex; flex-direction: column; overflow: hidden; padding: 44px clamp(36px, 6vw, 104px) 28px; border-right: 1px solid #21303b; background: radial-gradient(ellipse at 48% 57%, #12313b88, transparent 64%), #0c141e; }
.signin-story::before { position: absolute; inset: 0; content: ''; pointer-events: none; background-image: linear-gradient(#698ba30b 1px, transparent 1px), linear-gradient(90deg, #698ba30b 1px, transparent 1px); background-size: 40px 40px; mask-image: linear-gradient(transparent, #000 30%, #000 75%, transparent); }
.signin-brand { position: relative; display: flex; gap: 12px; align-items: center; width: fit-content; color: #f1f5f9; font-family: 'Space Grotesk', sans-serif; font-weight: 600; font-size: 24px; letter-spacing: -.7px; }
.signin-brand-subtitle { display: block; margin-top: 2px; color: #8ba4b4; font-family: 'Manrope', sans-serif; font-size: 9px; font-weight: 600; letter-spacing: 2px; }
.signin-story-content { position: relative; margin: auto 0; padding: 70px 0 48px; max-width: 590px; }
.signin-eyebrow { display: flex; align-items: center; gap: 9px; color: #83b9c7; font-size: 9px; font-weight: 700; letter-spacing: 1.9px; }
.signin-eyebrow > span { width: 5px; height: 5px; border-radius: 50%; background: #65d6cc; }
.signin-story h1 { margin: 23px 0 20px; font-size: clamp(38px, 4.05vw, 65px); font-weight: 500; line-height: 1.13; letter-spacing: -2.7px; }
.signin-story h1 > span { color: #77cbd1; }
.signin-intro { max-width: 420px; color: #91a7b8; font-size: 14px; line-height: 1.85; }
.signin-network { position: relative; width: 100%; max-width: 480px; height: 250px; margin: 30px 0 23px; }
.signin-network-lines { position: absolute; width: 100%; height: 100%; overflow: visible; }
.signin-network-lines path { stroke: #477280; stroke-width: 1; }
.signin-network-lines circle { fill: #7fd0d3; }
.signin-network-orbit { position: absolute; left: 50%; top: 50%; width: 154px; height: 154px; border: 1px solid #416a763d; border-radius: 50%; transform: translate(-50%, -50%); }
.signin-network-orbit--outer { width: 232px; height: 232px; border-style: dashed; border-color: #416a762b; }
.signin-network-core { position: absolute; left: 50%; top: 50%; display: grid; place-items: center; width: 96px; height: 96px; transform: translate(-50%, -50%); border: 1px solid #4d839559; border-radius: 25px; background: #101f2a; box-shadow: 0 12px 42px #020a1355, inset 0 1px #80afbe13; }
.signin-network-node { position: absolute; display: flex; align-items: center; gap: 9px; padding: 11px 13px; border: 1px solid #34576870; border-radius: 7px; color: #afc3cf; background: #11212be8; font-size: 11px; box-shadow: 0 8px 25px #040d1730; }
.signin-network-node svg { color: #7ab7c8; }
.signin-network-node--unraid { left: 5%; top: 20%; }
.signin-network-node--portainer { right: 2%; top: 18%; }
.signin-network-node--apps { left: 1%; bottom: 14%; }
.signin-network-node--policy { right: 0; bottom: 18%; }
.signin-principles { display: flex; flex-wrap: wrap; gap: 12px 19px; color: #92a9b9; font-size: 10px; }
.signin-principles > span { display: flex; align-items: center; gap: 6px; }
.signin-principles svg { color: #79beb8; }
.signin-story-footer { position: relative; color: #6f8799; font-size: 10px; letter-spacing: .2px; }
.signin-access { display: flex; flex-direction: column; padding: 48px clamp(36px, 5vw, 80px) 28px; background: radial-gradient(ellipse at 100% 0%, #14233466, transparent 60%), #0b1118; }
.signin-access-content { width: 100%; max-width: 376px; margin: auto; padding: 55px 0; }
.signin-access-symbol { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 29px; border: 1px solid #294653; border-radius: 15px; color: #8acdd9; background: #12212b; }
.signin-eyebrow--muted { color: #7e97a8; font-size: 9px; letter-spacing: 1.8px; }
.signin-access h2 { margin-top: 13px; font-size: 29px; line-height: 1.3; font-weight: 500; letter-spacing: -.8px; }
.signin-access-intro { margin-top: 13px; color: #92a1af; font-size: 12px; line-height: 1.8; }
.signin-provider-list { display: flex; flex-direction: column; gap: 12px; margin-top: 33px; min-height: 142px; }
.signin-provider { display: flex; align-items: center; gap: 14px; width: 100%; min-height: 68px; padding: 16px 19px; border: 1px solid #34424e; border-radius: 9px; color: #e1e8ed; background: #141e29; text-align: left; transition: border-color 150ms, background 150ms, transform 150ms; }
.signin-provider:hover:not(:disabled) { border-color: #628ea1; background: #1a2a36; transform: translateY(-1px); }
.signin-provider--primary { color: #122333; border-color: #e6edf2; background: #e6edf2; box-shadow: 0 4px 15px #0002; }
.signin-provider--primary:hover:not(:disabled) { color: #10232d; border-color: #fff; background: #fff; }
.signin-provider:disabled { opacity: .45; cursor: not-allowed; }
.signin-provider[disabled]:has(.signin-spinner) { opacity: .85; cursor: wait; }
.signin-provider-icon { display: grid; place-items: center; flex-shrink: 0; width: 22px; }
.signin-provider-icon--authentik { color: #e9b477; }
.signin-provider--primary .signin-provider-icon--authentik { color: #935b19; }
.signin-provider-text { display: flex; flex: 1; flex-direction: column; gap: 4px; min-width: 0; }
.signin-provider-text strong { font-size: 12px; font-weight: 700; }
.signin-provider-text > span { color: #8a9fac; font-size: 10px; line-height: 1.5; }
.signin-provider--primary .signin-provider-text > span { color: #596e80; }
.signin-provider-arrow { flex-shrink: 0; opacity: .7; }
.signin-loading { display: flex; align-items: center; gap: 12px; min-height: 110px; color: #92a9b9; font-size: 12px; }
.signin-spinner { animation: signin-spin 1s linear infinite; }
.signin-security-note { display: flex; align-items: flex-start; gap: 10px; margin-top: 29px; padding-top: 23px; border-top: 1px solid #22303b; color: #7bb5bc; }
.signin-security-note p { color: #afbec8; font-size: 11px; line-height: 1.7; }
.signin-security-note p > span { display: block; color: #7e93a4; font-size: 10px; }
.signin-help { max-width: 310px; margin-top: 30px; color: #7b8f9f; font-size: 10px; line-height: 1.8; }
.signin-access-footer { display: flex; justify-content: space-between; gap: 15px; color: #6b7e8e; font-size: 9px; letter-spacing: .4px; }
.signin-message { padding: 14px 16px; border: 1px solid #b78b464d; border-radius: 8px; color: #e7c996; background: #af87310a; font-size: 11px; line-height: 1.8; }
.signin-retry { display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; color: #e7dcc7; text-decoration: underline; text-underline-offset: 4px; }
@keyframes signin-spin { to { transform: rotate(360deg); } }
@media (min-width: 1700px) { .signin-story { padding-left: max(100px, calc((100vw - 1450px) / 2)); } }
@media (max-width: 1050px) { .signin-layout { grid-template-columns: minmax(0, 1fr) minmax(390px, 1fr); } .signin-story { padding-inline: 32px; } .signin-principles { gap: 10px 15px; } .signin-network { transform: scale(.95); transform-origin: center; } .signin-story h1 { letter-spacing: -1.8px; } }
@media (max-width: 800px) {
.signin-layout { grid-template-columns: 1fr; }
.signin-story { padding: 28px 28px 25px; border-right: 0; border-bottom: 1px solid #21303b; }
.signin-story-content { padding: 35px 0 0; max-width: 100%; }
.signin-story h1 { margin: 14px 0 0; font-size: 36px; letter-spacing: -1.3px; }
.signin-story h1 br { display: none; }
.signin-story h1 > span::before { content: ' '; }
.signin-story .signin-eyebrow { font-size: 8px; letter-spacing: 1.3px; }
.signin-intro, .signin-network, .signin-principles, .signin-story-footer { display: none; }
.signin-access { padding: 32px 28px 24px; }
.signin-access-content { max-width: 430px; padding: 0 0 40px; }
.signin-access-symbol { display: none; }
.signin-access h2 { font-size: 26px; }
.signin-provider-list { margin-top: 25px; }
.signin-help { margin-top: 23px; }
}
@media (prefers-reduced-motion: reduce) { .signin-spinner { animation: none; } .signin-provider { transition: none; } }
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useState } from 'react';
import { ArrowRight, Boxes, Check, KeyRound, Layers3, LoaderCircle, LockKeyhole, RefreshCw, Server, ShieldCheck } from 'lucide-react';
import { useSearchParams } from 'react-router-dom';
import { appopsClient } from '@/api/appopsClient';
import type { IdentityProvider } from '@/types/appops';
import './SignInPanel.css';
const loginMessages = new Map([
['expired', 'Je vorige aanmelding is verlopen of werd in een ander venster gestart. Probeer opnieuw.'],
['cancelled', 'De aanmelding is afgebroken. Kies hieronder hoe je wilt aanmelden.'],
['failed', 'De aanmelding kon niet worden voltooid. Probeer opnieuw of kies een andere aanmeldmethode.'],
]);
const MicrosoftMark = () => <svg width="20" height="20" viewBox="0 0 20 20" aria-hidden="true">
<path fill="#f25022" d="M1 1h8.5v8.5H1z" /><path fill="#7fba00" d="M10.5 1H19v8.5h-8.5z" />
<path fill="#00a4ef" d="M1 10.5h8.5V19H1z" /><path fill="#ffb900" d="M10.5 10.5H19V19h-8.5z" />
</svg>;
const GatewayIllustration = () => <div className="signin-network" aria-hidden="true">
<div className="signin-network-orbit signin-network-orbit--outer" /><div className="signin-network-orbit" />
<svg className="signin-network-lines" viewBox="0 0 480 250" fill="none">
<path d="M90 70H155Q170 70 170 85V110Q170 125 190 125H240M390 65H315Q300 65 300 85V110Q300 125 280 125H240M90 195H155Q170 195 170 175V145Q170 125 190 125H240M390 185H315Q300 185 300 165V145Q300 125 280 125H240" />
<circle cx="144" cy="70" r="3" /><circle cx="325" cy="185" r="3" />
</svg>
<div className="signin-network-core"><img src="/appops-icon.svg" width="64" height="64" alt="" /></div>
<div className="signin-network-node signin-network-node--unraid"><Server size={17} />Unraid</div>
<div className="signin-network-node signin-network-node--portainer"><Boxes size={17} />Portainer</div>
<div className="signin-network-node signin-network-node--apps"><Layers3 size={17} />Applicaties</div>
<div className="signin-network-node signin-network-node--policy"><ShieldCheck size={17} />Toegangsbeheer</div>
</div>;
export const SignInPanel = ({ checkingSession = false }: { checkingSession?: boolean }) => {
const [searchParams] = useSearchParams();
const [providers, setProviders] = useState<IdentityProvider[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
const [pending, setPending] = useState<IdentityProvider['id'] | null>(null);
const loginMessage = loginMessages.get(searchParams.get('signin_error') || '');
useEffect(() => {
let active = true;
appopsClient.getIdentityProviders().then(result => {
if (active) { setProviders(result); setLoading(false); }
}).catch(() => {
if (active) { setError('We kunnen de aanmeldmethoden momenteel niet laden. Probeer het opnieuw.'); setLoading(false); }
});
return () => { active = false; };
}, [attempt]);
const retry = () => { setError(''); setLoading(true); setAttempt(previous => previous + 1); };
const signIn = (provider: IdentityProvider['id']) => {
setPending(provider);
try { appopsClient.signIn(provider); }
catch { setPending(null); setError('Doorsturen naar de aanmeldprovider is niet gelukt. Probeer opnieuw.'); }
};
const availableProviders = [...providers].sort((a, b) => Number(b.configured) - Number(a.configured) || Number(b.id === 'entra') - Number(a.id === 'entra'));
const noProviders = !loading && !error && !providers.some(provider => provider.configured);
return <div className="signin-page">
<a href="#signin-main" className="app-skip-link">Ga naar aanmelden</a>
<div className="signin-layout">
<section className="signin-story" aria-label="AppOps Gateway">
<a className="signin-brand" href="/" aria-label="AppOps Gateway startpagina">
<img src="/appops-icon.svg" width="42" height="42" alt="" />
<span>AppOps<span className="signin-brand-subtitle">GATEWAY BY ITWORX</span></span>
</a>
<div className="signin-story-content">
<p className="signin-eyebrow"><span /> JOUW INFRASTRUCTUUR. JOUW OVERZICHT.</p>
<h1>Grip op je apps.<br /><span>Rust in je beheer.</span></h1>
<p className="signin-intro">Eén plek voor je infrastructuur, applicaties en dagelijkse operaties. Overzichtelijk, verbonden en onder jouw controle.</p>
<GatewayIllustration />
<div className="signin-principles"><span><Check size={14} />Persoonlijke accounts</span><span><Check size={14} />Toegang op maat</span><span><Check size={14} />Controle over elke actie</span></div>
</div>
<p className="signin-story-footer">Gebouwd voor de manier waarop jij beheert.</p>
</section>
<main id="signin-main" tabIndex={-1} className="signin-access">
<div className="signin-access-content">
<div className="signin-access-symbol"><LockKeyhole size={24} strokeWidth={1.6} aria-hidden="true" /></div>
<p className="signin-eyebrow signin-eyebrow--muted">WELKOM BIJ APPOPS</p>
<h2>Aanmelden bij je werkplek</h2>
<p className="signin-access-intro">Gebruik je vertrouwde account om verder te gaan.</p>
<div className="signin-provider-list" aria-busy={checkingSession || loading || pending !== null}>
{checkingSession || loading ? <div className="signin-loading" role="status"><LoaderCircle size={20} className="signin-spinner" aria-hidden="true" />{checkingSession ? 'Je aanmelding controleren…' : 'Aanmeldmethoden laden…'}</div> : <>
{loginMessage && <p className="signin-message" role="alert">{loginMessage}</p>}
{!error && availableProviders.map((provider, index) => <button
type="button" key={provider.id} onClick={() => signIn(provider.id)}
disabled={!provider.configured || pending !== null}
className={`signin-provider ${index === 0 && provider.configured ? 'signin-provider--primary' : ''}`}>
<span className={`signin-provider-icon ${provider.id === 'authentik' ? 'signin-provider-icon--authentik' : ''}`}>
{pending === provider.id ? <LoaderCircle size={20} className="signin-spinner" aria-hidden="true" /> : provider.id === 'entra' ? <MicrosoftMark /> : <KeyRound size={21} aria-hidden="true" />}
</span>
<span className="signin-provider-text"><strong>{pending === provider.id ? 'Je wordt doorgestuurd…' : provider.id === 'entra' ? 'Doorgaan met Microsoft' : 'Doorgaan met Authentik'}</strong>
<span>{provider.configured ? provider.label : 'Momenteel niet beschikbaar'}</span></span>
<ArrowRight size={18} className="signin-provider-arrow" aria-hidden="true" />
</button>)}
{error && <div className="signin-message" role="alert"><p>{error}</p><button type="button" className="signin-retry" onClick={retry}><RefreshCw size={14} aria-hidden="true" />Opnieuw proberen</button></div>}
{noProviders && <div className="signin-message" role="status"><p>Aanmelden is momenteel niet beschikbaar. Neem contact op met je beheerder.</p><button type="button" className="signin-retry" onClick={retry}><RefreshCw size={14} aria-hidden="true" />Opnieuw controleren</button></div>}
</>}
</div>
<div className="signin-security-note"><ShieldCheck size={17} aria-hidden="true" /><p>Veilig aanmelden via je organisatie.<span>Je account bepaalt wat je kunt bekijken en beheren.</span></p></div>
<p className="signin-help">Problemen met aanmelden? Neem contact op met je beheerder.</p>
</div>
<footer className="signin-access-footer"><span>Unraid AppOps Gateway</span><span>ITWorx</span></footer>
</main>
</div>
</div>;
};
+65 -29
View File
@@ -1,37 +1,73 @@
import { useMemo } from "react";
import type { SystemLoadChartPoint } from "@/lib/systemLoadChartView";
import { useId, useMemo, useState } from "react";
import { buildLoadSegments, chartTimePosition, formatLoadPercent, formatSampleTime, type SystemLoadChartPoint } from "@/lib/systemLoadChartView";
import { cn } from "@/lib/utils";
const polylinePoints = (points: SystemLoadChartPoint[], key: "cpu" | "ram") => {
const denominator = Math.max(1, points.length - 1);
return points.flatMap((point, index) => {
const value = point[key];
return typeof value === "number"
? [`${(index / denominator) * 100},${100 - Math.max(0, Math.min(value, 100))}`]
: [];
}).join(" ");
};
const series = [
{ key: "cpu", label: "CPU", stroke: "stroke-blue-400", dot: "bg-blue-400", text: "text-blue-300" },
{ key: "ram", label: "RAM", stroke: "stroke-violet-400", dot: "bg-violet-400", text: "text-violet-300" },
{ key: "gpu", label: "GPU", stroke: "stroke-amber-400", dot: "bg-amber-400", text: "text-amber-300" },
] as const;
export const SystemLoadSvgChart = ({ points }: { points: SystemLoadChartPoint[] }) => {
const cpu = useMemo(() => polylinePoints(points, "cpu"), [points]);
const ram = useMemo(() => polylinePoints(points, "ram"), [points]);
const firstTime = points[0]?.time ?? "";
const lastTime = points.at(-1)?.time ?? "";
const id = useId();
const [activeTimestamp, setActiveTimestamp] = useState<number | null>(null);
const [visible, setVisible] = useState({ cpu: true, ram: true, gpu: true });
const segments = useMemo(() => ({ cpu: buildLoadSegments(points, "cpu"), ram: buildLoadSegments(points, "ram"), gpu: buildLoadSegments(points, "gpu") }), [points]);
const activeIndex = activeTimestamp === null ? -1 : points.findIndex(point => point.timestamp === activeTimestamp);
const active = points[activeIndex];
const current = points.at(-1);
const start = points[0]?.timestamp ?? 0;
const end = current?.timestamp ?? start;
const ticks = start === end ? [start] : [start, start + (end - start) / 2, end];
return (
<figure className="flex h-full flex-col" aria-label="Live CPU- en RAM-belasting">
<svg className="min-h-0 flex-1 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" role="img">
<title>Live CPU- en RAM-belasting in procenten</title>
<desc>CPU is blauw en RAM is groen. De verticale schaal loopt van nul tot honderd procent.</desc>
{[0, 25, 50, 75, 100].map(value => (
<line key={value} x1="0" x2="100" y1={100 - value} y2={100 - value} vectorEffect="non-scaling-stroke" className="stroke-slate-800" strokeDasharray="2 6" />
))}
{cpu ? <polyline points={cpu} fill="none" vectorEffect="non-scaling-stroke" className="stroke-cyan-400" strokeWidth="2" /> : null}
{ram ? <polyline points={ram} fill="none" vectorEffect="non-scaling-stroke" className="stroke-emerald-400" strokeWidth="2" /> : null}
</svg>
<figcaption className="mt-2 flex items-center justify-between text-[10px] text-slate-500">
<span>{firstTime}</span>
<span className="flex gap-3"><span className="text-cyan-300">CPU</span><span className="text-emerald-300">RAM</span></span>
<span>{lastTime}</span>
<figure className="flex h-full min-w-0 flex-col gap-3" aria-label="Live CPU-, RAM- en GPU-belasting">
<div className="grid min-h-0 flex-1 grid-cols-[36px_minmax(0,1fr)] gap-2 pb-5 pt-2">
<div className="relative font-mono text-[10px] text-slate-500" aria-hidden="true">
{[0, 25, 50, 75, 100].map(value => <span key={value} className="absolute right-0 -translate-y-1/2" style={{ top: `${100 - value}%` }}>{value}%</span>)}
</div>
<div
className="relative min-h-[160px] rounded border-x border-slate-800/70 bg-slate-950/40 outline-none focus-visible:ring-2 focus-visible:ring-blue-400"
role="slider" tabIndex={0} aria-label="Meetpunt in belastinggrafiek" aria-describedby={`${id}-hint`}
aria-valuemin={0} aria-valuemax={Math.max(0, points.length - 1)} aria-valuenow={Math.max(0, activeIndex)}
aria-valuetext={active ? `${formatSampleTime(active.timestamp)}. ${series.map(item => `${item.label} ${formatLoadPercent(active[item.key])}`).join(". ")}` : "Selecteer een meetpunt"}
onFocus={() => setActiveTimestamp(current?.timestamp ?? null)}
onBlur={() => setActiveTimestamp(null)}
onKeyDown={event => {
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
event.preventDefault();
const index = activeIndex < 0 ? points.length - 1 : activeIndex;
const next = event.key === "Home" ? 0 : event.key === "End" ? points.length - 1 : Math.max(0, Math.min(points.length - 1, index + (event.key === "ArrowLeft" ? -1 : 1)));
setActiveTimestamp(points[next]?.timestamp ?? null);
}}
onPointerMove={event => {
const bounds = event.currentTarget.getBoundingClientRect();
const target = start + Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)) * (end - start);
const nearest = points.reduce<SystemLoadChartPoint | null>((best, point) => !best || Math.abs(point.timestamp - target) < Math.abs(best.timestamp - target) ? point : best, null);
setActiveTimestamp(nearest?.timestamp ?? null);
}}
onPointerLeave={event => { if (document.activeElement !== event.currentTarget) setActiveTimestamp(null); }}
>
<svg className="absolute inset-0 h-full w-full overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none" aria-hidden="true">
{[0, 25, 50, 75, 100].map(value => <line key={value} x1="0" x2="100" y1={100 - value} y2={100 - value} vectorEffect="non-scaling-stroke" className="stroke-slate-800" strokeDasharray="3 5" />)}
{series.filter(item => visible[item.key]).flatMap(item => segments[item.key].map((segment, index) => <polyline key={`${item.key}-${index}`} points={segment.map(point => `${point.x.toFixed(3)},${point.y.toFixed(3)}`).join(" ")} fill="none" vectorEffect="non-scaling-stroke" className={item.stroke} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />))}
{active && <line x1={chartTimePosition(points, active.timestamp)} x2={chartTimePosition(points, active.timestamp)} y1="0" y2="100" vectorEffect="non-scaling-stroke" className="stroke-slate-400" strokeDasharray="4 4" />}
</svg>
{series.filter(item => visible[item.key]).flatMap(item => segments[item.key].filter(segment => segment.length === 1).map((segment, index) => <span key={`${item.key}-${index}`} aria-hidden="true" className={cn("pointer-events-none absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full", item.dot)} style={{ left: `${segment[0].x}%`, top: `${segment[0].y}%` }} />))}
{active && <div data-load-tooltip className="pointer-events-none absolute top-2 z-10 w-40 max-w-full rounded-lg border border-slate-700 bg-slate-950/95 p-3 text-xs shadow-xl" style={{ left: `clamp(0px, ${chartTimePosition(points, active.timestamp)}%, max(0px, calc(100% - 160px)))` }} aria-hidden="true">
<p className="mb-2 font-mono text-slate-300">{formatSampleTime(active.timestamp)}</p>
{series.map(item => <p key={item.key} className={cn("flex justify-between gap-4 font-mono", item.text)}><span>{item.label}</span><span>{formatLoadPercent(active[item.key])}</span></p>)}
</div>}
<div className="absolute -bottom-5 left-0 right-0 flex justify-between gap-2 font-mono text-[10px] text-slate-500" aria-hidden="true">
{ticks.map((time, index) => <span key={index} className={ticks.length === 1 ? "mx-auto" : ""}>{formatSampleTime(time)}</span>)}
</div>
</div>
</div>
<figcaption className="flex flex-wrap items-center justify-between gap-2 text-[10px] text-slate-500">
<div className="flex flex-wrap gap-2">
{series.map(item => <button key={item.key} type="button" aria-pressed={visible[item.key]} aria-label={`${item.label}-reeks ${visible[item.key] ? "verbergen" : "tonen"}`} onClick={() => setVisible(previous => ({ ...previous, [item.key]: !previous[item.key] }))} className={cn("flex items-center gap-1.5 rounded border border-slate-800 px-2 py-1 font-mono outline-none transition-colors focus-visible:ring-2 focus-visible:ring-blue-400", visible[item.key] ? item.text : "text-slate-600")}><span className={cn("h-1.5 w-1.5 rounded-full", item.dot, !visible[item.key] && "opacity-30")} />{item.label} {formatLoadPercent(current?.[item.key] ?? null)}</button>)}
</div>
<span id={`${id}-hint`}>Wijs aan · gebruik ← → voor meetpunten</span>
</figcaption>
</figure>
);
+10 -7
View File
@@ -8,19 +8,22 @@ import { Button } from "./ui/button";
import { Sheet, SheetContent, SheetTrigger } from "./ui/sheet";
import { Sidebar } from "./Sidebar";
import { createCommandPaletteShortcutEvent } from "@/lib/commandPaletteShortcut";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { useUserSession } from "@/hooks/useUserSession";
export const TopStatusBar = () => {
const navigate = useNavigate();
const operatorUnlocked = useOperatorSession();
const session = useUserSession();
const operatorUnlocked = session.permissions.some(permission => permission !== 'read');
const summaryQuery = useQuery({
queryKey: ['system-summary'],
queryFn: () => appopsClient.getSystemSummary(),
enabled: session.authenticated,
refetchInterval: 30_000,
});
const notificationsQuery = useQuery({
queryKey: ['notifications-summary'],
queryFn: () => appopsClient.getNotificationSummary(),
enabled: session.authenticated,
refetchInterval: 30_000,
});
const summary = summaryQuery.data ?? null;
@@ -92,10 +95,10 @@ export const TopStatusBar = () => {
size="sm"
className={operatorUnlocked ? "text-emerald-300" : "text-amber-300"}
onClick={() => navigate("/settings?tab=security")}
title={operatorUnlocked ? "Operatorsessie actief" : "Mutaties vergrendeld — open beveiligingsinstellingen"}
title="Gebruikersaccount en rechten"
>
<Lock className="h-4 w-4" />
<span className="hidden 2xl:inline">{operatorUnlocked ? "UNLOCKED" : "READ-ONLY"}</span>
<span className="hidden 2xl:inline">{session.user?.role === 'admin' ? 'ADMIN' : operatorUnlocked ? 'GEBRUIKER' : 'READ-ONLY'}</span>
</Button>
{summary?.lastAction && (
<div className="hidden 2xl:flex items-center gap-2 px-3 py-1 rounded border border-slate-800 bg-slate-900">
@@ -145,11 +148,11 @@ export const TopStatusBar = () => {
<div className="flex items-center gap-2 pl-2 lg:gap-3 xl:pl-4 border-l border-slate-800">
<div className="text-right hidden xl:block">
<p className="text-[11px] font-bold text-slate-200 leading-none">{operatorUnlocked ? "Operator actief" : "Read-only viewer"}</p>
<p className="text-[9px] text-slate-500 uppercase tracking-widest mt-1">{operatorUnlocked ? "Unlocked" : "Operator locked"}</p>
<p className="text-[11px] font-bold text-slate-200 leading-none">{session.user?.displayName || session.user?.email || 'Niet aangemeld'}</p>
<p className="text-[9px] text-slate-500 uppercase tracking-widest mt-1">{session.user?.role === 'admin' ? 'Beheerder' : session.user?.role === 'read_only' ? 'Alleen lezen' : session.user?.role || 'Aanmelden'}</p>
</div>
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-blue-500/50 bg-blue-500/10 font-mono text-[9px] font-bold text-blue-300">
{operatorUnlocked ? "OP" : "RO"}
{session.user?.role === 'admin' ? "AD" : operatorUnlocked ? "OP" : "RO"}
</div>
</div>
</div>
+83
View File
@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react';
import { appopsClient } from '@/api/appopsClient';
import { setUserSessionStatus } from '@/api/userSession';
import { usePermission, useUserSession } from '@/hooks/useUserSession';
import type { GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate } from '@/types/appops';
import { Button } from '@/components/ui/button';
import { showError, showSuccess } from '@/utils/toast';
export const UserAccessSettings = () => {
const session = useUserSession();
const canManageUsers = usePermission('users.manage');
const [providers, setProviders] = useState<IdentityProvider[]>([]);
const [users, setUsers] = useState<GatewayUser[]>([]);
const [permissions, setPermissions] = useState<PermissionDefinition[]>([]);
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState('');
useEffect(() => {
let active = true;
appopsClient.getIdentityProviders().then(value => { if (active) setProviders(value); }).catch(() => { if (active) setError('Aanmeldproviders konden niet worden geladen.'); });
if (canManageUsers) {
Promise.all([appopsClient.getUsers(), appopsClient.getPermissionDefinitions()]).then(([accounts, catalog]) => {
if (active) { setUsers(accounts); setPermissions(catalog); }
}).catch(cause => { if (active) setError(cause instanceof Error ? cause.message : 'Gebruikers laden mislukt.'); });
}
return () => { active = false; };
}, [canManageUsers]);
const save = async (user: GatewayUser, update: UserAccessUpdate) => {
setBusy(user.id);
try {
const saved = await appopsClient.updateUserAccess(user.id, update);
setUsers(previous => previous.map(item => item.id === user.id ? saved : item));
setUserSessionStatus(await appopsClient.getUserSession());
showSuccess('Gebruikersrechten opgeslagen. De wijziging geldt direct.');
} catch (cause) { showError(cause instanceof Error ? cause.message : 'Opslaan mislukt.'); }
finally { setBusy(null); }
};
const logout = async () => {
setBusy('logout');
try { setUserSessionStatus(await appopsClient.logout()); }
catch (cause) { showError(cause instanceof Error ? cause.message : 'Afmelden mislukt.'); }
finally { setBusy(null); }
};
return <div className="space-y-5">
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-4">
<p className="text-sm font-bold text-slate-100">Gebruikers en toegang</p>
<p className="mt-2 text-sm text-slate-300">{session.user?.displayName || session.user?.email} · {session.user?.role === 'admin' ? 'Beheerder — alle bevoegdheden' : session.user?.role === 'read_only' ? 'Alleen lezen' : 'Toegang volgens jouw rechten'}</p>
<p className="mt-1 text-xs text-slate-400">Nieuwe gebruikers starten met alleen leesrechten. Policies en goedkeuringen blijven gelden voor iedere gebruiker.</p>
<div className="mt-3 flex flex-wrap gap-2">
{providers.map(provider => <Button key={provider.id} size="sm" variant="outline" disabled={!provider.configured} onClick={() => appopsClient.signIn(provider.id)}>{provider.label}{!provider.configured ? ' · niet geconfigureerd' : ''}</Button>)}
<Button size="sm" variant="outline" disabled={busy !== null} onClick={() => void logout()}>Afmelden</Button>
</div>
<p className="mt-2 text-xs text-slate-500">Bij toegang via een Authentik-proxy meld je ook af bij Authentik om automatische heraanmelding te beëindigen.</p>
</div>
{error && <p role="alert" className="text-sm text-rose-300">{error}</p>}
{canManageUsers && <div className="space-y-4">
<h3 className="text-sm font-bold text-slate-200">Gebruikersbeheer</h3>
<p className="text-xs text-slate-400">Gebruikers verschijnen na hun eerste SSO-aanmelding. Kies een rol of stel bij Aangepast de toegestane bevoegdheden in.</p>
{users.map(user => <UserAccessEditor key={`${user.id}:${user.role}:${user.enabled}:${user.permissions.join(',')}`} user={user} permissions={permissions} busy={busy !== null} onSave={update => void save(user, update)} />)}
</div>}
</div>;
};
const UserAccessEditor = ({ user, permissions, busy, onSave }: { user: GatewayUser; permissions: PermissionDefinition[]; busy: boolean; onSave: (update: UserAccessUpdate) => void }) => {
const [draft, setDraft] = useState<UserAccessUpdate>({ role: user.role, enabled: user.enabled, permissions: user.permissions });
return <fieldset disabled={busy} className="space-y-3 rounded-lg border border-slate-800 bg-slate-950 p-4">
<legend className="px-1 text-sm font-semibold text-slate-100">{user.email || user.displayName}</legend>
<p className="text-xs text-slate-500">{user.provider === 'entra' ? 'Microsoft Entra' : 'Authentik'} · {user.displayName}</p>
<div className="flex flex-wrap items-center gap-4">
<label className="text-sm text-slate-300">Rol <select value={draft.role} onChange={event => setDraft(previous => ({ ...previous, role: event.target.value as UserAccessUpdate['role'], permissions: [] }))} className="ml-2 rounded border border-slate-700 bg-slate-900 p-2">
<option value="admin">Beheerder</option><option value="operator">Operator</option><option value="read_only">Alleen lezen</option><option value="custom">Aangepast</option>
</select></label>
<label className="flex items-center gap-2 text-sm text-slate-300"><input type="checkbox" checked={draft.enabled} onChange={event => setDraft(previous => ({ ...previous, enabled: event.target.checked }))} />Account actief</label>
</div>
{draft.role === 'custom' && <div className="grid gap-2 sm:grid-cols-2">{permissions.filter(permission => !['read', 'users.manage'].includes(permission.id)).map(permission => <label key={permission.id} className="flex items-center gap-2 text-xs text-slate-300">
<input type="checkbox" checked={draft.permissions.includes(permission.id)} onChange={event => setDraft(previous => ({ ...previous, permissions: event.target.checked ? [...previous.permissions, permission.id] : previous.permissions.filter(item => item !== permission.id) }))} />{permission.label}
</label>)}</div>}
<Button size="sm" onClick={() => onSave(draft)}>Rechten opslaan</Button>
</fieldset>;
};
@@ -165,7 +165,7 @@ export const GovernedIntegrationFlow = ({ status, loading, unavailable }: Govern
id: "operator",
label: "Menselijke operator",
eyebrow: "beslist in AppOps",
description: "Alleen een ontgrendelde menselijke operatorsessie kan beslissen en de bestaande typed action-flow vervolgen.",
description: "Alleen een aangemelde gebruiker met de juiste bevoegdheden kan beslissen en de bestaande typed action-flow vervolgen.",
detail: "Policy, approvalstatus, audittrail en uitvoerresultaat blijven zichtbaar binnen het platform.",
state: states.executionBoundary,
stateLabel: states.executionBoundary === "healthy" ? "handmatig" : undefined,