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

+34 -21
View File
@@ -1,9 +1,11 @@
import { setUserSessionStatus } from './userSession';
import { ActionID, ActionPlan, ActionRegistryDetail, ActionRegistryResponse, ActionResult, ApprovalRequest, AiMemoryItem, AiRecommendation, App, AuditDetail, AuditEvent, Connector, ConnectorBinding, ConnectorCandidate, ConnectorSettings, ConnectorSettingsPayload, DeepHealth, HealthCheck, HomeAssistantExport, HomeAssistantVoiceReadiness, Incident, JobRun, NetworkExposure, PreflightPreview, RuntimeInfo, SetupCompleteResult, SetupConnectionTestResult, SetupReconcileResult, SetupStatus, Stack, StorageSummary, Container, ContainerLogsResponse, StatisticsSummary, SystemSummary, UnraidSummary, VoiceCommand, SourceType, PolicyRule, Playbook, PolicyDecision, RiskLevel, AiToolPermission, Status, SonarrAddSeriesPayload, SonarrHealth, SonarrQueueSummary, SonarrSearchResponse, SonarrWantedSummary, RadarrAddMoviePayload, RadarrHealth, RadarrQueueSummary, RadarrSearchResponse, RadarrWantedSummary, InventoryDiscoveryResult, InventoryDriftItem, InventoryMapExistingPayload, InventoryReviewUpdatePayload, InventoryRunSummary, DiscoveredContainer, AppContainerMapping, WidefrogActivity, WidefrogDownloadStatus, WidefrogFailedJobs, WidefrogHealth, WidefrogQueueSummary, SecretsResponse, SqliteBackupCreateResult, SqliteBackupsResponse, SqliteBackupVerifyResult, SqliteCheckpointResult, SqliteMaintenanceStatus, ProwlarrApplicationStatus, ProwlarrHealth, ProwlarrIndexerHealth, ProwlarrIndexerStatus, ProwlarrSearchTest, ProwlarrSyncStatus, DelugeDownloadSummary, DelugeHealth, DelugeSessionStatus, DelugeTorrentStatus, DelugeSpeedStatus, PlexHealth, PlexLibraryStatus, PlexRecentMedia, PlexServerStatus, PlexSessionStatus, PlexStreamSummary, TautulliActivity, TautulliHealth, TautulliHistory, TautulliLibraries, TautulliPlexInfo, TautulliRecentlyAdded, TautulliStatus, TautulliStreamSummary, TautulliUsers, TautulliWatchStats, NpmAccessLists, NpmCertificates, NpmExposureSummary, NpmHealth, NpmHostReachability, NpmProxyHosts, NpmSecurityWarnings, AuthentikApplication, AuthentikAuthCoverage, AuthentikCollection, AuthentikEvent, AuthentikFlowsSummary, AuthentikHealth, AuthentikOAuth2Provider, AuthentikOutpost, AuthentikOutpostHealth, AuthentikPoliciesSummary, AuthentikProvider, AuthentikProxyProvider, AuthentikSecurityWarnings, AuthentikSystemStatus, MediaPipelineActivity, MediaPipelineCheckResult, MediaPipelineDiagnosis, MediaPipelineDownloads, MediaPipelineFlow, MediaPipelineIssues, MediaPipelineLibrary, MediaPipelineRecommendations, MediaPipelineSummary, VoiceIntentTestResult } from '../types/appops';
import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } from '../types/ai';
import { toIsoString } from '@/lib/backendDateTime';
import type { AiSecurityFinding, AiSecurityRecommendation, AiSecurityReviewResult, AiSecurityScope, AiSecuritySummary, NotificationChannel, NotificationEvaluationResult, NotificationEvent, NotificationRule, NotificationSummary, RemediationApprovalProposal, RemediationDryRunResult, RemediationPlan, RemediationPlanCollection, SafetyDashboard, SafetyFinding, SafetyRecommendation, SafetyScoreBreakdown, SafetySection, SafetySummary, UnraidDisksResponse, UnraidDockerResponse, UnraidSharesResponse, UnraidVmCapabilitiesResponse, UnraidVmsResponse } from '../types/appops';
import type { GiteaActionsStatus, IntegrationStatus, OperatorAuthPolicy, OperatorSessionStatus, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops';
import type { GiteaActionsStatus, IntegrationStatus, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops';
import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, WorkMeshPromotion } from '../types/appops';
import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops';
type HealthStatus = {
status: string;
@@ -39,6 +41,8 @@ const safetyConfig: LiveClientConfig = {
requestTimeoutMs: 30000,
};
const aiConfig: LiveClientConfig = { ...defaultConfig, requestTimeoutMs: 65000 };
const containerListConfig: LiveClientConfig = {
...defaultConfig,
requestTimeoutMs: 30000,
@@ -74,12 +78,21 @@ const redactText = (input: string): string => {
const friendlyBackendError = (status: number, message: string): string => {
if (status === 401) {
return "Operatorautorisatie ontbreekt of is verlopen. Meld opnieuw aan via de accountproxy of ontgrendel dit tabblad via Instellingen → Beveiliging.";
return "Je aanmelding ontbreekt of is verlopen. Meld opnieuw aan via Authentik of Microsoft Entra.";
}
if (status === 403) {
return "Backend autorisatie is geweigerd.";
return "Je account heeft geen rechten voor deze actie.";
}
return `Backend ${status}: ${redactText(message || "Request failed")}`;
let detail = message || "Request failed";
try {
const parsed = JSON.parse(detail) as { detail?: unknown };
if (typeof parsed.detail === 'string') detail = parsed.detail;
else if (parsed.detail && typeof parsed.detail === 'object' && !Array.isArray(parsed.detail)) {
const named = parsed.detail as { message?: string; code?: string; correlationId?: string };
if (typeof named.message === 'string') detail = `${named.message}${named.code ? ` (${named.code})` : ''}${named.correlationId ? ` · ${named.correlationId}` : ''}`;
}
} catch { /* Keep the existing redacted text for non-JSON backend errors. */ }
return `Backend ${status}: ${redactText(detail)}`;
};
const request = async <T>(
@@ -107,6 +120,7 @@ const request = async <T>(
});
if (!response.ok) {
if (response.status === 401) setUserSessionStatus({ authenticated: false, user: null, permissions: [] });
const message = await response.text();
throw new Error(friendlyBackendError(response.status, message || response.statusText));
}
@@ -362,23 +376,16 @@ export const createLiveClient = () => ({
async getSystemSummary(): Promise<SystemSummary> { return get<SystemSummary>('/api/system/summary', defaultConfig); },
async getHealth(): Promise<HealthStatus> { return get<HealthStatus>('/api/health', defaultConfig); },
async getRuntimeInfo(): Promise<RuntimeInfo> { return get<RuntimeInfo>('/api/runtime/info', defaultConfig); },
async getOperatorSession(): Promise<OperatorSessionStatus> { return get<OperatorSessionStatus>('/api/operator/session', defaultConfig); },
async getOperatorAuthPolicy(): Promise<OperatorAuthPolicy> { return get<OperatorAuthPolicy>('/api/runtime/operator-auth', defaultConfig); },
async updateOperatorAuthPolicy(required: boolean): Promise<OperatorAuthPolicy> {
return request<OperatorAuthPolicy>('/api/runtime/operator-auth', defaultConfig, {
method: 'PATCH',
body: JSON.stringify({ required }),
});
async getUserSession(): Promise<UserAuthSession> { return get<UserAuthSession>('/api/auth/session', defaultConfig); },
async getIdentityProviders(): Promise<IdentityProvider[]> { return get<IdentityProvider[]>('/api/auth/providers', defaultConfig); },
signIn(provider: IdentityProvider['id']): void {
window.location.assign(`${defaultConfig.baseUrl}/api/auth/login/${encodeURIComponent(provider)}`);
},
async unlockOperatorSession(token: string): Promise<OperatorSessionStatus> {
return request<OperatorSessionStatus>('/api/operator/session', defaultConfig, {
method: 'POST',
headers: { 'X-AppOps-Operator-Token': token },
body: '{}',
});
},
async lockOperatorSession(): Promise<OperatorSessionStatus> {
return request<OperatorSessionStatus>('/api/operator/session', defaultConfig, { method: 'DELETE' });
async logout(): Promise<UserAuthSession> { return request<UserAuthSession>('/api/auth/logout', defaultConfig, { method: 'POST' }); },
async getUsers(): Promise<GatewayUser[]> { return get<GatewayUser[]>('/api/auth/users', defaultConfig); },
async getPermissionDefinitions(): Promise<PermissionDefinition[]> { return get<PermissionDefinition[]>('/api/auth/permissions', defaultConfig); },
async updateUserAccess(id: string, payload: UserAccessUpdate): Promise<GatewayUser> {
return request<GatewayUser>(`/api/auth/users/${encodeURIComponent(id)}`, defaultConfig, { method: 'PATCH', body: JSON.stringify(payload) });
},
async getDeepHealth(): Promise<DeepHealth> { return get<DeepHealth>('/api/health/deep', defaultConfig); },
async exportAdminData(kind: 'config' | 'policies' | 'inventory' | 'audit-summary'): Promise<Record<string, unknown>> {
@@ -524,6 +531,12 @@ export const createLiveClient = () => ({
return post<ActionResult>('/api/audit/export', { format }, defaultConfig);
},
async testConnector(id: string): Promise<ActionResult> { return post<ActionResult>(`/api/connectors/${id}/test`, {}, defaultConfig); },
async getExposurePreferences(): Promise<ExposurePreferences> { return get<ExposurePreferences>('/api/settings/exposure', defaultConfig); },
async updateExposurePreferences(payload: ExposurePreferences): Promise<ExposurePreferences> { return patch<ExposurePreferences>('/api/settings/exposure', payload, defaultConfig); },
async getManagedAiConfig(): Promise<ManagedAiConfig> { return get<ManagedAiConfig>('/api/settings/ai', defaultConfig); },
async getManagedAiStatus(): Promise<ManagedAiStatus> { return get<ManagedAiStatus>('/api/settings/ai/status', defaultConfig); },
async updateManagedAiConfig(payload: ManagedAiConfigUpdate): Promise<ManagedAiConfig> { return patch<ManagedAiConfig>('/api/settings/ai', payload, defaultConfig); },
async testManagedAiInference(): Promise<ManagedAiInference & { success: boolean }> { return post<ManagedAiInference & { success: boolean }>('/api/settings/ai/test', {}, aiConfig); },
async getAISummary(): Promise<AISummary> { return get<AISummary>('/api/ai/status', defaultConfig); },
async getAIRecommendations(): Promise<AIRecommendation[]> { return get<AIRecommendation[]>('/api/ai/recommendations', defaultConfig); },
async getAIChatHistory(): Promise<AIChatMessage[]> { return get<AIChatMessage[]>('/api/ai/history', defaultConfig); },
@@ -531,7 +544,7 @@ export const createLiveClient = () => ({
async getAIMemory(): Promise<AiMemoryItem[]> { return get<AiMemoryItem[]>('/api/ai/memory', defaultConfig); },
async getAITools(): Promise<AITool[]> { return get<AITool[]>('/api/ai/tools', defaultConfig); },
async sendAIChatMessage(content: string): Promise<AIChatMessage> {
return post<AIChatMessage>('/api/ai/chat', { content }, defaultConfig);
return post<AIChatMessage>('/api/ai/chat', { content }, aiConfig);
},
async getIntegrationStatus(): Promise<IntegrationStatus> {
return get<IntegrationStatus>('/api/integrations/status', defaultConfig);
-26
View File
@@ -1,26 +0,0 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
getOperatorSessionSnapshot,
setOperatorSessionStatus,
} from "./operatorSession";
describe("operator session", () => {
it("tracks backend-owned account and opaque browser sessions without storing tokens", () => {
setOperatorSessionStatus({ authorized: false, mode: "locked" });
assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: false, mode: "locked" });
setOperatorSessionStatus({ authorized: true, mode: "session" });
assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: true, mode: "session" });
setOperatorSessionStatus({ authorized: true, mode: "account", identity: "example-host" });
assert.deepEqual(getOperatorSessionSnapshot(), {
unlocked: true,
mode: "account",
identity: "example-host",
});
setOperatorSessionStatus({ authorized: false, mode: "locked" });
assert.deepEqual(getOperatorSessionSnapshot(), { unlocked: false, mode: "locked" });
});
});
-31
View File
@@ -1,31 +0,0 @@
import type { OperatorSessionStatus } from "@/types/appops";
export const OPERATOR_SESSION_EVENT = "appops:operator-session";
export type OperatorSessionMode = "locked" | "token" | "session" | "account" | "private";
export interface OperatorSessionSnapshot {
unlocked: boolean;
mode: OperatorSessionMode;
identity?: string;
}
let snapshot: OperatorSessionSnapshot = { unlocked: false, mode: "locked" };
const notify = (): void => {
if (typeof document !== "undefined") document.dispatchEvent(new CustomEvent(OPERATOR_SESSION_EVENT));
};
export const setOperatorSessionStatus = (status: OperatorSessionStatus): void => {
const mode = status.authorized && status.mode !== "locked" ? status.mode : "locked";
snapshot = mode === "account"
? { unlocked: true, mode, identity: (status.identity || "account").trim() }
: mode === "locked"
? { unlocked: false, mode }
: { unlocked: true, mode };
notify();
};
export const hasOperatorSession = (): boolean => snapshot.unlocked;
export const getOperatorSessionSnapshot = (): OperatorSessionSnapshot => snapshot;
+15
View File
@@ -0,0 +1,15 @@
import assert from 'node:assert/strict';
import { it } from 'node:test';
import { getUserSessionSnapshot, hasPermission, setUserSessionStatus } from './userSession';
it('uses server permissions for read-only, custom and admin accounts', () => {
setUserSessionStatus({ authenticated: true, user: null, permissions: ['read'] });
assert.equal(hasPermission('actions.execute'), false);
assert.equal(hasPermission('read'), true);
setUserSessionStatus({ authenticated: true, user: null, permissions: ['read', 'inventory.manage'] });
assert.equal(hasPermission('inventory.manage'), true);
assert.equal(hasPermission('users.manage'), false);
setUserSessionStatus({ authenticated: false, user: null, permissions: [] });
assert.equal(hasPermission('read'), false);
assert.equal(getUserSessionSnapshot().ready, true);
});
+12
View File
@@ -0,0 +1,12 @@
import type { UserAuthSession, UserPermission } from '@/types/appops';
export const USER_SESSION_EVENT = 'appops:user-session';
export type UserSessionSnapshot = UserAuthSession & { ready: boolean };
let snapshot: UserSessionSnapshot = { ready: false, authenticated: false, user: null, permissions: [] };
export const setUserSessionStatus = (status: UserAuthSession): void => {
snapshot = { ready: true, ...status };
if (typeof document !== 'undefined') document.dispatchEvent(new CustomEvent(USER_SESSION_EVENT));
};
export const getUserSessionSnapshot = (): UserSessionSnapshot => snapshot;
export const hasPermission = (permission: UserPermission): boolean => snapshot.authenticated && snapshot.permissions.includes(permission);
+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,
-17
View File
@@ -1,17 +0,0 @@
import { useSyncExternalStore } from "react";
import { getOperatorSessionSnapshot, OPERATOR_SESSION_EVENT, type OperatorSessionSnapshot } from "@/api/operatorSession";
const subscribe = (onStoreChange: () => void): (() => void) => {
document.addEventListener(OPERATOR_SESSION_EVENT, onStoreChange);
return () => document.removeEventListener(OPERATOR_SESSION_EVENT, onStoreChange);
};
const getSnapshot = (): OperatorSessionSnapshot => getOperatorSessionSnapshot();
const serverSnapshot: OperatorSessionSnapshot = { unlocked: false, mode: "locked" };
export const useOperatorSessionDetails = (): OperatorSessionSnapshot => (
useSyncExternalStore(subscribe, getSnapshot, () => serverSnapshot)
);
export const useOperatorSession = (): boolean => useOperatorSessionDetails().unlocked;
+14
View File
@@ -0,0 +1,14 @@
import { useSyncExternalStore } from 'react';
import { getUserSessionSnapshot, USER_SESSION_EVENT, type UserSessionSnapshot } from '@/api/userSession';
import type { UserPermission } from '@/types/appops';
const subscribe = (onChange: () => void): (() => void) => {
document.addEventListener(USER_SESSION_EVENT, onChange);
return () => document.removeEventListener(USER_SESSION_EVENT, onChange);
};
const serverSnapshot: UserSessionSnapshot = { ready: false, authenticated: false, user: null, permissions: [] };
export const useUserSession = (): UserSessionSnapshot => useSyncExternalStore(subscribe, getUserSessionSnapshot, () => serverSnapshot);
export const usePermission = (permission: UserPermission): boolean => {
const session = useUserSession();
return session.authenticated && session.permissions.includes(permission);
};
+5 -4
View File
@@ -16,12 +16,13 @@ describe("accessible operator shell", () => {
it("announces read-only state without claiming the status data is locked", () => {
assert.match(banner, /role="status" aria-live="polite"/);
assert.match(banner, /Read-only modus/);
assert.match(banner, /Operatorsessie ontgrendelen/);
assert.match(banner, /Alleen lezen/);
assert.match(banner, /Je beheerder kan extra rechten toekennen/);
assert.doesNotMatch(banner, /ontgrendel|operator-token/i);
});
it("keeps notification mutations and write-only channel fields locked without an operator session", () => {
assert.match(notifications, /const unlocked = useOperatorSession\(\)/);
it("keeps notification mutations and write-only channel fields locked without notification permissions", () => {
assert.match(notifications, /const unlocked = usePermission\("notifications.manage"\)/);
assert.match(notifications, /disabled=\{!unlocked \|\| working !== null\}/);
assert.match(notifications, /disabled=\{!unlocked\} className="border-slate-800 bg-slate-950 text-slate-100"/);
});
+40
View File
@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildExposureView, readExposureFilter, toggleExposureFilter, withExposureFilter } from "./exposureView.ts";
import type { NetworkExposure } from "../types/appops.ts";
const rows: NetworkExposure[] = [
{ source: "reverse_proxy", host: "safe.example.test", protocol: "https", port: 443, protected: true, risk: "low" },
{ source: "reverse_proxy", host: "open.example.test", protocol: "https", port: 443, protected: false, risk: "high" },
{ source: "reverse_proxy", host: "expired.example.test", protocol: "https", port: 443, protected: true, risk: "critical" },
];
describe("exposure filters", () => {
it("filters canonical rows without changing full snapshot counts or risk", () => {
const view = buildExposureView(rows, "protected");
assert.deepEqual(view.rows.map(row => row.host), ["expired.example.test", "safe.example.test"]);
assert.deepEqual(view.counts, { total: 3, protected: 2, highRisk: 2 });
assert.deepEqual(buildExposureView(rows, "high-risk").rows.map(row => row.host), ["expired.example.test", "open.example.test"]);
assert.equal(rows[1].protected, false);
assert.equal(rows[1].risk, "high");
});
it("toggles a selected card off and allows switching cards", () => {
assert.equal(toggleExposureFilter("protected", "protected"), "all");
assert.equal(toggleExposureFilter("protected", "high-risk"), "high-risk");
assert.equal(buildExposureView(rows, "all").rows.length, 3);
assert.deepEqual(buildExposureView([], "high-risk").counts, { total: 0, protected: 0, highRisk: 0 });
});
it("restores filters from URL and preserves unrelated parameters on reset", () => {
const original = new URLSearchParams("filter=protected&query=host");
assert.equal(readExposureFilter(original), "protected");
assert.equal(readExposureFilter(new URLSearchParams("filter=unknown")), "all");
assert.equal(readExposureFilter(new URLSearchParams("filter=high-risk")), "high-risk");
const updated = withExposureFilter(original, "high-risk");
assert.equal(updated.get("query"), "host");
assert.equal(updated.get("filter"), "high-risk");
assert.equal(original.get("filter"), "protected");
assert.equal(withExposureFilter(updated, "all").toString(), "query=host");
});
});
+32
View File
@@ -0,0 +1,32 @@
import type { NetworkExposure } from "@/types/appops";
export type ExposureFilter = "all" | "protected" | "high-risk";
export const readExposureFilter = (params: URLSearchParams): ExposureFilter => {
const value = params.get("filter");
return value === "protected" || value === "high-risk" ? value : "all";
};
export const withExposureFilter = (params: URLSearchParams, filter: ExposureFilter) => {
const next = new URLSearchParams(params);
if (filter === "all") next.delete("filter");
else next.set("filter", filter);
return next;
};
export const toggleExposureFilter = (current: ExposureFilter, selected: ExposureFilter): ExposureFilter => (
current === selected ? "all" : selected
);
const highRisk = (row: NetworkExposure) => row.risk === "high" || row.risk === "critical";
const riskRank = { critical: 4, high: 3, medium: 2, low: 1 };
export const buildExposureView = (rows: NetworkExposure[], filter: ExposureFilter) => ({
counts: {
total: rows.length,
protected: rows.filter(row => row.protected).length,
highRisk: rows.filter(highRisk).length,
},
rows: rows.filter(row => filter === "all" || (filter === "protected" ? row.protected : highRisk(row)))
.sort((left, right) => riskRank[right.risk] - riskRank[left.risk] || left.host.localeCompare(right.host)),
});
+33 -2
View File
@@ -18,8 +18,8 @@ describe("jobRunView", () => {
it("filters jobs by id, action, target and audit reference", () => {
const jobs = [
{ id: "job-1", actionId: "container.restart", target: "sonarr", auditEventId: "audit-1" },
{ id: "job-2", actionId: "stack.redeploy", target: "auth-stack", auditEventId: "audit-2" },
{ id: "job-1", status: "running" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-1" },
{ id: "job-2", status: "succeeded" as const, actionId: "stack.redeploy", target: "auth-stack", auditEventId: "audit-2" },
];
assert.deepEqual(filterJobRuns(jobs, "sonarr").map(job => job.id), ["job-1"]);
@@ -27,6 +27,37 @@ describe("jobRunView", () => {
assert.deepEqual(filterJobRuns(jobs, "").map(job => job.id), ["job-1", "job-2"]);
});
const jobsByStatus = [
{ id: "job-queued", status: "queued" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-queued" },
{ id: "job-running", status: "running" as const, actionId: "stack.redeploy", target: "media" },
{ id: "job-failed", status: "failed" as const, actionId: "container.restart", target: "SONARR", auditEventId: "audit-failed" },
{ id: "job-succeeded", status: "succeeded" as const, actionId: "container.restart", target: "sonarr", auditEventId: "audit-succeeded" },
{ id: "job-cancelled", status: "cancelled" as const, actionId: "stack.redeploy", target: "media" },
];
it("shows only failed jobs when the Failed filter is selected", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "failed").map(job => job.id), ["job-failed"]);
});
it("keeps succeeded and cancelled jobs in Closed without including failed jobs", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "closed").map(job => job.id), ["job-succeeded", "job-cancelled"]);
});
it("groups queued and running jobs under Open", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "", "open").map(job => job.id), ["job-queued", "job-running"]);
});
it("combines text matching with the selected status filter", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, " SoNaRr ", "closed").map(job => job.id), ["job-succeeded"]);
assert.deepEqual(filterJobRuns(jobsByStatus, "audit-failed", "closed"), []);
assert.deepEqual(filterJobRuns(jobsByStatus, "audit-failed", "failed").map(job => job.id), ["job-failed"]);
});
it("restores all search matches after clearing a status filter", () => {
assert.deepEqual(filterJobRuns(jobsByStatus, "sonarr", "all").map(job => job.id), ["job-queued", "job-failed", "job-succeeded"]);
assert.deepEqual(filterJobRuns([], "", "failed"), []);
});
it("builds operator follow-up state for running jobs", () => {
const view = buildJobRunView({
id: "job-1",
+19 -11
View File
@@ -19,14 +19,15 @@ export const formatJobDuration = (durationMs?: number): string => {
export interface JobRunFilterTarget {
id: string;
status: JobStatus;
actionId: string;
target: string;
auditEventId?: string;
}
export interface JobRunViewTarget extends JobRunFilterTarget {
status: JobStatus;
}
export type JobRunViewTarget = JobRunFilterTarget;
export type JobRunStatusFilter = "all" | "open" | "failed" | "closed";
export interface JobRunView {
statusLabel: string;
@@ -65,14 +66,21 @@ export const buildJobRunView = (job: JobRunViewTarget): JobRunView => {
};
};
export const filterJobRuns = <T extends JobRunFilterTarget>(jobs: T[], query: string): T[] => {
export const filterJobRuns = <T extends JobRunFilterTarget>(jobs: T[], query: string, statusFilter: JobRunStatusFilter = "all"): T[] => {
const normalized = query.trim().toLowerCase();
if (!normalized) return jobs;
if (!normalized && statusFilter === "all") return jobs;
return jobs.filter(job => [
job.id,
job.actionId,
job.target,
job.auditEventId || "",
].some(value => value.toLowerCase().includes(normalized)));
return jobs.filter(job => {
const matchesStatus = statusFilter === "all"
|| (statusFilter === "open" && ["queued", "running"].includes(job.status))
|| (statusFilter === "failed" && job.status === "failed")
|| (statusFilter === "closed" && ["succeeded", "cancelled"].includes(job.status));
return matchesStatus && (!normalized || [
job.id,
job.actionId,
job.target,
job.auditEventId || "",
].some(value => value.toLowerCase().includes(normalized)));
});
};
+100
View File
@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import type { MediaPipelineEdge, MediaPipelineNode } from "../types/appops.ts";
import { buildMediaPipelineFlowLayout } from "./mediaPipelineFlowLayout.ts";
const node = (id: string, type = "unknown"): MediaPipelineNode => ({
id, label: id, type, status: "healthy", metrics: {}, actions: [],
});
const edge = (from: string, to: string): MediaPipelineEdge => ({
from, to, status: "healthy", label: "relatie",
});
describe("media pipeline flow layout", () => {
it("keeps the download and Widefrog chains independent without adding connections", () => {
const layout = buildMediaPipelineFlowLayout([
node("sonarr", "wanted"), node("prowlarr", "indexers"), node("deluge", "downloads"),
node("widefrog", "processing"), node("plex", "library"), node("tautulli", "analytics"),
], [edge("sonarr", "prowlarr"), edge("prowlarr", "deluge"), edge("widefrog", "plex"), edge("plex", "tautulli")]);
assert.equal(layout.lanes.length, 2);
assert.deepEqual(layout.lanes.map(lane => lane.nodeIds), [
["sonarr", "prowlarr", "deluge"], ["widefrog", "plex", "tautulli"],
]);
assert.deepEqual(layout.edges.map(item => [item.edge.from, item.edge.to]), [
["sonarr", "prowlarr"], ["prowlarr", "deluge"], ["widefrog", "plex"], ["plex", "tautulli"],
]);
assert.ok(layout.nodes.find(item => item.node.id === "widefrog")!.y > layout.nodes.find(item => item.node.id === "deluge")!.y);
});
it("lays out dynamic Radarr branching and merging in the direction of the supplied edges", () => {
const layout = buildMediaPipelineFlowLayout([
node("sonarr", "wanted"), node("radarr", "wanted"), node("prowlarr", "indexers"),
node("deluge", "downloads"), node("new-downloader", "downloads"),
], [edge("sonarr", "prowlarr"), edge("radarr", "prowlarr"), edge("prowlarr", "deluge"), edge("prowlarr", "new-downloader")]);
const byId = new Map(layout.nodes.map(item => [item.node.id, item]));
assert.equal(layout.lanes.length, 1);
assert.ok(byId.get("sonarr")!.x < byId.get("prowlarr")!.x);
assert.ok(byId.get("radarr")!.x < byId.get("prowlarr")!.x);
assert.ok(byId.get("prowlarr")!.x < byId.get("new-downloader")!.x);
assert.notEqual(byId.get("sonarr")!.y, byId.get("radarr")!.y);
assert.notEqual(byId.get("deluge")!.y, byId.get("new-downloader")!.y);
for (const first of layout.nodes) {
for (const second of layout.nodes) {
if (first === second) continue;
assert.ok(first.x + layout.nodeWidth <= second.x || second.x + layout.nodeWidth <= first.x || first.y + layout.nodeHeight <= second.y || second.y + layout.nodeHeight <= first.y);
}
}
});
it("places unknown and isolated connector IDs without assuming a fixed six-node pipeline", () => {
const layout = buildMediaPipelineFlowLayout([
node("custom-source"), node("custom-target"), node("standalone"),
], [edge("custom-source", "custom-target")]);
assert.equal(layout.nodes.length, 3);
assert.equal(layout.lanes.length, 2);
assert.ok(layout.nodes.every(item => Number.isFinite(item.x) && Number.isFinite(item.y)));
assert.ok(layout.nodes.every(item => item.x >= 0 && item.y >= 0 && item.x + layout.nodeWidth <= layout.width && item.y + layout.nodeHeight <= layout.height));
});
it("retains dangling edges for a text explanation without drawing invented nodes", () => {
const missingEdge = edge("present", "missing");
const layout = buildMediaPipelineFlowLayout([node("present")], [missingEdge, edge("absent", "present")]);
assert.equal(layout.nodes.length, 1);
assert.equal(layout.edges.length, 0);
assert.deepEqual(layout.unresolvedEdges, [missingEdge, edge("absent", "present")]);
});
it("handles cycles and self-references without dropping connectors or producing invalid geometry", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b"), node("c")], [
edge("a", "b"), edge("b", "a"), edge("b", "c"), edge("c", "c"),
]);
const byId = new Map(layout.nodes.map(item => [item.node.id, item]));
assert.equal(layout.nodes.length, 3);
assert.equal(layout.edges.length, 4);
assert.notEqual(byId.get("a")!.y, byId.get("b")!.y);
assert.ok(byId.get("c")!.x > byId.get("b")!.x);
assert.ok(layout.edges.every(item => !/NaN|Infinity|undefined/.test(item.path)));
assert.ok(layout.edges.every(item => Number.isFinite(item.labelX) && Number.isFinite(item.labelY)));
});
it("routes a connection that skips a stage around the intermediate node", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b"), node("c")], [edge("a", "b"), edge("b", "c"), edge("a", "c")]);
const skip = layout.edges.find(item => item.edge.from === "a" && item.edge.to === "c")!;
const middle = layout.nodes.find(item => item.node.id === "b")!;
assert.ok(skip.labelY < middle.y);
assert.equal(layout.edges.length, 3);
});
it("keeps return-edge labels within the scrollable canvas", () => {
const layout = buildMediaPipelineFlowLayout([node("a"), node("b")], [edge("a", "b"), edge("b", "b")]);
assert.ok(layout.edges.every(item => item.labelX - 65 >= 0 && item.labelX + 65 <= layout.width));
});
});
+191
View File
@@ -0,0 +1,191 @@
import type { MediaPipelineEdge, MediaPipelineNode } from "../types/appops";
export interface FlowPlacedNode {
node: MediaPipelineNode;
x: number;
y: number;
}
export interface FlowPlacedEdge {
edge: MediaPipelineEdge;
index: number;
path: string;
labelX: number;
labelY: number;
}
export interface FlowLane {
nodeIds: string[];
y: number;
height: number;
}
export interface MediaPipelineFlowLayout {
nodes: FlowPlacedNode[];
edges: FlowPlacedEdge[];
unresolvedEdges: MediaPipelineEdge[];
lanes: FlowLane[];
width: number;
height: number;
nodeWidth: number;
nodeHeight: number;
}
/** Lay out only the supplied topology. Disconnected components receive separate lanes. */
export function buildMediaPipelineFlowLayout(nodes: MediaPipelineNode[], edges: MediaPipelineEdge[]): MediaPipelineFlowLayout {
const nodeWidth = 204;
const nodeHeight = 172;
const columnGap = 144;
const rowGap = 24;
const padding = 24;
const uniqueNodes = nodes.filter((node, index) => nodes.findIndex(other => other.id === node.id) === index);
const byId = new Map(uniqueNodes.map(node => [node.id, node]));
const outgoing = new Map(uniqueNodes.map(node => [node.id, new Set<string>()]));
const neighbours = new Map(uniqueNodes.map(node => [node.id, new Set<string>()]));
const validEdges = edges.flatMap((edge, index) => {
if (!byId.has(edge.from) || !byId.has(edge.to)) return [];
outgoing.get(edge.from)!.add(edge.to);
neighbours.get(edge.from)!.add(edge.to);
neighbours.get(edge.to)!.add(edge.from);
return [{ edge, index }];
});
const unresolvedEdges = edges.filter(edge => !byId.has(edge.from) || !byId.has(edge.to));
// Collapse cycles before ranking so malformed or future topology always terminates.
const order = new Map<string, number>();
const low = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const clusters: string[][] = [];
const visit = (id: string) => {
order.set(id, order.size);
low.set(id, order.get(id)!);
stack.push(id);
onStack.add(id);
for (const target of outgoing.get(id)!) {
if (!order.has(target)) {
visit(target);
low.set(id, Math.min(low.get(id)!, low.get(target)!));
} else if (onStack.has(target)) {
low.set(id, Math.min(low.get(id)!, order.get(target)!));
}
}
if (low.get(id) === order.get(id)) {
const cluster: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
cluster.push(member);
} while (member !== id);
clusters.push(cluster);
}
};
for (const node of uniqueNodes) if (!order.has(node.id)) visit(node.id);
const clusterById = new Map(clusters.flatMap((cluster, index) => cluster.map(id => [id, index] as const)));
const clusterEdges = clusters.map(() => new Set<number>());
const incoming = clusters.map(() => 0);
for (const { edge } of validEdges) {
const from = clusterById.get(edge.from)!;
const to = clusterById.get(edge.to)!;
if (from !== to && !clusterEdges[from].has(to)) {
clusterEdges[from].add(to);
incoming[to]++;
}
}
const ranks = clusters.map(() => 0);
const ready = incoming.flatMap((count, index) => count === 0 ? [index] : []);
for (let index = 0; index < ready.length; index++) {
const from = ready[index];
for (const to of clusterEdges[from]) {
ranks[to] = Math.max(ranks[to], ranks[from] + 1);
if (--incoming[to] === 0) ready.push(to);
}
}
const components: string[][] = [];
const seen = new Set<string>();
for (const node of uniqueNodes) {
if (seen.has(node.id)) continue;
const connected = [node.id];
seen.add(node.id);
for (let index = 0; index < connected.length; index++) {
for (const neighbour of neighbours.get(connected[index])!) {
if (!seen.has(neighbour)) {
seen.add(neighbour);
connected.push(neighbour);
}
}
}
components.push(uniqueNodes.filter(item => connected.includes(item.id)).map(item => item.id));
}
const placedNodes: FlowPlacedNode[] = [];
const lanes: FlowLane[] = [];
let nextY = padding;
let maxRank = 0;
for (const nodeIds of components) {
const columns = new Map<number, string[]>();
for (const id of nodeIds) {
const rank = ranks[clusterById.get(id)!];
maxRank = Math.max(maxRank, rank);
columns.set(rank, [...(columns.get(rank) || []), id]);
}
const rows = Math.max(...Array.from(columns.values(), ids => ids.length));
const height = 68 + rows * nodeHeight + (rows - 1) * rowGap + padding;
lanes.push({ nodeIds, y: nextY, height });
for (const [rank, ids] of [...columns.entries()].sort(([first], [second]) => first - second)) {
ids.forEach((id, row) => placedNodes.push({
node: byId.get(id)!,
x: padding + rank * (nodeWidth + columnGap),
y: nextY + 68 + (rows - ids.length) * (nodeHeight + rowGap) / 2 + row * (nodeHeight + rowGap),
}));
}
nextY += height + 18;
}
const placedById = new Map(placedNodes.map(node => [node.node.id, node]));
const laneById = new Map(lanes.flatMap(lane => lane.nodeIds.map(id => [id, lane] as const)));
let hasReturnEdge = false;
const placedEdges = validEdges.map(({ edge, index }): FlowPlacedEdge => {
const from = placedById.get(edge.from)!;
const to = placedById.get(edge.to)!;
const startX = from.x + nodeWidth;
const startY = from.y + nodeHeight / 2;
const endX = to.x;
const endY = to.y + nodeHeight / 2;
if (to.x <= from.x) {
hasReturnEdge = true;
const turnX = Math.max(startX, to.x + nodeWidth) + 48;
const targetX = to.x + nodeWidth;
const targetY = edge.from === edge.to ? endY + 44 : endY;
return {
edge, index,
path: `M ${startX} ${startY} C ${turnX} ${startY - 36}, ${turnX} ${targetY + 36}, ${targetX} ${targetY}`,
labelX: turnX + 12,
labelY: (startY + targetY) / 2,
};
}
if (to.x - from.x > nodeWidth + columnGap) {
const railY = laneById.get(edge.from)!.y + 46;
return {
edge, index,
path: `M ${startX} ${startY} C ${startX + 28} ${startY}, ${startX + 28} ${railY}, ${startX + 48} ${railY} L ${endX - 48} ${railY} C ${endX - 28} ${railY}, ${endX - 28} ${endY}, ${endX} ${endY}`,
labelX: (startX + endX) / 2,
labelY: railY,
};
}
const midpoint = (startX + endX) / 2;
return {
edge, index,
path: `M ${startX} ${startY} C ${midpoint} ${startY}, ${midpoint} ${endY}, ${endX} ${endY}`,
labelX: midpoint,
labelY: (startY + endY) / 2 - 22,
};
});
return {
nodes: placedNodes, edges: placedEdges, unresolvedEdges, lanes,
width: padding * 2 + (maxRank + 1) * nodeWidth + maxRank * columnGap + (hasReturnEdge ? 128 : 0),
height: Math.max(nextY - 18 + padding, padding * 2), nodeWidth, nodeHeight,
};
}
+33
View File
@@ -9,6 +9,39 @@ const exposureRows = [
];
describe("loadNetworkSnapshot", () => {
const client = () => ({
getNpmProxyHosts: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmCertificates: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmAccessLists: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNpmExposureSummary: async () => { throw new Error("summary unavailable"); },
getNpmSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getAuthentikAuthCoverage: async () => { throw new Error("coverage unavailable"); },
getAuthentikOutpostHealth: async () => ({ success: true, status: "warning", available: false, total: 0, embeddedExpected: true, items: [] }),
getAuthentikSecurityWarnings: async () => ({ success: true, status: "ok", items: [], total: 0 }),
getNetworkExposure: async () => exposureRows,
});
it("keeps checks enabled with explicit partial evidence when preference permission is denied", async () => {
const snapshot = await loadNetworkSnapshot({ ...client(), getExposurePreferences: async () => { throw new Error("permission denied"); } });
assert.equal(snapshot.preferences.nginxAccessListChecksEnabled, true);
assert(snapshot.errors.some(error => error.source === "exposurePreferences"));
assert.deepEqual(snapshot.exposureRows, exposureRows);
});
it("does not fetch access lists when their diagnostics are disabled", async () => {
let accessListCalls = 0;
const snapshot = await loadNetworkSnapshot({
...client(),
getExposurePreferences: async () => ({ nginxAccessListChecksEnabled: false }),
getNpmAccessLists: async () => { accessListCalls++; throw new Error("unused ACL endpoint"); },
});
assert.equal(accessListCalls, 0);
assert.equal(snapshot.preferences.nginxAccessListChecksEnabled, false);
assert(!snapshot.errors.some(error => error.source === "accessLists"));
assert.equal(snapshot.exposureRows[0].risk, "high");
assert.equal(snapshot.exposureRows[0].protected, false);
});
it("keeps canonical exposure rows when optional network detail calls fail", async () => {
const snapshot = await loadNetworkSnapshot({
getNpmProxyHosts: async () => {
+21 -24
View File
@@ -2,6 +2,7 @@ import type {
AuthentikAuthCoverage,
AuthentikOutpostHealth,
AuthentikSecurityWarnings,
ExposurePreferences,
NetworkExposure,
NpmAccessLists,
NpmCertificates,
@@ -11,6 +12,7 @@ import type {
} from "@/types/appops";
export type NetworkSnapshotClient = {
getExposurePreferences?: () => Promise<ExposurePreferences>;
getNpmProxyHosts: () => Promise<NpmProxyHosts>;
getNpmCertificates: () => Promise<NpmCertificates>;
getNpmAccessLists: () => Promise<NpmAccessLists>;
@@ -28,6 +30,7 @@ export type NetworkSnapshotError = {
};
export type NetworkSnapshot = {
preferences: ExposurePreferences;
hosts: NpmProxyHosts | null;
certificates: NpmCertificates | null;
accessLists: NpmAccessLists | null;
@@ -65,6 +68,7 @@ type SettledNetworkPart<T> = {
};
type NetworkSnapshotParts = {
preferences: SettledNetworkPart<ExposurePreferences>;
hosts: SettledNetworkPart<NpmProxyHosts>;
certificates: SettledNetworkPart<NpmCertificates>;
accessLists: SettledNetworkPart<NpmAccessLists>;
@@ -83,6 +87,7 @@ const buildSnapshot = (parts: NetworkSnapshotParts): NetworkSnapshot => {
.map((item) => ({ source: item.source, message: item.error || "unknown error" }));
return {
preferences: parts.preferences.value || { nginxAccessListChecksEnabled: true },
hosts: parts.hosts.value,
certificates: parts.certificates.value,
accessLists: parts.accessLists.value,
@@ -97,41 +102,32 @@ const buildSnapshot = (parts: NetworkSnapshotParts): NetworkSnapshot => {
};
};
const startOptionalDetails = (client: NetworkSnapshotClient) => Promise.all([
const startOptionalDetails = (client: NetworkSnapshotClient) => {
const preferences = settle("exposurePreferences", client.getExposurePreferences
? client.getExposurePreferences()
: Promise.resolve({ nginxAccessListChecksEnabled: true }));
return Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("accessLists", preferences.then(part => part.value?.nginxAccessListChecksEnabled === false
? { success: true, status: "disabled", items: [], total: 0 }
: client.getNpmAccessLists())),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
preferences,
] as const);
};
export const loadNetworkSnapshot = async (client: NetworkSnapshotClient): Promise<NetworkSnapshot> => {
const [
hosts,
certificates,
accessLists,
summary,
warnings,
authCoverage,
outpostHealth,
authWarnings,
exposureRows,
] = await Promise.all([
settle("npmHosts", client.getNpmProxyHosts()),
settle("certificates", client.getNpmCertificates()),
settle("accessLists", client.getNpmAccessLists()),
settle("summary", client.getNpmExposureSummary()),
settle("npmWarnings", client.getNpmSecurityWarnings()),
settle("authCoverage", client.getAuthentikAuthCoverage()),
settle("outpostHealth", client.getAuthentikOutpostHealth()),
settle("authWarnings", client.getAuthentikSecurityWarnings()),
const [details, exposureRows] = await Promise.all([
startOptionalDetails(client),
settle("exposure", client.getNetworkExposure()),
]);
return buildSnapshot({ hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, exposureRows });
const [hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences] = details;
return buildSnapshot({ hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences, exposureRows });
};
export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): Promise<StartedNetworkSnapshotLoad> => {
@@ -141,7 +137,7 @@ export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): P
return {
exposureRows,
details: optionalDetails.then(([hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings]) => (
details: optionalDetails.then(([hosts, certificates, accessLists, summary, warnings, authCoverage, outpostHealth, authWarnings, preferences]) => (
buildSnapshot({
hosts,
certificates,
@@ -151,6 +147,7 @@ export const startNetworkSnapshotLoad = async (client: NetworkSnapshotClient): P
authCoverage,
outpostHealth,
authWarnings,
preferences,
exposureRows: { source: "exposure", value: exposureRows, error: null },
})
)),
+55 -12
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { buildSystemLoadChartData, formatLoadPercent } from "./systemLoadChartView.ts";
import { buildSystemLoadChartData, formatLoadPercent, buildLoadSegments } from "./systemLoadChartView.ts";
import type { StatisticsSummary } from "../types/appops.ts";
const statistics = (cpu: Array<[string, number | null]>, ram: Array<[string, number | null]>): StatisticsSummary => ({
@@ -14,19 +14,17 @@ const statistics = (cpu: Array<[string, number | null]>, ram: Array<[string, num
});
describe("buildSystemLoadChartData", () => {
it("turns one live sample into a renderable flat trend without inventing values", () => {
it("shows a single measured point without inventing five minutes of history", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:00Z", 0]],
[["2026-07-10T00:00:00Z", 38]],
));
assert.equal(view.renderable, true);
assert.equal(view.points.length, 2);
assert.equal(view.points.length, 1);
assert.equal(view.points[0].cpu, 0);
assert.equal(view.points[1].cpu, 0);
assert.equal(view.points[0].ram, 38);
assert.equal(view.points[1].ram, 38);
assert.match(view.caption, /huidige live sample/i);
assert.match(view.caption, /eerste meting/i);
});
it("keeps multi-sample history unchanged except for labels", () => {
@@ -38,7 +36,7 @@ describe("buildSystemLoadChartData", () => {
assert.equal(view.renderable, true);
assert.deepEqual(view.points.map((point) => point.cpu), [12, 18]);
assert.deepEqual(view.points.map((point) => point.ram), [40, 44]);
assert.equal(view.caption, "Live trend uit statistics history.");
assert.match(view.caption, /gemeten/i);
});
it("returns an explicit non-renderable state when no real samples exist", () => {
@@ -46,7 +44,7 @@ describe("buildSystemLoadChartData", () => {
assert.equal(view.renderable, false);
assert.deepEqual(view.points, []);
assert.match(view.caption, /geen live statistics/i);
assert.match(view.caption, /geen.*metingen/i);
});
it("keeps unavailable live metrics explicit instead of formatting them as zero", () => {
@@ -56,9 +54,54 @@ describe("buildSystemLoadChartData", () => {
));
assert.equal(view.renderable, true);
assert.deepEqual(view.points.map((point) => point.cpu), [null, null]);
assert.deepEqual(view.points.map((point) => point.ram), [92, 92]);
assert.equal(formatLoadPercent(view.currentCpu), "n/a");
assert.equal(formatLoadPercent(view.currentRam), "92%");
assert.deepEqual(view.points.map((point) => point.cpu), [null]);
assert.deepEqual(view.points.map((point) => point.ram), [92]);
assert.equal(formatLoadPercent(view.currentCpu), "—");
assert.equal(formatLoadPercent(view.currentRam), "92,0%");
});
it("aligns CPU, RAM and GPU by real timestamp without borrowing another sample", () => {
const input = statistics(
[["2026-07-10T00:00:00Z", 12.35], ["2026-07-10T00:00:30Z", 18.25]],
[["2026-07-10T00:00:00Z", 40.55]],
);
input.gpuHistory = [{ time: "2026-07-10T00:00:10Z", value: 65.45 }];
const view = buildSystemLoadChartData(input);
assert.deepEqual(view.points.map(p => [p.cpu, p.ram, p.gpu]), [[12.35, 40.55, null], [null, null, 65.45], [18.25, null, null]]);
assert.equal(view.points[2].timestamp - view.points[1].timestamp, 20_000);
assert.equal(view.currentRam, null);
assert.equal(view.currentGpu, null);
assert.equal(formatLoadPercent(view.currentCpu), "18,3%");
});
it("sorts timestamps and rejects impossible values and invalid timestamps", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:20Z", Infinity], ["invalid", 12], ["2026-07-10T00:00:00Z", 25.5]],
[["2026-07-10T00:00:20Z", 101]],
));
assert.equal(view.points.length, 2);
assert.equal(view.points[0].cpu, 25.5);
assert.equal(view.points[1].cpu, null);
assert.equal(view.points[1].ram, null);
assert.equal(formatLoadPercent(NaN), "—");
});
it("limits history to the selected real time window", () => {
const view = buildSystemLoadChartData(statistics(
[["2026-07-10T00:00:00Z", 25], ["2026-07-10T00:10:00Z", 35]], [],
), 5);
assert.equal(view.points.length, 1);
assert.equal(view.points[0].cpu, 35);
});
it("draws time-proportional segments and breaks at missing measurements and long pauses", () => {
const view = buildSystemLoadChartData(statistics([
["2026-07-10T00:00:00Z", 25], ["2026-07-10T00:00:10Z", 35],
["2026-07-10T00:00:30Z", null], ["2026-07-10T00:01:40Z", 15],
], []));
const segments = buildLoadSegments(view.points, "cpu");
assert.equal(segments.length, 2);
assert.deepEqual(segments[0], [{ x: 0, y: 75 }, { x: 10, y: 65 }]);
assert.deepEqual(segments[1], [{ x: 100, y: 85 }]);
});
});
+59 -58
View File
@@ -1,9 +1,13 @@
import type { StatisticsSummary } from "../types/appops.ts";
export const metricRefreshInterval = (visibility: string): number | false => visibility === "visible" ? 5_000 : false;
export interface SystemLoadChartPoint {
time: string;
timestamp: number;
cpu: number | null;
ram: number | null;
gpu: number | null;
}
export interface SystemLoadChartView {
@@ -12,73 +16,70 @@ export interface SystemLoadChartView {
caption: string;
currentCpu: number | null;
currentRam: number | null;
currentGpu: number | null;
}
const toTimeLabel = (time: string): string => {
const date = new Date(time);
if (Number.isNaN(date.getTime())) return time;
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
export const validLoadPercent = (value: unknown): number | null => (
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 ? value : null
);
const previousTimeLabel = (time: string): string => {
const date = new Date(time);
if (Number.isNaN(date.getTime())) return "vorige";
date.setMinutes(date.getMinutes() - 5);
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
};
export const formatSampleTime = (timestamp: number): string => new Date(timestamp).toLocaleTimeString("nl-BE", {
hour: "2-digit", minute: "2-digit", second: "2-digit",
});
export const buildSystemLoadChartData = (statistics: StatisticsSummary | null): SystemLoadChartView => {
const cpuHistory = statistics?.cpuHistory ?? [];
const ramHistory = statistics?.ramHistory ?? [];
const currentCpu = cpuHistory.at(-1)?.value ?? null;
const currentRam = ramHistory.at(-1)?.value ?? null;
const hasRealSample = [...cpuHistory, ...ramHistory].some((point) => point.value !== null);
if ((cpuHistory.length === 0 && ramHistory.length === 0) || !hasRealSample) {
return {
points: [],
renderable: false,
caption: "Geen live statistics samples beschikbaar.",
currentCpu,
currentRam,
};
export const buildSystemLoadChartData = (statistics: StatisticsSummary | null, windowMinutes = 15): SystemLoadChartView => {
const samples = new Map<number, SystemLoadChartPoint>();
const histories = { cpu: statistics?.cpuHistory, ram: statistics?.ramHistory, gpu: statistics?.gpuHistory };
for (const key of ["cpu", "ram", "gpu"] as const) {
for (const sample of histories[key] ?? []) {
const timestamp = Date.parse(sample.time);
if (!Number.isFinite(timestamp)) continue;
const point = samples.get(timestamp) ?? { time: sample.time, timestamp, cpu: null, ram: null, gpu: null };
point[key] = validLoadPercent(sample.value);
samples.set(timestamp, point);
}
}
const pointCount = Math.max(cpuHistory.length, ramHistory.length);
const points = Array.from({ length: pointCount }, (_, index) => {
const cpuPoint = cpuHistory[index] ?? cpuHistory.at(-1);
const ramPoint = ramHistory[index] ?? ramHistory.at(-1);
const time = cpuPoint?.time ?? ramPoint?.time ?? statistics?.generatedAt ?? "";
return {
time: toTimeLabel(time),
cpu: cpuPoint?.value ?? null,
ram: ramPoint?.value ?? null,
};
});
if (points.length === 1) {
const sampleTime = cpuHistory[0]?.time ?? ramHistory[0]?.time ?? statistics?.generatedAt ?? "";
return {
points: [
{ ...points[0], time: previousTimeLabel(sampleTime) },
{ ...points[0], time: toTimeLabel(sampleTime) },
],
renderable: true,
caption: "Vlakke trend uit huidige live sample; extra punten verschijnen bij volgende reads.",
currentCpu,
currentRam,
};
}
const ordered = [...samples.values()].sort((a, b) => a.timestamp - b.timestamp);
const lastTimestamp = Math.max(ordered.at(-1)?.timestamp ?? 0, Date.parse(statistics?.generatedAt ?? "") || 0);
const minutes = Number.isFinite(windowMinutes) && windowMinutes > 0 ? windowMinutes : 15;
const points = ordered.filter(point => point.timestamp >= lastTimestamp - minutes * 60_000);
const current = points.at(-1);
const renderable = points.some(point => point.cpu !== null || point.ram !== null || point.gpu !== null);
return {
points,
renderable: true,
caption: "Live trend uit statistics history.",
currentCpu,
currentRam,
renderable,
caption: !renderable ? "Geen recente metingen beschikbaar." : points.length === 1
? "Eerste meting. De historie bouwt op bij volgende metingen."
: `${points.length} gemeten punten · ontbrekende waarden blijven onderbroken.`,
currentCpu: current?.cpu ?? null,
currentRam: current?.ram ?? null,
currentGpu: current?.gpu ?? null,
};
};
export const formatLoadPercent = (value: number | null): string => (
typeof value === "number" ? `${value}%` : "n/a"
validLoadPercent(value) !== null ? `${value!.toLocaleString("nl-BE", { minimumFractionDigits: 1, maximumFractionDigits: 1 })}%` : "—"
);
export const chartTimePosition = (points: SystemLoadChartPoint[], timestamp: number): number => {
const start = points[0]?.timestamp ?? timestamp;
const end = points.at(-1)?.timestamp ?? timestamp;
return start === end ? 50 : ((timestamp - start) / (end - start)) * 100;
};
export const buildLoadSegments = (points: SystemLoadChartPoint[], key: "cpu" | "ram" | "gpu"): Array<Array<{ x: number; y: number }>> => {
const segments: Array<Array<{ x: number; y: number }>> = [];
let segment: Array<{ x: number; y: number }> | null = null;
let previousTimestamp = 0;
for (const point of points) {
const value = point[key];
if (value === null) { segment = null; continue; }
if (!segment || point.timestamp - previousTimestamp > 30_000) {
segment = [];
segments.push(segment);
}
segment.push({ x: chartTimePosition(points, point.timestamp), y: 100 - value });
previousTimestamp = point.timestamp;
}
return segments;
};
+7 -4
View File
@@ -14,8 +14,11 @@ import { buildApprovalEmptyState, buildApprovalFollowupView, filterApprovals } f
import { buildRouteLoadingState } from "@/lib/routeLoadingView";
import type { ApprovalStatus } from "@/types/appops";
import { formatOperatorError } from "@/lib/operatorError";
import { usePermission } from "@/hooks/useUserSession";
const Approvals = () => {
const canManageApprovals = usePermission("approvals.manage");
const canExecuteActions = usePermission("actions.execute");
const [searchParams, setSearchParams] = useSearchParams();
const query = (searchParams.get('query') || '').trim();
const [approvals, setApprovals] = useState<ApprovalRequest[]>([]);
@@ -166,7 +169,7 @@ const Approvals = () => {
type="button"
variant="outline"
onClick={() => void handleCleanup()}
disabled={cleanupBusy}
disabled={!canManageApprovals || cleanupBusy}
className="border-slate-700 bg-slate-950 text-slate-200"
>
<Archive className="mr-2 h-4 w-4" />
@@ -308,10 +311,10 @@ const Approvals = () => {
{followup.canDecide ? (
<div className="flex gap-3">
<Button onClick={() => handleAction(appr.id, 'approved')} disabled={busyApprovalId === appr.id} className="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-60">
<Button onClick={() => handleAction(appr.id, 'approved')} disabled={!canManageApprovals || busyApprovalId === appr.id} className="flex-1 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-60">
<Check className="w-4 h-4 mr-2" /> {busyApprovalId === appr.id ? "Verwerken..." : "Goedkeuren"}
</Button>
<Button onClick={() => handleAction(appr.id, 'rejected')} disabled={busyApprovalId === appr.id} variant="outline" className="flex-1 bg-slate-900 border-slate-800 text-rose-500 hover:bg-rose-500/10 disabled:opacity-60">
<Button onClick={() => handleAction(appr.id, 'rejected')} disabled={!canManageApprovals || busyApprovalId === appr.id} variant="outline" className="flex-1 bg-slate-900 border-slate-800 text-rose-500 hover:bg-rose-500/10 disabled:opacity-60">
<X className="w-4 h-4 mr-2" /> Afwijzen
</Button>
</div>
@@ -321,7 +324,7 @@ const Approvals = () => {
<p className="text-sm font-bold text-emerald-100">Goedgekeurd en klaar voor uitvoering</p>
<p className="mt-1 text-xs text-emerald-100/70">Dit start nu pas de echte connectorjob en schrijft job- en auditresultaat.</p>
</div>
<Button onClick={() => handleExecute(appr.id)} disabled={busyApprovalId === appr.id} className="bg-emerald-600 text-white hover:bg-emerald-500">
<Button onClick={() => handleExecute(appr.id)} disabled={!canManageApprovals || !canExecuteActions || busyApprovalId === appr.id} className="bg-emerald-600 text-white hover:bg-emerald-500">
<Play className="mr-2 h-4 w-4" /> {busyApprovalId === appr.id ? "Uitvoeren..." : "Voer goedgekeurde actie uit"}
</Button>
</div>
+3 -3
View File
@@ -19,7 +19,7 @@ import { buildCommandActionFollowup, type CommandActionFollowup } from "@/lib/co
import { buildCommandActionView } from "@/lib/commandActionView";
import { normalizeCommandCenterQuery } from "@/lib/commandCenterQuery";
import { formatOperatorError } from "@/lib/operatorError";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
type BrowserSpeechRecognition = {
continuous: boolean;
@@ -43,7 +43,7 @@ const registryExecutionStatus = (detail: ActionRegistryDetail) => {
};
const CommandCenter = () => {
const operatorUnlocked = useOperatorSession();
const operatorUnlocked = usePermission("actions.execute");
const [searchParams, setSearchParams] = useSearchParams();
const handoffQuery = normalizeCommandCenterQuery(searchParams.get("query"));
const [input, setInput] = useState(handoffQuery);
@@ -333,7 +333,7 @@ const CommandCenter = () => {
placeholder="Bijv: herstart widefrog, check unraid..."
className="h-14 min-w-0 flex-1 border-none bg-transparent font-display text-lg font-medium text-white placeholder:text-slate-600 focus-visible:bg-transparent focus-visible:ring-0"
/>
<Button onClick={handleSearch} disabled={isSearching || !operatorUnlocked} className="h-12 shrink-0 px-5" aria-label="Maak commandoplan" title={operatorUnlocked ? "Commando analyseren" : "Ontgrendel de operatorsessie om een commandoplan te maken."}>
<Button onClick={handleSearch} disabled={isSearching || !operatorUnlocked} className="h-12 shrink-0 px-5" aria-label="Maak commandoplan" title={operatorUnlocked ? "Commando analyseren" : "Je account mag geen commandoplan maken."}>
{isSearching ? <Zap className="w-5 h-5 animate-pulse" /> : <Send className="w-5 h-5" />}
<span className="hidden sm:inline">Analyseer</span>
</Button>
+4 -4
View File
@@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input";
import { StatusChip } from "@/components/StatusChip";
import type { RagQueryResult } from "@/types/appops";
import { showError, showSuccess } from "@/utils/toast";
import { OPERATOR_SESSION_EVENT } from "@/api/operatorSession";
import { USER_SESSION_EVENT } from "@/api/userSession";
import { GovernedIntegrationFlow } from "@/components/integrations/GovernedIntegrationFlow";
import { copyTextToClipboard } from "@/lib/clipboard";
import { getIntegrationRuntimeStates } from "@/lib/integrationFlow";
@@ -42,8 +42,8 @@ const Integrations = () => {
const refetchIntegrationStatus = statusQuery.refetch;
useEffect(() => {
const refresh = () => void refetchIntegrationStatus();
document.addEventListener(OPERATOR_SESSION_EVENT, refresh);
return () => document.removeEventListener(OPERATOR_SESSION_EVENT, refresh);
document.addEventListener(USER_SESSION_EVENT, refresh);
return () => document.removeEventListener(USER_SESSION_EVENT, refresh);
}, [refetchIntegrationStatus]);
const queryMutation = useMutation({
mutationFn: (value: string) => appopsClient.queryRag(value),
@@ -152,7 +152,7 @@ const Integrations = () => {
: statusQuery.isPending
? "Bronnen en chunks worden geladen…"
: statusQuery.isError
? "RAG-status is onbekend totdat dit tabblad opnieuw is ontgrendeld."
? "RAG-status is onbekend totdat je opnieuw bent aangemeld."
: `${status?.rag.sources ?? 0} bronnen en ${status?.rag.chunks ?? 0} chunks, aangevuld met actuele apps, policies, audit en notificaties.`}
</p>
<div className="flex items-center justify-between rounded border border-slate-800 bg-slate-950 px-3 py-2 text-xs">
+2 -2
View File
@@ -8,7 +8,7 @@ const clipboardSource = readFileSync(new URL("../lib/clipboard.ts", import.meta.
test("locked integration status stays unknown instead of reporting false outages", () => {
assert.match(source, /if \(unavailable\) return "warning"/);
assert.match(source, /RAG-status is onbekend totdat dit tabblad opnieuw is ontgrendeld/);
assert.match(source, /RAG-status is onbekend totdat je opnieuw bent aangemeld/);
assert.match(source, /statusQuery\.isError \? "ONBEKEND"/);
});
@@ -23,7 +23,7 @@ test("locked integration controls stay disabled until operator authorization ret
test("integration status uses verified runtime states and stops locked polling", () => {
assert.match(source, /state === "listening" \|\| state === "reachable"/);
assert.match(source, /query\.state\.status === "error" \? false : 30_000/);
assert.match(source, /OPERATOR_SESSION_EVENT/);
assert.match(source, /USER_SESSION_EVENT/);
assert.match(source, /laatst geverifieerd/);
assert.match(source, /Workflowstatus/);
});
+3 -3
View File
@@ -21,7 +21,7 @@ import { buildRouteLoadingState } from "@/lib/routeLoadingView";
import { showError, showSuccess } from "@/utils/toast";
import { Boxes, CheckSquare, ChevronLeft, ChevronRight, GitCompare, Import, Info, Link2, Pencil, RefreshCw, Search, ShieldCheck, SplitSquareHorizontal, X } from "lucide-react";
import { formatOperatorError } from "@/lib/operatorError";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
const statusClass = {
discovered: "text-blue-400 border-blue-500/20 bg-blue-500/5",
@@ -76,7 +76,7 @@ const extractResultIncidentId = (result: ActionResult): string | undefined => {
};
const Inventory = () => {
const operatorUnlocked = useOperatorSession();
const operatorUnlocked = usePermission("inventory.manage");
const location = useLocation();
const [searchParams, setSearchParams] = useSearchParams();
const defaultTab = location.pathname.includes("mapping") ? "mapping" : location.pathname.includes("drift") ? "drift" : "discovery";
@@ -474,7 +474,7 @@ const Inventory = () => {
<h2 className="text-3xl font-bold tracking-tight text-white">Inventory Review</h2>
<p className="text-slate-400 mt-1">Reviewledger voor discovery, importbeslissingen en drift. Gebruik Containers voor actuele runtime-state.</p>
</div>
<Button onClick={runDiscovery} disabled={!operatorUnlocked || busy || initialLoading} title={operatorUnlocked ? undefined : "Ontgrendel de operatorsessie om discovery te starten."} className="bg-blue-600 hover:bg-blue-500">
<Button onClick={runDiscovery} disabled={!operatorUnlocked || busy || initialLoading} title={operatorUnlocked ? undefined : "Je account mag geen discovery starten."} className="bg-blue-600 hover:bg-blue-500">
<RefreshCw className={cn("w-4 h-4 mr-2", (busy || initialLoading) && "animate-spin")} />
Run Discovery
</Button>
+50 -23
View File
@@ -12,7 +12,7 @@ import { cn } from "@/lib/utils";
import { RiskBadge } from "@/components/RiskBadge";
import { PolicyBadge } from "@/components/PolicyAction";
import { showError, showSuccess } from "@/utils/toast";
import { buildJobRunView, filterJobRuns, formatJobDuration, parseJobSteps } from "@/lib/jobRunView";
import { buildJobRunView, filterJobRuns, formatJobDuration, parseJobSteps, type JobRunStatusFilter } from "@/lib/jobRunView";
import { buildJobCancelFollowup, type JobCancelFollowup } from "@/lib/jobCancelFollowup";
import { buildRouteLoadingState } from "@/lib/routeLoadingView";
import { formatOperatorError } from "@/lib/operatorError";
@@ -34,7 +34,9 @@ const Jobs = () => {
const [cancelFollowups, setCancelFollowups] = useState<Record<string, JobCancelFollowup>>({});
const [error, setError] = useState<string | null>(null);
const [searchParams, setSearchParams] = useSearchParams();
const [query, setQuery] = useState(searchParams.get("query") || "");
const query = searchParams.get("query") || "";
const statusParam = searchParams.get("status");
const statusFilter: JobRunStatusFilter = statusParam === "open" || statusParam === "failed" || statusParam === "closed" ? statusParam : "all";
const load = async () => {
setLoading(true);
@@ -53,22 +55,29 @@ const Jobs = () => {
void load();
}, []);
const visibleJobs = useMemo(() => filterJobRuns(jobs, query), [jobs, query]);
const searchMatches = useMemo(() => filterJobRuns(jobs, query), [jobs, query]);
const visibleJobs = useMemo(() => filterJobRuns(searchMatches, "", statusFilter), [searchMatches, statusFilter]);
const summary = useMemo(() => ({
running: visibleJobs.filter(job => ["queued", "running"].includes(job.status)).length,
failed: visibleJobs.filter(job => job.status === "failed").length,
done: visibleJobs.filter(job => ["succeeded", "cancelled"].includes(job.status)).length,
}), [visibleJobs]);
running: searchMatches.filter(job => ["queued", "running"].includes(job.status)).length,
failed: searchMatches.filter(job => job.status === "failed").length,
done: searchMatches.filter(job => ["succeeded", "cancelled"].includes(job.status)).length,
}), [searchMatches]);
const updateQuery = (value: string) => {
setQuery(value);
const nextParams = new URLSearchParams(searchParams);
if (value.trim()) nextParams.set("query", value);
else nextParams.delete("query");
setSearchParams(nextParams, { replace: true });
};
const updateStatusFilter = (value: JobRunStatusFilter) => {
const nextParams = new URLSearchParams(searchParams);
if (value === "all" || value === statusFilter) nextParams.delete("status");
else nextParams.set("status", value);
setSearchParams(nextParams);
};
const cancelJob = async (job: JobRun) => {
setBusyJobId(job.id);
try {
@@ -119,28 +128,46 @@ const Jobs = () => {
</Button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-blue-300">Open</p>
<p className="text-2xl font-black text-white">{summary.running}</p>
</div>
<div className="rounded-lg border border-rose-500/20 bg-rose-500/5 p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-rose-300">Failed</p>
<p className="text-2xl font-black text-white">{summary.failed}</p>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900/50 p-4">
<p className="text-[10px] font-bold uppercase tracking-wider text-slate-500">Closed</p>
<p className="text-2xl font-black text-white">{summary.done}</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3" role="group" aria-label="Filter jobs op status">
{([
{ value: "open", label: "Open", count: summary.running, tone: "border-blue-500/20 bg-blue-500/5", labelTone: "text-blue-300" },
{ value: "failed", label: "Failed", count: summary.failed, tone: "border-rose-500/20 bg-rose-500/5", labelTone: "text-rose-300" },
{ value: "closed", label: "Closed", count: summary.done, tone: "border-slate-800 bg-slate-900/50", labelTone: "text-slate-400" },
] as const).map(filter => (
<button
key={filter.value}
type="button"
aria-pressed={statusFilter === filter.value}
onClick={() => updateStatusFilter(filter.value)}
className={cn(
"rounded-lg border p-4 text-left transition-colors hover:border-blue-400/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950",
filter.tone,
statusFilter === filter.value && "border-blue-400 bg-blue-500/10 ring-1 ring-blue-400",
)}
>
<span className="flex items-center justify-between gap-2">
<span className={cn("text-[10px] font-bold uppercase tracking-wider", filter.labelTone)}>{filter.label}</span>
{statusFilter === filter.value && <span className="flex items-center gap-1 text-[10px] font-semibold text-blue-200"><CheckCircle2 className="h-3 w-3" aria-hidden="true" /> Geselecteerd</span>}
</span>
<span className="block text-2xl font-black text-white">{filter.count}</span>
</button>
))}
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900/50 p-4">
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-900/50 p-4">
<Input
value={query}
onChange={(event) => updateQuery(event.target.value)}
aria-label="Zoek jobs op id, actie, target of audit-id"
placeholder="Filter jobs op id, actie, target of audit-id..."
className="border-slate-800 bg-slate-950 text-slate-200"
/>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-slate-400" role="status">{visibleJobs.length} van {searchMatches.length} jobs zichtbaar{statusFilter !== "all" && ` · ${statusFilter === "open" ? "Open" : statusFilter === "failed" ? "Failed" : "Closed"}`}</p>
<Button type="button" variant="outline" size="sm" aria-pressed={statusFilter === "all"} onClick={() => updateStatusFilter("all")} className={cn("border-slate-700 text-slate-300", statusFilter === "all" && "border-blue-400 text-blue-200")}>
Alle jobs
</Button>
</div>
</div>
{error && (
@@ -158,7 +185,7 @@ const Jobs = () => {
{!error && visibleJobs.length === 0 && (
<div className="flex flex-col items-center justify-center rounded-lg border border-slate-800 bg-slate-900/40 py-20 text-slate-500">
<History className="mb-4 h-10 w-10 opacity-30" />
<p>{query ? "Geen jobs gevonden voor deze filter." : "Geen jobs gevonden."}</p>
<p>{query || statusFilter !== "all" ? "Geen jobs gevonden voor deze filters." : "Geen jobs gevonden."}</p>
</div>
)}
+2 -34
View File
@@ -9,6 +9,7 @@ import { StatusChip } from "@/components/StatusChip";
import { RiskBadge } from "@/components/RiskBadge";
import { PolicyBadge } from "@/components/PolicyAction";
import { RouteLoadingCard } from "@/components/RouteLoadingCard";
import { MediaPipelineFlowDiagram } from "@/components/MediaPipelineFlowDiagram";
import { buildMediaDownloadTriage } from "@/lib/mediaDownloadTriage";
import { startMediaPipelinePageLoad } from "@/lib/mediaPipelinePageLoad";
import { buildRouteLoadingState } from "@/lib/routeLoadingView";
@@ -16,11 +17,9 @@ import { cn } from "@/lib/utils";
import {
Activity,
AlertTriangle,
ArrowRight,
ClipboardList,
Download,
Film,
GitBranch,
History,
Library,
Lock,
@@ -308,38 +307,7 @@ const MediaPipeline = () => {
</TabsContent>
<TabsContent value="flow" className="space-y-6">
<Card className="bg-slate-900/50 border-slate-800">
<CardContent className="p-6 space-y-6">
<div className="grid grid-cols-1 xl:grid-cols-6 gap-3">
{(flow?.nodes || []).map(node => (
<Link key={node.id} to={`/apps/${node.id}`} className="rounded-lg border border-slate-800 bg-slate-950 p-4 hover:border-blue-500/40">
<div className="mb-3 flex items-center justify-between gap-2">
<GitBranch className="h-4 w-4 text-blue-400" />
<StatusChip status={statusToChip(node.status)} />
</div>
<p className="text-sm font-bold text-white">{node.label}</p>
<p className="mb-3 text-[10px] uppercase text-slate-500">{node.type}</p>
<div className="space-y-1">
{Object.entries(node.metrics).slice(0, 3).map(([key, value]) => (
<div key={key} className="flex justify-between text-[10px] text-slate-500"><span>{key}</span><span>{String(value)}</span></div>
))}
</div>
</Link>
))}
</div>
<div className="space-y-2">
{(flow?.edges || []).map(edge => (
<div key={`${edge.from}-${edge.to}`} className="flex flex-wrap items-center gap-3 rounded border border-slate-800 bg-slate-950 p-3 text-xs">
<span className="font-bold text-slate-100">{edge.from}</span>
<ArrowRight className="h-3 w-3 text-slate-500" />
<span className="font-bold text-slate-100">{edge.to}</span>
<span className="text-slate-500">{edge.label}</span>
<StatusChip status={statusToChip(edge.status)} />
</div>
))}
</div>
</CardContent>
</Card>
<MediaPipelineFlowDiagram flow={flow} />
</TabsContent>
<TabsContent value="issues" className="grid grid-cols-1 xl:grid-cols-2 gap-4">
+47 -31
View File
@@ -10,13 +10,18 @@ import { Globe, ShieldCheck, ArrowRightLeft, Lock, AlertTriangle, ShieldAlert, K
import { StatusChip } from "@/components/StatusChip";
import { buildExposureWarningTriage, type ExposureWarningSource } from "@/lib/exposureWarningTriage";
import { startNetworkSnapshotLoad } from "@/lib/networkSnapshot";
import { buildExposureView, readExposureFilter, toggleExposureFilter, withExposureFilter, type ExposureFilter } from "@/lib/exposureView";
import { buildRouteLoadingState } from "@/lib/routeLoadingView";
import { cn } from "@/lib/utils";
import type { AuthentikAuthCoverage, AuthentikOutpostHealth, AuthentikSecurityWarnings, NetworkExposure, NpmAccessLists, NpmCertificates, NpmExposureSummary, NpmProxyHosts, NpmSecurityWarnings } from "@/types/appops";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { formatOperatorError } from "@/lib/operatorError";
const Network = () => {
const [searchParams, setSearchParams] = useSearchParams();
const exposureFilter = readExposureFilter(searchParams);
const selectFilter = (selected: ExposureFilter) => setSearchParams(withExposureFilter(searchParams, toggleExposureFilter(exposureFilter, selected)));
const [accessListChecksEnabled, setAccessListChecksEnabled] = useState(true);
const [hosts, setHosts] = useState<NpmProxyHosts | null>(null);
const [certificates, setCertificates] = useState<NpmCertificates | null>(null);
const [accessLists, setAccessLists] = useState<NpmAccessLists | null>(null);
@@ -43,6 +48,7 @@ const Network = () => {
setLoadError(null);
try {
const startedLoad = await startNetworkSnapshotLoad({
getExposurePreferences: () => appopsClient.getExposurePreferences(),
getNpmProxyHosts: () => appopsClient.getNpmProxyHosts(),
getNpmCertificates: () => appopsClient.getNpmCertificates(),
getNpmAccessLists: () => appopsClient.getNpmAccessLists(),
@@ -58,6 +64,7 @@ const Network = () => {
setLoading(false);
const snapshot = await startedLoad.details;
setAccessListChecksEnabled(snapshot.preferences.nginxAccessListChecksEnabled);
setHosts(snapshot.hosts);
setCertificates(snapshot.certificates);
setAccessLists(snapshot.accessLists);
@@ -86,7 +93,8 @@ const Network = () => {
const accessById = new Map((accessLists?.items || []).map(list => [list.id, list]));
const authByHostId = new Map((authCoverage?.publicHosts || []).map(host => [host.hostId, host]));
const rows = hosts?.items || [];
const protectedExposure = exposureRows.filter(row => row.protected).length;
const exposureView = buildExposureView(exposureRows, exposureFilter);
const protectedExposure = exposureView.counts.protected;
const enabledHosts = summary?.enabledProxyHosts ?? (rows.length ? rows.filter(host => host.enabled).length : exposureRows.length);
const combinedProtectedHosts = (authCoverage?.summary.protectedHosts ?? 0) + (authCoverage?.summary.likelyProtectedHosts ?? 0);
const accessListCoverage = enabledHosts
@@ -96,7 +104,7 @@ const Network = () => {
? Math.round((combinedProtectedHosts / authCoverage.summary.totalPublicHosts) * 100)
: exposureRows.length
? Math.round((protectedExposure / exposureRows.length) * 100)
: accessListCoverage;
: accessListChecksEnabled ? accessListCoverage : 0;
const authCoveragePercent = authCoverage?.summary.totalPublicHosts
? Math.round((combinedProtectedHosts / authCoverage.summary.totalPublicHosts) * 100)
: 0;
@@ -121,17 +129,16 @@ const Network = () => {
const allWarnings = [
...(authWarnings?.items || []).map(warning => ({ source: "authentik" as ExposureWarningSource, warning })),
...(warnings?.items || []).filter(warning => {
if (!accessListChecksEnabled && warning.type === "public_host_without_access_list") return false;
if (warning.type !== "public_host_without_access_list") return true;
const warningText = warning.evidence.join(" ").toLowerCase();
return !Array.from(protectedDomains).some(domain => warningText.includes(domain));
}).map(warning => ({ source: "nginx_proxy_manager" as ExposureWarningSource, warning })),
];
const highRiskExposure = exposureRows.filter(row => row.risk === "high" || row.risk === "critical").length;
const highRiskExposure = exposureView.counts.highRisk;
const publicHostCount = summary?.publicHosts ?? exposureRows.length;
const exposureRiskRank = { critical: 4, high: 3, medium: 2, low: 1 };
const sortedExposureRows = [...exposureRows].sort((left, right) => (
exposureRiskRank[right.risk] - exposureRiskRank[left.risk] || left.host.localeCompare(right.host)
));
const sortedExposureRows = exposureView.rows;
const exposureFilterLabel = exposureFilter === "protected" ? "Protected" : exposureFilter === "high-risk" ? "High Risk" : "Alle hosts";
const hasSnapshotEvidence = exposureRows.length > 0 || Boolean(hosts || summary || warnings || authCoverage || authWarnings);
const snapshotUnavailable = Boolean(loadError && !hasSnapshotEvidence);
@@ -177,25 +184,33 @@ const Network = () => {
<div className="rounded-lg border border-slate-800 bg-slate-900/50 p-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<MetricPill label="Snapshot" value={snapshotUnavailable ? "n/a" : `${exposureRows.length} hosts`} />
<MetricPill label="Protected" value={snapshotUnavailable ? "n/a" : `${protectedExposure}/${exposureRows.length || 0}`} tone={!snapshotUnavailable && protectedExposure === exposureRows.length ? "healthy" : "warning"} />
<MetricPill label="High Risk" value={snapshotUnavailable ? "n/a" : highRiskExposure} tone={!snapshotUnavailable && highRiskExposure ? "critical" : "healthy"} />
<MetricPill label="Protected" value={snapshotUnavailable ? "n/a" : `${protectedExposure}/${exposureRows.length || 0}`} tone={!snapshotUnavailable && protectedExposure === exposureRows.length ? "healthy" : "warning"} onClick={() => selectFilter("protected")} active={exposureFilter === "protected"} />
<MetricPill label="High Risk" value={snapshotUnavailable ? "n/a" : highRiskExposure} tone={!snapshotUnavailable && highRiskExposure ? "critical" : "healthy"} onClick={() => selectFilter("high-risk")} active={exposureFilter === "high-risk"} />
<MetricPill label={detailsLoading ? "Details" : "Updated"} value={detailsLoading ? "Loading" : snapshotUnavailable ? "n/a" : loadedAt || "..."} />
</div>
{partialErrors.length > 0 && (
<div className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
<div role="status" className="mt-3 rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Exposure snapshot deels geladen. Primaire hostlijst blijft zichtbaar; ontbrekende detailbronnen: {partialErrors.join(", ")}.
{partialErrors.includes("exposurePreferences") && " De monitoringvoorkeur kon niet worden gelezen; Nginx access-list-controle blijft standaard ingeschakeld."}
</div>
)}
{!accessListChecksEnabled && (
<p className="mt-3 text-xs text-slate-400">Nginx access-list-controle is uitgeschakeld. Authentik, certificaten en risico’s van onbeschermde publieke hosts blijven gecontroleerd. <Link to="/settings?tab=security" className="text-blue-400 underline">Instellingen</Link></p>
)}
</div>
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<Globe className="w-4 h-4" /> Canonical Exposure Snapshot
</CardTitle>
<div className="flex flex-wrap items-center justify-between gap-3">
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<Globe className="w-4 h-4" /> Canonical Exposure Snapshot
</CardTitle>
<Button size="sm" variant="outline" className="border-slate-700 bg-slate-950 text-slate-300" aria-controls="canonical-exposure-table" aria-pressed={exposureFilter === "all"} onClick={() => setSearchParams(withExposureFilter(searchParams, "all"))}>Alle hosts</Button>
</div>
<p role="status" aria-live="polite" className="text-xs text-slate-400">{sortedExposureRows.length} van {exposureRows.length} hosts · Filter: {exposureFilterLabel}. De tellers tonen de volledige snapshot.</p>
</CardHeader>
<CardContent className="p-0">
<Table>
<Table id="canonical-exposure-table">
<TableHeader className="bg-slate-950">
<TableRow className="border-slate-800">
<TableHead className="text-[10px] font-bold uppercase tracking-widest text-slate-500">Hostname</TableHead>
@@ -210,7 +225,7 @@ const Network = () => {
<TableRow key={`${row.source}-${row.host}-${row.port}`} className="border-slate-800 hover:bg-slate-900/50 transition-colors">
<TableCell className="font-mono text-xs text-blue-400">{row.host}</TableCell>
<TableCell className="text-xs text-slate-300">{row.protocol}:{row.port}</TableCell>
<TableCell className="text-[10px] text-slate-400">{row.protected ? row.protectionSource || "protected" : "none"}</TableCell>
<TableCell className="text-[10px] text-slate-400">{row.protected ? (!accessListChecksEnabled && row.protectionSource === "npm_access_list" ? "verified protection" : row.protectionSource || "protected") : "none"}</TableCell>
<TableCell className="max-w-[360px] text-[10px] text-slate-400">
{row.riskReasons?.length ? row.riskReasons.join("; ") : "No active exposure warning"}
</TableCell>
@@ -224,10 +239,10 @@ const Network = () => {
</TableCell>
</TableRow>
))}
{!exposureRows.length && (
{!sortedExposureRows.length && (
<TableRow className="border-slate-800">
<TableCell colSpan={5} className="py-6 text-center text-xs text-slate-500">
{snapshotUnavailable ? "Live exposure dataset niet beschikbaar." : "Geen canonical exposure rows beschikbaar."}
{snapshotUnavailable ? "Live exposure dataset niet beschikbaar." : exposureRows.length ? `Geen hosts voor filter ${exposureFilterLabel}. Kies Alle hosts om de volledige snapshot te zien.` : "Geen canonical exposure rows beschikbaar."}
</TableCell>
</TableRow>
)}
@@ -270,7 +285,7 @@ const Network = () => {
<h4 className="font-bold text-slate-200">Access Protection</h4>
</div>
<p className="text-2xl font-bold text-white">{protectionCoverage}% Coverage</p>
<p className="text-[10px] text-slate-500 mt-2 uppercase font-bold">{authCoverage?.summary.unprotectedHosts ?? 0} zonder Authentik match, {summary?.hostsWithoutAccessList ?? 0} zonder NPM access list</p>
<p className="text-[10px] text-slate-500 mt-2 uppercase font-bold">{authCoverage?.summary.unprotectedHosts ?? 0} zonder Authentik match{accessListChecksEnabled ? `, ${summary?.hostsWithoutAccessList ?? 0} zonder NPM access list` : " · Nginx access-list-controle uitgeschakeld"}</p>
</CardContent>
</Card>
</div>
@@ -372,11 +387,11 @@ const Network = () => {
<TableBody>
{rows.map(host => {
const cert = certById.get(host.certificateId);
const access = accessById.get(host.accessListId);
const access = accessListChecksEnabled ? accessById.get(host.accessListId) : undefined;
const auth = authByHostId.get(host.id);
const authProtected = auth?.coverageStatus === "protected" || auth?.coverageStatus === "likely_protected";
const ssl = !cert ? "Missing" : cert.isExpired ? "Expired" : cert.isExpiringSoon ? "Expiring" : "Valid";
const protectionHealthy = Boolean(access || authProtected);
const protectionHealthy = Boolean(host.accessListId || authProtected);
return (
<TableRow key={String(host.id)} className="border-slate-800 hover:bg-slate-900/50 transition-colors">
<TableCell className="font-mono text-xs text-blue-400">{host.domainNames.join(", ") || "unknown"}</TableCell>
@@ -399,7 +414,7 @@ const Network = () => {
{protectionHealthy ? (
<div className="flex items-center gap-1.5">
<Lock className="w-3 h-3 text-emerald-500" />
<span className="text-[10px] text-slate-400">{access ? "NPM list" : auth?.coverageStatus?.replace("_", " ")}</span>
<span className="text-[10px] text-slate-400">{authProtected ? auth?.coverageStatus?.replace("_", " ") : !accessListChecksEnabled ? "verified protection" : access ? "NPM list" : "NPM list assigned"}</span>
</div>
) : (
<AlertTriangle className="w-3 h-3 text-rose-500" />
@@ -463,14 +478,15 @@ const Network = () => {
);
};
const MetricPill = ({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "healthy" | "warning" | "critical" }) => (
<div className={cn(
"rounded border px-3 py-2",
tone === "healthy" ? "border-emerald-500/20 bg-emerald-500/5" : tone === "warning" ? "border-amber-500/20 bg-amber-500/5" : tone === "critical" ? "border-rose-500/20 bg-rose-500/5" : "border-slate-800 bg-slate-950"
)}>
<p className="text-[10px] font-bold uppercase text-slate-500">{label}</p>
<p className="mt-1 text-sm font-bold text-white">{value}</p>
</div>
);
const MetricPill = ({ label, value, tone = "default", onClick, active = false }: { label: string; value: string | number; tone?: "default" | "healthy" | "warning" | "critical"; onClick?: () => void; active?: boolean }) => {
const className = cn(
"rounded border px-3 py-2 text-left",
tone === "healthy" ? "border-emerald-500/20 bg-emerald-500/5" : tone === "warning" ? "border-amber-500/20 bg-amber-500/5" : tone === "critical" ? "border-rose-500/20 bg-rose-500/5" : "border-slate-800 bg-slate-950",
onClick && "transition-colors hover:border-blue-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400",
active && "ring-2 ring-blue-400",
);
const content = <><span className="block text-[10px] font-bold uppercase text-slate-500">{label}</span><span className="mt-1 block text-sm font-bold text-white">{value}</span></>;
return onClick ? <button type="button" className={className} onClick={onClick} aria-pressed={active} aria-controls="canonical-exposure-table" aria-label={`Filter ${label}`}>{content}</button> : <div className={className}>{content}</div>;
};
export default Network;
+2 -2
View File
@@ -19,7 +19,7 @@ import { buildHomeAssistantNotificationActionState, buildNotificationEmptyState,
import { buildRouteLoadingState } from "@/lib/routeLoadingView";
import { formatOperatorError } from "@/lib/operatorError";
import { ExternalNotificationChannels } from "@/components/ExternalNotificationChannels";
import { useOperatorSession } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
const severityTone: Record<NotificationSeverity, string> = {
info: "border-slate-600 bg-slate-500/10 text-slate-300",
@@ -100,7 +100,7 @@ const buildHomeAssistantForm = (channel?: NotificationChannel | null): HomeAssis
};
const Notifications = () => {
const unlocked = useOperatorSession();
const unlocked = usePermission("notifications.manage");
const [searchParams, setSearchParams] = useSearchParams();
const query = (searchParams.get("query") || "").trim();
const [summary, setSummary] = useState<NotificationSummary | null>(null);
+3 -2
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { metricRefreshInterval } from "../lib/systemLoadChartView.ts";
const source = readFileSync(new URL("./Overview.tsx", import.meta.url), "utf8");
const appDetailSource = readFileSync(new URL("./AppDetail.tsx", import.meta.url), "utf8");
@@ -12,7 +13,6 @@ test("overview uses live statistics instead of hardcoded chart data", () => {
assert.match(source, /buildSystemLoadChartData/);
assert.match(systemLoadChartSource, /cpuHistory/);
assert.match(systemLoadChartSource, /ramHistory/);
assert.match(systemLoadChartSource, /Vlakke trend uit huidige live sample/);
});
test("overview does not show setup-complete cards when there are no setup problems", () => {
@@ -28,7 +28,8 @@ test("overview uses independent live queries so one failed source does not blank
});
test("overview refreshes fast metrics separately from connector-heavy intelligence", () => {
assert.match(source, /statistics-summary[\s\S]*refetchInterval: 10_000/);
assert.equal(metricRefreshInterval("visible"), 5_000);
assert.equal(metricRefreshInterval("hidden"), false);
assert.match(source, /system-summary[\s\S]*refetchInterval: 10_000/);
assert.match(source, /safety-dashboard[\s\S]*refetchInterval: 60_000/);
assert.match(source, /storage-summary[\s\S]*refetchInterval: 60_000/);
+32 -18
View File
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { appopsClient } from "@/api/appopsClient";
import { StatusChip } from "@/components/StatusChip";
@@ -6,7 +6,7 @@ import { RiskBadge } from "@/components/RiskBadge";
import { cn } from "@/lib/utils";
import { selectTopSafetyIssue } from "@/lib/overviewTopIssue";
import { buildOperatorActionQueue, type OperatorActionTone } from "@/lib/operatorActionQueue";
import { buildSystemLoadChartData, formatLoadPercent } from "@/lib/systemLoadChartView";
import { buildSystemLoadChartData, formatSampleTime, metricRefreshInterval } from "@/lib/systemLoadChartView";
import { formatUptimeLabel } from "@/lib/systemSummaryView";
import {
Activity,
@@ -45,6 +45,8 @@ const queueToneClass: Record<OperatorActionTone, string> = {
clear: "border-emerald-500/30 bg-emerald-500/10 text-emerald-100",
};
const overviewCardLinkClass = "block h-full rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950";
const formatLastAction = (value: string) => {
const [action, reference] = value.split(" -> ");
return {
@@ -54,8 +56,9 @@ const formatLastAction = (value: string) => {
};
const Overview = () => {
const [loadWindowMinutes, setLoadWindowMinutes] = useState(15);
const summaryQuery = useQuery({ queryKey: ['system-summary'], queryFn: () => appopsClient.getSystemSummary(), refetchInterval: 10_000 });
const statisticsQuery = useQuery({ queryKey: ['statistics-summary'], queryFn: () => appopsClient.getStatisticsSummary(), refetchInterval: 10_000 });
const statisticsQuery = useQuery({ queryKey: ['statistics-summary'], queryFn: () => appopsClient.getStatisticsSummary(), refetchInterval: () => metricRefreshInterval(document.visibilityState) });
const appsQuery = useQuery({ queryKey: ['apps', 'compact'], queryFn: () => appopsClient.getApps(), refetchInterval: 30_000 });
const incidentsQuery = useQuery({ queryKey: ['incidents'], queryFn: () => appopsClient.getIncidents(), refetchInterval: 30_000 });
const auditQuery = useQuery({ queryKey: ['audit', 'recent'], queryFn: () => appopsClient.getAuditLogs(5), refetchInterval: 30_000 });
@@ -86,7 +89,7 @@ const Overview = () => {
const safety = safetyDashboard?.summary ?? null;
const topIssue = useMemo(() => selectTopSafetyIssue(safetyDashboard?.findings ?? []), [safetyDashboard]);
const topIssueHref = topIssue?.suggestedActions.find((action) => action.href)?.href ?? "/safety";
const systemLoadChart = useMemo(() => buildSystemLoadChartData(statistics), [statistics]);
const systemLoadChart = useMemo(() => buildSystemLoadChartData(statistics, loadWindowMinutes), [statistics, loadWindowMinutes]);
const operatorQueue = useMemo(() => buildOperatorActionQueue({
setup: setupStatus,
safety: safetyDashboard,
@@ -110,10 +113,6 @@ const Overview = () => {
const healthyApps = apps.filter(a => a.status === 'healthy').length;
const appHealthPercent = apps.length ? Math.round((healthyApps / apps.length) * 100) : 0;
const chartData = systemLoadChart.points;
const currentCpu = systemLoadChart.currentCpu;
const currentRam = systemLoadChart.currentRam;
const currentCpuLabel = formatLoadPercent(currentCpu);
const currentRamLabel = formatLoadPercent(currentRam);
const openIncidents = incidents.filter((incident) => incident.status === "open");
const criticalSafetyCount = safety?.criticalCount ?? 0;
const highSafetyCount = safety?.highCount ?? 0;
@@ -305,7 +304,8 @@ const Overview = () => {
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
<Card>
<Link to="/unraid" aria-label="Server Health openen in Unraid" className={overviewCardLinkClass}>
<Card className="h-full transition-colors hover:border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="data-label text-slate-500">Server Health</p>
@@ -320,8 +320,10 @@ const Overview = () => {
</div>
</CardContent>
</Card>
</Link>
<Card>
<Link to="/apps" aria-label="App Status openen in Apps" className={overviewCardLinkClass}>
<Card className="h-full transition-colors hover:border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="data-label text-slate-500">App Status</p>
@@ -336,8 +338,10 @@ const Overview = () => {
</div>
</CardContent>
</Card>
</Link>
<Card>
<Link to="/storage" aria-label="Storage Usage openen in Storage & Volumes" className={overviewCardLinkClass}>
<Card className="h-full transition-colors hover:border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="data-label text-slate-500">Storage Usage</p>
@@ -352,8 +356,10 @@ const Overview = () => {
</div>
</CardContent>
</Card>
</Link>
<Card>
<Link to="/incidents" aria-label="Incidenten openen" className={overviewCardLinkClass}>
<Card className="h-full transition-colors hover:border-blue-500/40">
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<p className="data-label text-slate-500">Incidenten</p>
@@ -370,6 +376,7 @@ const Overview = () => {
</div>
</CardContent>
</Card>
</Link>
<Link to="/safety">
<Card className="h-full transition-colors hover:border-blue-500/40">
@@ -400,9 +407,8 @@ const Overview = () => {
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400 flex items-center gap-2">
<Cpu className="w-4 h-4 text-blue-500" /> Live Systeem Belasting
</CardTitle>
<div className="flex flex-wrap gap-2">
<span className="rounded border border-blue-500/20 bg-blue-500/10 px-2 py-1 text-[10px] font-bold uppercase text-blue-100">CPU {currentCpuLabel}</span>
<span className="rounded border border-violet-500/20 bg-violet-500/10 px-2 py-1 text-[10px] font-bold uppercase text-violet-100">RAM {currentRamLabel}</span>
<div className="flex gap-1 rounded-lg border border-slate-800 bg-slate-950 p-1" role="group" aria-label="Tijdsvenster belastinggrafiek">
{[5, 15, 30].map(minutes => <button key={minutes} type="button" onClick={() => setLoadWindowMinutes(minutes)} aria-pressed={loadWindowMinutes === minutes} className={cn("rounded px-2.5 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-blue-400", loadWindowMinutes === minutes ? "bg-slate-800 text-white" : "text-slate-500 hover:text-slate-200")}>{minutes} min</button>)}
</div>
</CardHeader>
<CardContent className="w-full space-y-2">
@@ -415,7 +421,13 @@ const Overview = () => {
</div>
)}
</div>
<p className="mt-2 text-[10px] font-medium text-slate-500">{systemLoadChart.caption}</p>
<p className="mt-2 text-[10px] font-medium text-slate-500">{systemLoadChart.caption} Verversing elke 5 seconden.</p>
<div className="flex flex-wrap gap-x-5 gap-y-1 text-[11px] text-slate-500">
{statistics?.gpu?.devices.map(device => <span key={device.id} className="text-amber-200/70">{device.name}{device.temperatureC !== null ? ` · ${device.temperatureC.toLocaleString('nl-BE')} °C` : ''}{device.memoryUsedMiB !== null && device.memoryTotalMiB !== null ? ` · ${(device.memoryUsedMiB / 1024).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} / ${(device.memoryTotalMiB / 1024).toLocaleString('nl-BE', { maximumFractionDigits: 1 })} GiB VRAM` : ''}</span>)}
{!statistics?.gpu?.devices.length && <span>GPU-meting niet beschikbaar.</span>}
{(statistics?.gpu?.devices.length ?? 0) > 1 && <span>GPU-lijn toont de hoogst belaste GPU.</span>}
{Boolean(statistics?.gpu?.devices.length) && statistics?.gpu?.receivedAt && <span>GPU opgehaald om {formatSampleTime(Date.parse(statistics.gpu.receivedAt))}.</span>}
</div>
</CardContent>
</Card>
@@ -461,9 +473,10 @@ const Overview = () => {
<div className="min-w-0 space-y-6">
{audit.length > 0 && (
<Card className="bg-slate-900/50 border-slate-800">
<Link to="/audit" aria-label="Recente Activiteit openen in Audit Log" className={overviewCardLinkClass}>
<Card className="bg-slate-900/50 border-slate-800 transition-colors hover:border-blue-500/40">
<CardHeader>
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Recente Activiteit</CardTitle>
<CardTitle className="flex items-center justify-between gap-2 text-sm font-bold uppercase tracking-wider text-slate-400">Recente Activiteit <ArrowUpRight className="h-4 w-4 text-blue-400" aria-hidden="true" /></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6">
@@ -487,6 +500,7 @@ const Overview = () => {
</div>
</CardContent>
</Card>
</Link>
)}
{unreadNotifications > 0 && (
+71 -11
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
AlertTriangle,
@@ -62,6 +62,7 @@ const Safety = () => {
const [query, setQuery] = useState("");
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionFeedback, setActionFeedback] = useState<SafetyActionFeedback | null>(null);
const findingsRegionRef = useRef<HTMLDivElement>(null);
const load = async () => {
const dashboardData = await appopsClient.getSafetyDashboard();
@@ -108,6 +109,28 @@ const Safety = () => {
const openRecommendations = recommendations.filter(item => item.status === "open");
const categories = Array.from(new Set(findings.map(item => item.category))).sort();
const hasFilters = severityFilter !== "all" || categoryFilter !== "all" || query.trim() !== "";
const resetFilters = () => {
setSeverityFilter("all");
setCategoryFilter("all");
setQuery("");
};
const selectSummarySeverity = (severity: SafetySeverity) => {
setSeverityFilter(current => current === severity ? "all" : severity);
setCategoryFilter("all");
setQuery("");
requestAnimationFrame(() => {
const region = findingsRegionRef.current;
if (!region) return;
region.focus({ preventScroll: true });
region.scrollIntoView({
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
block: "start",
});
});
};
const refresh = async () => {
if (refreshing) return;
@@ -252,10 +275,10 @@ const Safety = () => {
</div>
)}
<Progress value={summary.score} className="h-2 bg-slate-950" />
<div className="grid grid-cols-3 gap-3">
<Metric label="Critical" value={summary.criticalCount} tone="text-rose-300" />
<Metric label="High" value={summary.highCount} tone="text-orange-300" />
<Metric label="Medium" value={summary.mediumCount} tone="text-amber-300" />
<div role="group" aria-label="Filter findings op ernst" className="grid grid-cols-3 gap-3">
<SeverityMetric label="Critical" value={summary.criticalCount} tone="text-rose-300" selected={severityFilter === "critical"} onClick={() => selectSummarySeverity("critical")} />
<SeverityMetric label="High" value={summary.highCount} tone="text-orange-300" selected={severityFilter === "high"} onClick={() => selectSummarySeverity("high")} />
<SeverityMetric label="Medium" value={summary.mediumCount} tone="text-amber-300" selected={severityFilter === "medium"} onClick={() => selectSummarySeverity("medium")} />
</div>
</div>
</CardContent>
@@ -336,25 +359,39 @@ const Safety = () => {
</div>
<div className="grid gap-8 xl:grid-cols-[1.6fr_1fr]">
<Card className="border-slate-800 bg-slate-900/50">
<Card
ref={findingsRegionRef}
id="safety-findings"
role="region"
aria-labelledby="safety-findings-heading"
tabIndex={-1}
className="scroll-mt-6 border-slate-800 bg-slate-900/50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/50"
>
<CardHeader className="space-y-4">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-400">
<CardTitle id="safety-findings-heading" className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-400">
<ShieldAlert className="h-4 w-4 text-amber-400" /> Findings
</CardTitle>
<Badge variant="outline" className="border-slate-700 text-slate-300">{filteredFindings.length} open</Badge>
<div className="flex flex-wrap items-center justify-end gap-2">
{hasFilters && (
<Button size="sm" variant="ghost" className="text-blue-300 hover:text-blue-200" onClick={resetFilters}>Filters wissen</Button>
)}
<Badge role="status" variant="outline" className="border-slate-700 text-slate-300">
{filteredFindings.length} open{severityFilter !== "all" ? ` · ${severityFilter}` : ""}
</Badge>
</div>
</div>
<div className="grid gap-3 md:grid-cols-[1fr_160px_180px]">
<Input value={query} onChange={event => setQuery(event.target.value)} placeholder="Zoek findings" className="border-slate-800 bg-slate-950 text-slate-100" />
<Input aria-label="Zoek findings" value={query} onChange={event => setQuery(event.target.value)} placeholder="Zoek findings" className="border-slate-800 bg-slate-950 text-slate-100" />
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="border-slate-800 bg-slate-950 text-slate-200"><SelectValue /></SelectTrigger>
<SelectTrigger aria-label="Filter op ernst" className="border-slate-800 bg-slate-950 text-slate-200"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle severity</SelectItem>
{["critical", "high", "medium", "low", "info"].map(value => <SelectItem key={value} value={value}>{value}</SelectItem>)}
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="border-slate-800 bg-slate-950 text-slate-200"><SelectValue /></SelectTrigger>
<SelectTrigger aria-label="Filter op categorie" className="border-slate-800 bg-slate-950 text-slate-200"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all">Alle categorieen</SelectItem>
{categories.map(value => <SelectItem key={value} value={value}>{value}</SelectItem>)}
@@ -363,6 +400,11 @@ const Safety = () => {
</div>
</CardHeader>
<CardContent className="space-y-4">
{filteredFindings.length === 0 && (
<p className="rounded-lg border border-slate-800 bg-slate-950 p-4 text-sm text-slate-400">
{hasFilters ? "Geen open findings voor deze filters. Wis de filters om alle open findings te bekijken." : "Geen open findings. Er zijn momenteel geen open waarschuwingen."}
</p>
)}
{filteredFindings.map(finding => (
<div key={finding.id} className="rounded-lg border border-slate-800 bg-slate-950 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
@@ -472,6 +514,24 @@ const Metric = ({ label, value, tone }: { label: string; value: number; tone?: s
</div>
);
const SeverityMetric = ({ label, value, tone, selected, onClick }: { label: string; value: number; tone: string; selected: boolean; onClick: () => void }) => (
<button
type="button"
aria-label={`${label}: ${value} open findings`}
aria-pressed={selected}
aria-controls="safety-findings"
onClick={onClick}
className={cn(
"rounded-md border p-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900 motion-reduce:transition-none",
selected ? "border-blue-400/70 bg-blue-500/10 ring-1 ring-blue-400/50" : "border-slate-800 bg-slate-950 hover:border-slate-600 hover:bg-slate-800",
)}
>
<span className={cn("block text-[10px] font-bold uppercase tracking-wider", selected ? "text-blue-200" : "text-slate-400")}>{label}</span>
<span className={cn("mt-1 block text-xl font-black", tone)}>{value}</span>
<span className={cn("mt-2 block text-[10px]", selected ? "text-blue-200" : "text-slate-500")}>{selected ? "Filter actief" : "Toon findings"}</span>
</button>
);
const QuickAction = ({ to, label }: { to: string; label: string }) => (
<Button asChild variant="outline" className="justify-between border-slate-800 bg-slate-950 text-slate-200">
<Link to={to}>{label}<ArrowUpRight className="h-4 w-4" /></Link>
+11 -83
View File
@@ -11,11 +11,12 @@ import { DeepHealth, RuntimeInfo, SqliteBackupsResponse, SqliteMaintenanceStatus
import { Link, useSearchParams } from "react-router-dom";
import { buildSettingsRuntimeView } from "@/lib/settingsRuntimeView";
import { formatOperatorError } from "@/lib/operatorError";
import { setOperatorSessionStatus } from "@/api/operatorSession";
import { Input } from "@/components/ui/input";
import { useOperatorSessionDetails } from "@/hooks/useOperatorSession";
import { usePermission } from "@/hooks/useUserSession";
import { UserAccessSettings } from "@/components/UserAccessSettings";
import { ExposureSettings } from "@/components/ExposureSettings";
import { ManagedAiSettings } from "@/components/ManagedAiSettings";
const settingsTabs = ["general", "security", "notifications", "audit", "voice", "maintenance", "backups"] as const;
const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const;
type SettingsTab = typeof settingsTabs[number];
const Settings = () => {
@@ -29,12 +30,7 @@ const Settings = () => {
const [backupBusy, setBackupBusy] = useState(false);
const [verifyingBackup, setVerifyingBackup] = useState<string | null>(null);
const [verifiedBackups, setVerifiedBackups] = useState<Record<string, string>>({});
const [operatorTokenDraft, setOperatorTokenDraft] = useState("");
const [operatorSessionBusy, setOperatorSessionBusy] = useState(false);
const [operatorAuthRequired, setOperatorAuthRequired] = useState(false);
const [operatorAuthBusy, setOperatorAuthBusy] = useState(false);
const operatorSession = useOperatorSessionDetails();
const operatorUnlocked = operatorSession.unlocked;
const operatorUnlocked = usePermission("settings.manage");
const requestedTab = searchParams.get("tab");
const activeTab: SettingsTab = settingsTabs.includes(requestedTab as SettingsTab) ? requestedTab as SettingsTab : "general";
@@ -70,9 +66,6 @@ const Settings = () => {
useEffect(() => {
void loadRuntime();
loadBackups();
void appopsClient.getOperatorAuthPolicy()
.then(policy => setOperatorAuthRequired(policy.required))
.catch(error => setRuntimeError(previous => previous || formatSettingsError(error)));
}, []);
const downloadExport = async (kind: 'config' | 'policies' | 'inventory' | 'audit-summary') => {
@@ -156,50 +149,6 @@ const Settings = () => {
const runtimeView = buildSettingsRuntimeView(runtime, deepHealth);
const unlockOperatorSession = async () => {
setOperatorSessionBusy(true);
try {
const session = await appopsClient.unlockOperatorSession(operatorTokenDraft);
setOperatorSessionStatus(session);
setOperatorTokenDraft("");
showSuccess("Operatorsessie ontgrendeld voor deze browsersessie.");
} catch (error) {
showError(formatSettingsError(error));
} finally {
setOperatorSessionBusy(false);
}
};
const lockOperatorSession = async () => {
setOperatorSessionBusy(true);
try {
const session = await appopsClient.lockOperatorSession();
setOperatorSessionStatus(session);
setOperatorTokenDraft("");
showSuccess("Lokale operatorsessie vergrendeld.");
} catch (error) {
showError(formatSettingsError(error));
} finally {
setOperatorSessionBusy(false);
}
};
const toggleOperatorAuth = async (required: boolean) => {
setOperatorAuthBusy(true);
try {
const policy = await appopsClient.updateOperatorAuthPolicy(required);
setOperatorAuthRequired(policy.required);
const session = await appopsClient.getOperatorSession();
setOperatorSessionStatus(session);
showSuccess(policy.required ? "Operatorsessie ingeschakeld." : "Operatorsessie uitgeschakeld; lokaal beheer is direct beschikbaar.");
await loadRuntime();
} catch (error) {
showError(formatSettingsError(error));
} finally {
setOperatorAuthBusy(false);
}
};
return (
<div className="space-y-6">
<div className="app-page-header">
@@ -235,6 +184,7 @@ const Settings = () => {
<TabsList className="inline-flex w-auto border-slate-800 bg-slate-950">
<TabsTrigger value="general">Algemeen</TabsTrigger>
<TabsTrigger value="security">Beveiliging</TabsTrigger>
<TabsTrigger value="ai">AI & ModelForge</TabsTrigger>
<TabsTrigger value="notifications">Notificaties</TabsTrigger>
<TabsTrigger value="audit">Audit</TabsTrigger>
<TabsTrigger value="voice">Voice Bridge</TabsTrigger>
@@ -250,7 +200,7 @@ const Settings = () => {
</CardHeader>
<CardContent className="space-y-6">
<div className="border-y border-blue-500/20 bg-blue-500/5 py-4 text-sm text-blue-200">
Deze pagina toont productie-instellingen en veilige beheerlinks. V1 wijzigt geen runtime-, taal-, retentie- of maintenancebeleid vanuit losse UI-toggles; echte configuratie loopt via Setup, Policies, Notifications, Voice Bridge en server-side deploymentconfig.
Deze pagina toont het actieve runtimeprofiel. Beheer gebruikers en exposurecontroles bij Beveiliging, modeladvies bij AI & ModelForge, en connectoren via Setup. Policies, notificaties en Voice Bridge hebben hun eigen beheerpagina’s.
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
@@ -298,37 +248,15 @@ const Settings = () => {
</Card>
</TabsContent>
<TabsContent value="ai"><ManagedAiSettings /></TabsContent>
<TabsContent value="security">
<Card className="bg-slate-900/50 border-slate-800">
<CardHeader>
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Beveiligingsbeleid</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
<div className="rounded-lg border border-blue-500/20 bg-blue-500/5 p-4">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-bold text-slate-100">Operatorsessie vereisen</p>
<p className="mt-1 text-xs text-slate-400">Optionele extra toegangsslot voor mutaties en adminroutes. Standaard uit; policies, approvals en geblokkeerde destructieve acties blijven actief.</p>
</div>
<Switch checked={operatorAuthRequired} onCheckedChange={checked => void toggleOperatorAuth(checked)} disabled={operatorAuthBusy} aria-label="Operatorsessie vereisen" />
</div>
{!operatorAuthRequired ? (
<p className="mt-3 text-xs font-bold text-emerald-300">Uitgeschakeld — lokaal beheer is direct beschikbaar</p>
) : operatorSession.mode === "account" ? (
<p className="mt-1 text-xs text-slate-300">Account <span className="font-mono text-emerald-300">{operatorSession.identity || "aangemeld"}</span> is automatisch als operator geautoriseerd. Afmelden bij de identity-provider beëindigt deze toegang.</p>
) : (
<>
<p className="mt-1 text-xs text-slate-400">Fallback voor lokale toegang: het token wordt éénmalig ingewisseld voor een HttpOnly browsersessie. De sleutel wordt niet in JavaScript- of browseropslag bewaard en de sessie vervalt bij sluiten, vergrendelen, tokenrotatie of na acht uur.</p>
<div className="mt-3 flex flex-col gap-2 sm:flex-row">
<Label htmlFor="operator-token" className="sr-only">Operator token</Label>
<Input id="operator-token" type="password" autoComplete="off" value={operatorTokenDraft} onChange={event => setOperatorTokenDraft(event.target.value)} placeholder="APPOPS_OPERATOR_TOKEN" className="border-slate-800 bg-slate-950" />
<Button type="button" onClick={() => void unlockOperatorSession()} disabled={!operatorTokenDraft.trim() || operatorSessionBusy} className="bg-blue-600 text-white hover:bg-blue-500">Ontgrendel</Button>
<Button type="button" variant="outline" onClick={() => void lockOperatorSession()} disabled={operatorSession.mode !== "session" || operatorSessionBusy} className="border-slate-800 bg-slate-950">Vergrendel</Button>
</div>
</>
)}
{operatorAuthRequired ? <p className={`mt-2 text-xs font-bold ${operatorUnlocked ? "text-emerald-300" : "text-amber-300"}`}>{operatorUnlocked ? "Operatorsessie actief" : "Mutaties en adminroutes zijn vergrendeld"}</p> : null}
</div>
<UserAccessSettings />
<ExposureSettings />
<div className="flex items-center justify-between p-4 rounded-lg bg-slate-950 border border-slate-800">
<div className="space-y-0.5">
<Label className="text-sm font-bold text-slate-200">Strict Policy Mode</Label>
+22 -6
View File
@@ -9,15 +9,18 @@ import { cn } from "@/lib/utils";
import { RiskBadge } from "@/components/RiskBadge";
import { PolicyBadge } from "@/components/PolicyAction";
import { getAiChatSuggestedActionNotice } from "@/lib/aiChatView";
import { formatOperatorError } from "@/lib/operatorError";
import { Link } from "react-router-dom";
const AIChat = () => {
const [messages, setMessages] = useState<AIChatMessage[]>([]);
const [input, setInput] = useState("");
const [isTyping, setIsTyping] = useState(false);
const [error, setError] = useState("");
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
appopsClient.getAIChatHistory().then(setMessages);
appopsClient.getAIChatHistory().then(setMessages).catch(err => setError(formatOperatorError(err, "Gespreksgeschiedenis laden mislukt.")));
}, []);
useEffect(() => {
@@ -27,7 +30,8 @@ const AIChat = () => {
}, [messages, isTyping]);
const handleSend = async () => {
if (!input.trim()) return;
if (!input.trim() || isTyping) return;
setError("");
const userMsg: AIChatMessage = {
id: Date.now().toString(),
@@ -40,9 +44,16 @@ const AIChat = () => {
setInput("");
setIsTyping(true);
const response = await appopsClient.sendAIChatMessage(userMsg.content);
setMessages(prev => [...prev, response]);
setIsTyping(false);
try {
const response = await appopsClient.sendAIChatMessage(userMsg.content);
setMessages(prev => [...prev, response]);
} catch (err) {
setError(formatOperatorError(err, "AI-antwoord ophalen mislukt. Controleer de ModelForge-configuratie."));
setInput(userMsg.content);
setMessages(prev => prev.filter(message => message.id !== userMsg.id));
} finally {
setIsTyping(false);
}
};
return (
@@ -53,7 +64,7 @@ const AIChat = () => {
<h2 className="flex items-center gap-2">
<Brain className="w-6 h-6 text-blue-500" /> AI Analyst Chat
</h2>
<p className="mt-1 text-sm text-slate-500">Evidence-based antwoorden over je homelabstatus.</p>
<p className="mt-1 text-sm text-slate-500">ModelForge-advies met broncontext over je homelabstatus.</p>
</div>
<div className="metric-capsule text-slate-400">
Read-only advisory
@@ -86,6 +97,7 @@ const AIChat = () => {
{msg.metadata && (
<div className="grid grid-cols-1 gap-3 animate-in zoom-in-95 duration-500 delay-200">
{msg.metadata.model && <p className="break-words text-xs text-slate-500">ModelForge · {msg.metadata.model} · revisie {msg.metadata.modelRevision}{msg.metadata.contextTruncated ? " · Context gedeeltelijk" : ""}</p>}
{msg.metadata.evidence && (
<div className="p-3 rounded-lg bg-blue-600/5 border border-blue-500/10">
<p className="text-[10px] font-bold text-blue-500 uppercase mb-1 flex items-center gap-1">
@@ -154,10 +166,14 @@ const AIChat = () => {
)}
</div>
{error && <div role="alert" className="mt-3 rounded-md border border-rose-500/20 bg-rose-500/5 p-3 text-sm text-rose-200">{error} <Link to="/settings?tab=ai" className="ml-1 underline">AI-instellingen</Link></div>}
<div className="signal-rail relative mt-5 rounded-md border border-slate-700 bg-slate-900 p-2 pl-3">
<div className="flex items-center">
<Input
value={input}
maxLength={2048}
disabled={isTyping}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="Stel een vraag over je homelab..."
+8 -1
View File
@@ -113,9 +113,16 @@ const AIDashboard = () => {
<Card className="signal-rail overflow-hidden border-slate-700 bg-slate-900">
<CardContent className="p-6 pl-7">
<h3 className="mb-3 flex items-center gap-2 text-base font-bold text-blue-300">
<Sparkles className="h-5 w-5" /> Daily AI Summary
<Sparkles className="h-5 w-5" /> Security-overzicht uit vaste controles
</h3>
<p className="max-w-5xl text-sm leading-7 text-slate-200">{summary.dailySummary}</p>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3 border-t border-slate-800 pt-4 text-xs text-slate-400">
<div>
<p className={summary.inference?.ready ? "text-emerald-300" : "text-amber-300"}>ModelForge tekstadvies: {summary.inference?.ready ? "beschikbaar" : summary.inference?.state || "status onbekend"}</p>
{summary.inference?.lastInference && <p className="mt-1 break-words">Laatst gebruikt model: {summary.inference.lastInference.model} · {summary.inference.lastInference.revision}</p>}
</div>
<Button asChild variant="outline"><Link to="/settings?tab=ai">AI & ModelForge beheren</Link></Button>
</div>
</CardContent>
</Card>
+11 -1
View File
@@ -1,4 +1,4 @@
import { ActionID, RiskLevel, PolicyDecision, Status, SourceType, ActionPlan, RagCitation } from './appops';
import { ActionID, RiskLevel, PolicyDecision, Status, SourceType, ActionPlan, RagCitation, ManagedAiStatus } from './appops';
export type AIAutonomyLevel = 0 | 1 | 2 | 3 | 4 | 5;
@@ -27,6 +27,14 @@ export interface AIChatMessage {
nextStep?: string;
citations?: RagCitation[];
confidence?: string;
provider?: 'modelforge';
capability?: string;
model?: string;
modelRevision?: string;
deploymentId?: string;
requestId?: string;
artifactDigest?: string;
contextTruncated?: boolean;
};
}
@@ -68,4 +76,6 @@ export interface AISummary {
storageForecast: string;
securityScore: number;
autopilotStatus: 'active' | 'paused' | 'off';
inference?: ManagedAiStatus;
recommendationEngine?: 'deterministic_policy_evidence';
}
+78 -7
View File
@@ -234,6 +234,50 @@ export interface IntegrationStatus {
} | null;
};
}
export interface ExposurePreferences {
nginxAccessListChecksEnabled: boolean;
}
export interface ManagedAiConfig {
enabled: boolean;
gatewayUrl: string;
capability: 'assistant.ops@1';
credentialConfigured: boolean;
managedBy: 'ModelForge';
}
export interface ManagedAiInference {
content?: string;
model: string;
revision: string;
deploymentId: string;
requestId: string;
artifactDigest: string;
latencyMs?: number;
observedAt?: string;
contextTruncated?: boolean;
}
export interface ManagedAiStatus extends ManagedAiConfig {
provider: 'modelforge';
state: 'not_configured' | 'ready' | 'not_deployed' | 'unverified' | 'unavailable';
ready: boolean;
readOnly: true;
projectKey?: string;
deploymentId?: string;
model?: string;
revision?: string;
errorCode?: string;
correlationId?: string | null;
lastInference: ManagedAiInference | null;
}
export interface ManagedAiConfigUpdate {
enabled: boolean;
gatewayUrl: string;
credential?: string;
}
export type SourceType = 'voice' | 'manual' | 'automation' | 'system';
export type MaintenanceState = 'active' | 'inactive';
export type ConnectorStatus = 'connected' | 'disconnected' | 'error' | 'missing_config' | 'configured' | 'none';
@@ -597,6 +641,22 @@ export interface StatisticsSummary {
sourceWarnings?: Array<{ source: string; surface: string; status: string; reason: string }>;
cpuHistory: Array<{ time: string; value: number | null }>;
ramHistory: Array<{ time: string; value: number | null }>;
gpuHistory?: Array<{ time: string; value: number | null }>;
gpu?: {
status: string;
source?: string;
observedAt?: string | null;
receivedAt?: string;
reason?: string | null;
devices: Array<{
id: string;
name: string;
utilizationPercent: number | null;
memoryUsedMiB: number | null;
memoryTotalMiB: number | null;
temperatureC: number | null;
}>;
};
restartsByApp: Array<{ appId: string; count: number }>;
blockedActions: number;
voiceActionCount: number;
@@ -853,15 +913,26 @@ export interface RuntimeInfo {
productionWarnings: string[];
}
export interface OperatorSessionStatus {
authorized: boolean;
mode: 'locked' | 'token' | 'session' | 'account' | 'private';
identity?: string | null;
export type UserRole = 'admin' | 'operator' | 'read_only' | 'custom';
export type UserPermission = 'read' | 'actions.execute' | 'inventory.manage' | 'approvals.manage' | 'connectors.manage' | 'policies.manage' | 'settings.manage' | 'notifications.manage' | 'voice.use' | 'users.manage';
export interface GatewayUser {
id: string;
provider: 'authentik' | 'entra';
subject: string;
email: string;
displayName: string;
role: UserRole;
enabled: boolean;
permissions: UserPermission[];
}
export interface OperatorAuthPolicy {
required: boolean;
export interface UserAuthSession {
authenticated: boolean;
user: GatewayUser | null;
permissions: UserPermission[];
}
export interface IdentityProvider { id: 'authentik' | 'entra'; label: string; configured: boolean }
export interface PermissionDefinition { id: UserPermission; label: string }
export interface UserAccessUpdate { role: UserRole; enabled: boolean; permissions: UserPermission[] }
export interface SqliteBackupMetadata {
name: string;