Publish curated Unraid-AppOps-Gateway source from 034c9f844d2455850ba37b31379b7edaa786583e
This commit is contained in:
1 parent
c61fbfd87f
commit
1a54836efc
101 files changed
+4973
-1298
No files matched your search
+34
-21
@@ -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);
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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; } }
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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)),
|
||||
});
|
||||
@@ -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
@@ -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)));
|
||||
});
|
||||
};
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 },
|
||||
})
|
||||
)),
|
||||
|
||||
@@ -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 }]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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..."
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user