Publish curated Unraid-AppOps-Gateway source from ad0a390e431166c3198a2ac3bec010c2b44f2dea
This commit is contained in:
1 parent
29c1e3d5a1
commit
98d31477f9
35 files changed
+7264
-185
No files matched your search
@@ -4,7 +4,7 @@ import { AISummary, AIRecommendation, AIChatMessage, AIAutopilotRule, AITool } f
|
||||
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, GatewayUser, IdentityProvider, PermissionDefinition, UserAccessUpdate, UserAuthSession, RagQueryResult, RagStatus, RunnerFleetStatus } from '../types/appops';
|
||||
import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, WorkMeshPromotion } from '../types/appops';
|
||||
import type { DeliveryStatus, DeploymentJob, Enrollment, EnrollmentCapabilities, EnrollmentStorageRootSelection, EnrollmentTrustStatus, WorkMeshPromotion } from '../types/appops';
|
||||
import type { ExposurePreferences, ManagedAiConfig, ManagedAiConfigUpdate, ManagedAiStatus, ManagedAiInference } from '../types/appops';
|
||||
|
||||
type HealthStatus = {
|
||||
@@ -372,6 +372,10 @@ export const createLiveClient = () => ({
|
||||
},
|
||||
async getDeliveryStatus(): Promise<DeliveryStatus> { return get<DeliveryStatus>('/api/deployments/delivery-status', defaultConfig); },
|
||||
async getEnrollmentCapabilities(): Promise<EnrollmentCapabilities> { return get<EnrollmentCapabilities>('/api/enrollments/capabilities', defaultConfig); },
|
||||
async getEnrollmentTrustStatus(): Promise<EnrollmentTrustStatus> { return get<EnrollmentTrustStatus>('/api/enrollments/trust', defaultConfig); },
|
||||
async updateEnrollmentStorageRoot(payload: EnrollmentStorageRootSelection): Promise<EnrollmentTrustStatus> {
|
||||
return patch<EnrollmentTrustStatus>('/api/enrollments/trust/storage-root', payload, defaultConfig);
|
||||
},
|
||||
async getEnrollments(): Promise<{ items: Enrollment[] }> { return get<{ items: Enrollment[] }>('/api/enrollments', defaultConfig); },
|
||||
async getSystemSummary(): Promise<SystemSummary> { return get<SystemSummary>('/api/system/summary', defaultConfig); },
|
||||
async getHealth(): Promise<HealthStatus> { return get<HealthStatus>('/api/health', defaultConfig); },
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AlertCircle, CheckCircle2, Database, LoaderCircle, RefreshCw, ShieldAlert } from 'lucide-react';
|
||||
import { appopsClient } from '@/api/appopsClient';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { formatOperatorError } from '@/lib/operatorError';
|
||||
import {
|
||||
buildEnrollmentStorageView,
|
||||
buildStorageRootSelection,
|
||||
canSaveStorageRootSelection,
|
||||
canRequestManagedStorageInitialization,
|
||||
normalizeManagedStoragePreview,
|
||||
normalizeManagedStorageActionOutcome,
|
||||
LEGACY_APPDATA_ROOT,
|
||||
MANAGED_APPDATA_ROOT,
|
||||
MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
MANAGED_STORAGE_TARGET,
|
||||
managedStorageBlockerLabel,
|
||||
managedStorageStatusLabel,
|
||||
rootReadinessLabel,
|
||||
} from '@/lib/enrollmentStorageView';
|
||||
import { useUserSession } from '@/hooks/useUserSession';
|
||||
import type { ActionPlan, EnrollmentCapabilities, EnrollmentTrustStatus, PreflightPreview, TrustedAppdataRoot } from '@/types/appops';
|
||||
|
||||
interface StorageSnapshot {
|
||||
trust: EnrollmentTrustStatus;
|
||||
capabilities: EnrollmentCapabilities;
|
||||
view: ReturnType<typeof buildEnrollmentStorageView>;
|
||||
preview: PreflightPreview | null;
|
||||
previewError: string | null;
|
||||
}
|
||||
|
||||
interface StorageDraft {
|
||||
root: TrustedAppdataRoot;
|
||||
backupAcknowledged: boolean;
|
||||
}
|
||||
|
||||
const labelForRoot = (root: TrustedAppdataRoot) => (
|
||||
root === LEGACY_APPDATA_ROOT ? 'Unraid appdata share' : 'Dedicated managed Docker-root volume'
|
||||
);
|
||||
|
||||
const isConflict = (error: unknown): boolean => (
|
||||
error instanceof Error && /Backend 409:/.test(error.message)
|
||||
);
|
||||
|
||||
export const ManagedStorageSettings = () => {
|
||||
const session = useUserSession();
|
||||
const isAdmin = session.ready && session.authenticated && session.user?.role === 'admin';
|
||||
const canManage = isAdmin && session.permissions.includes('settings.manage');
|
||||
const [snapshot, setSnapshot] = useState<StorageSnapshot | null>(null);
|
||||
const [draft, setDraft] = useState<StorageDraft>({ root: LEGACY_APPDATA_ROOT, backupAcknowledged: false });
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [saveError, setSaveError] = useState('');
|
||||
const [conflict, setConflict] = useState('');
|
||||
const [saveNotice, setSaveNotice] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initializing, setInitializing] = useState(false);
|
||||
const [actionNotice, setActionNotice] = useState<{ tone: 'status' | 'alert'; message: string } | null>(null);
|
||||
const requestSequence = useRef(0);
|
||||
const snapshotRef = useRef<StorageSnapshot | null>(null);
|
||||
const draftRef = useRef(draft);
|
||||
|
||||
const updateDraft = (next: StorageDraft) => {
|
||||
draftRef.current = next;
|
||||
setDraft(next);
|
||||
};
|
||||
|
||||
const load = useCallback(async (options: { preserveDraft?: boolean } = {}) => {
|
||||
if (!canManage) return;
|
||||
const sequence = ++requestSequence.current;
|
||||
setRefreshing(true);
|
||||
setLoadError('');
|
||||
try {
|
||||
const [trust, capabilities] = await Promise.all([
|
||||
appopsClient.getEnrollmentTrustStatus(),
|
||||
appopsClient.getEnrollmentCapabilities(),
|
||||
]);
|
||||
const view = buildEnrollmentStorageView(trust, capabilities);
|
||||
let preview: PreflightPreview | null = null;
|
||||
let previewError: string | null = null;
|
||||
if (view.managedStatus === 'initialization_required') {
|
||||
try {
|
||||
const rawPreview: unknown = await appopsClient.previewAction(MANAGED_STORAGE_INITIALIZE_ACTION, MANAGED_STORAGE_TARGET);
|
||||
preview = normalizeManagedStoragePreview(rawPreview);
|
||||
if (!preview) previewError = 'De policy-preview had geen geldig formaat; de actie blijft geblokkeerd.';
|
||||
} catch (error) {
|
||||
previewError = formatOperatorError(error, 'De policy-preview is niet beschikbaar.');
|
||||
}
|
||||
}
|
||||
if (sequence !== requestSequence.current) return;
|
||||
|
||||
const previous = snapshotRef.current;
|
||||
const currentDraft = draftRef.current;
|
||||
const draftChanged = previous !== null && (
|
||||
currentDraft.root !== previous.view.currentRoot ||
|
||||
currentDraft.backupAcknowledged !== previous.view.backupAcknowledged
|
||||
);
|
||||
const nextSnapshot = { trust, capabilities, view, preview, previewError };
|
||||
snapshotRef.current = nextSnapshot;
|
||||
setSnapshot(nextSnapshot);
|
||||
if (!options.preserveDraft && !draftChanged && view.currentRoot) {
|
||||
updateDraft({ root: view.currentRoot, backupAcknowledged: view.backupAcknowledged });
|
||||
}
|
||||
if (!options.preserveDraft) setConflict('');
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setLoadError(formatOperatorError(error, 'Opslaginstellingen konden niet worden geladen.'));
|
||||
} finally {
|
||||
if (sequence === requestSequence.current) setRefreshing(false);
|
||||
}
|
||||
}, [canManage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (canManage) void load();
|
||||
}, [canManage, load]);
|
||||
|
||||
if (!isAdmin) return null;
|
||||
|
||||
const view = snapshot?.view;
|
||||
const selectedDraft = draft.root;
|
||||
const draftIsManaged = selectedDraft === MANAGED_APPDATA_ROOT;
|
||||
const dirty = Boolean(view?.currentRoot && (
|
||||
draft.root !== view.currentRoot ||
|
||||
(draftIsManaged && draft.backupAcknowledged !== view.backupAcknowledged)
|
||||
));
|
||||
const requestPayload = buildStorageRootSelection(draft.root, draft.backupAcknowledged);
|
||||
const saveDisabled = !canSaveStorageRootSelection({
|
||||
canManage,
|
||||
configured: Boolean(snapshot?.view.configured),
|
||||
payloadAvailable: Boolean(requestPayload),
|
||||
dirty,
|
||||
loadError,
|
||||
refreshing,
|
||||
saving,
|
||||
initializing,
|
||||
});
|
||||
const initializationGate = {
|
||||
canManage,
|
||||
view,
|
||||
preview: snapshot?.preview,
|
||||
dirty,
|
||||
loadError,
|
||||
conflict,
|
||||
refreshing,
|
||||
saving,
|
||||
initializing,
|
||||
};
|
||||
const initializeAllowed = canRequestManagedStorageInitialization(initializationGate);
|
||||
|
||||
const save = async () => {
|
||||
if (!canManage || !requestPayload || saveDisabled) return;
|
||||
const sequence = ++requestSequence.current;
|
||||
setSaving(true);
|
||||
setSaveError('');
|
||||
setConflict('');
|
||||
setSaveNotice('');
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const updatedTrust = await appopsClient.updateEnrollmentStorageRoot(requestPayload);
|
||||
if (sequence !== requestSequence.current) return;
|
||||
const current = snapshotRef.current;
|
||||
if (current) {
|
||||
const updatedView = buildEnrollmentStorageView(updatedTrust, current.capabilities);
|
||||
const updatedSnapshot = { ...current, trust: updatedTrust, view: updatedView };
|
||||
snapshotRef.current = updatedSnapshot;
|
||||
setSnapshot(updatedSnapshot);
|
||||
if (updatedView.currentRoot) updateDraft({ root: updatedView.currentRoot, backupAcknowledged: updatedView.backupAcknowledged });
|
||||
}
|
||||
setSaveNotice('Storagevoorkeur opgeslagen. De controller-readiness wordt opnieuw gecontroleerd; bestaande targets blijven ongewijzigd.');
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
if (isConflict(error)) {
|
||||
setConflict('De trustconfiguratie is intussen gewijzigd. De actuele status wordt geladen; jouw keuze blijft als concept bewaard. Controleer de selectie en sla opnieuw op als die nog klopt.');
|
||||
await load({ preserveDraft: true });
|
||||
} else {
|
||||
setSaveError(formatOperatorError(error, 'De storagevoorkeur is niet opgeslagen. Jouw concept blijft behouden.'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initialize = async () => {
|
||||
if (!snapshot?.preview || !view || !canRequestManagedStorageInitialization(initializationGate)) return;
|
||||
const preview = snapshot.preview;
|
||||
const plan: ActionPlan = {
|
||||
intent: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
source: 'manual',
|
||||
risk: preview.risk,
|
||||
policy: preview.policyDecision,
|
||||
reason: preview.auditPreview,
|
||||
plan: preview.preChecks,
|
||||
postChecks: preview.postChecks,
|
||||
};
|
||||
setInitializing(true);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result: unknown = await appopsClient.executeAction(plan);
|
||||
setActionNotice(normalizeManagedStorageActionOutcome(result));
|
||||
await load({ preserveDraft: true });
|
||||
} catch (error) {
|
||||
setActionNotice({ tone: 'alert', message: formatOperatorError(error, 'Het initialisatieverzoek is niet bevestigd.') });
|
||||
} finally {
|
||||
setInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-slate-700 bg-slate-900/50" aria-busy={refreshing || saving || initializing}>
|
||||
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="flex items-center gap-2 text-sm font-bold uppercase tracking-wider text-slate-300">
|
||||
<Database className="h-4 w-4 shrink-0 text-blue-400" /> Opslag voor nieuwe targets
|
||||
</CardTitle>
|
||||
<p className="mt-2 max-w-3xl text-sm leading-6 text-slate-400">
|
||||
Kies de vertrouwde opslagroot voor toekomstige registraties. Dit wijzigt of verplaatst geen bestaande targetregistraties.
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="w-full shrink-0 border-slate-700 bg-slate-950 text-slate-200 sm:w-auto" onClick={() => void load()} disabled={!canManage || refreshing || saving || initializing}>
|
||||
{refreshing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <RefreshCw className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{refreshing ? 'Status vernieuwen…' : 'Vernieuw status'}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
{!canManage && (
|
||||
<p className="text-sm text-slate-400">Alleen een beheerder met de instellingentoestemming kan deze opslagkeuze bekijken of wijzigen.</p>
|
||||
)}
|
||||
{canManage && refreshing && !snapshot && <p role="status" aria-live="polite" className="text-sm text-slate-400">Opslagstatus laden…</p>}
|
||||
{canManage && loadError && (
|
||||
<div role="alert" className="flex items-start gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<p>{snapshot ? `De laatste opslagstatus kon niet worden vernieuwd. De waarden hieronder zijn de laatst succesvol gelezen gegevens. ${loadError}` : loadError}</p>
|
||||
{!snapshot && <Button type="button" variant="outline" className="mt-3 border-amber-500/30" onClick={() => void load()}>Opnieuw proberen</Button>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{snapshot && view && canManage && (
|
||||
<>
|
||||
<div className="grid min-w-0 gap-3 rounded-lg border border-slate-800 bg-slate-950/70 p-3 sm:grid-cols-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Opgeslagen root</p>
|
||||
<p className="mt-1 break-all text-sm text-slate-100">{view.currentRoot || 'Niet bevestigd door de API'}</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{view.currentRoot ? labelForRoot(view.currentRoot) : 'De huidige trustconfiguratie kan niet veilig worden gewijzigd.'}</p>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Truststatus</p>
|
||||
<p className="mt-1 text-sm text-slate-100">
|
||||
{view.trustStatus === 'ready' ? 'Configuratie gelezen' : view.trustStatus === 'blocked' ? 'Trustconfiguratie heeft andere blockers' : view.trustStatus === 'needs_setup' ? 'Trustconfiguratie ontbreekt' : 'Status onbekend'}
|
||||
</p>
|
||||
{view.trustStatus === 'needs_setup' && <p className="mt-1 text-xs text-amber-200">Rond de trustconfiguratie eerst af via Setup.</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.trustStatus === 'needs_setup' && (
|
||||
<div className="rounded-lg border border-amber-500/20 bg-amber-500/5 p-3 text-sm text-amber-100">Deze beperkte kaart kan geen volledige trustconfiguratie maken.</div>
|
||||
)}
|
||||
|
||||
{snapshot.view.configured && (
|
||||
<fieldset disabled={saving || refreshing || initializing} className="min-w-0 space-y-3 disabled:opacity-80">
|
||||
<legend className="text-sm font-semibold text-slate-200">Vertrouwde opslagroot</legend>
|
||||
<RadioGroup
|
||||
aria-label="Vertrouwde opslagroot voor toekomstige targetregistraties"
|
||||
value={draft.root}
|
||||
onValueChange={(value) => {
|
||||
if (value !== LEGACY_APPDATA_ROOT && value !== MANAGED_APPDATA_ROOT) return;
|
||||
setSaveNotice('');
|
||||
setConflict('');
|
||||
updateDraft({ root: value, backupAcknowledged: false });
|
||||
}}
|
||||
className="grid min-w-0 gap-3 md:grid-cols-2"
|
||||
>
|
||||
<label htmlFor="appdata-root-legacy" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-legacy" value={LEGACY_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(LEGACY_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{LEGACY_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[LEGACY_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
<label htmlFor="appdata-root-managed" className="flex min-w-0 cursor-pointer gap-3 rounded-lg border border-slate-700 bg-slate-950/70 p-3 has-[:disabled]:cursor-not-allowed">
|
||||
<RadioGroupItem id="appdata-root-managed" value={MANAGED_APPDATA_ROOT} className="mt-1 shrink-0" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold text-slate-100">{labelForRoot(MANAGED_APPDATA_ROOT)}</span>
|
||||
<code className="mt-1 block break-all text-xs text-slate-400">{MANAGED_APPDATA_ROOT}</code>
|
||||
<span className="mt-2 block text-xs text-slate-300">{rootReadinessLabel(view.rootReadiness[MANAGED_APPDATA_ROOT])}</span>
|
||||
</span>
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
)}
|
||||
|
||||
{draftIsManaged && snapshot.view.configured && (
|
||||
<section aria-labelledby="managed-root-backup-title" className="space-y-3 rounded-lg border border-amber-500/25 bg-amber-500/5 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-300" aria-hidden="true" />
|
||||
<div className="min-w-0">
|
||||
<h3 id="managed-root-backup-title" className="text-sm font-semibold text-amber-100">Back-upverantwoordelijkheid voor DockerRootDir</h3>
|
||||
<p className="mt-1 break-words text-sm leading-6 text-amber-100/80">
|
||||
Deze vaste root ligt onder <code className="break-all">/var/lib/docker</code> en kan deel uitmaken van <code>docker.img</code>. Gebruikelijke back-ups van <code>/srv/example/appdata</code> nemen dit niet mee. Beheer en herstel van DockerRootDir vallen onder jouw back-upplan.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-start gap-3 rounded-md border border-amber-500/20 bg-slate-950/60 p-3">
|
||||
<Checkbox
|
||||
id="managed-root-backup-ack"
|
||||
checked={draft.backupAcknowledged}
|
||||
onCheckedChange={(checked) => {
|
||||
setSaveNotice('');
|
||||
setConflict('');
|
||||
updateDraft({ ...draftRef.current, backupAcknowledged: checked === true });
|
||||
}}
|
||||
disabled={saving || refreshing || initializing}
|
||||
aria-describedby="managed-root-backup-copy"
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<Label htmlFor="managed-root-backup-ack" id="managed-root-backup-copy" className="cursor-pointer text-sm leading-6 text-slate-200">
|
||||
Ik bevestig dat reguliere appdata-back-ups DockerRootDir en <code>docker.img</code> niet dekken en dat ik daarvoor afzonderlijk back-up- en herstelbeheer regel.
|
||||
</Label>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Controller-readiness van de beheerde volume</p>
|
||||
<p className="mt-1 text-sm text-slate-100">{managedStorageStatusLabel(view.managedStatus)}</p>
|
||||
{managedStorageBlockerLabel(view.managedBlocker) && <p className="mt-1 text-xs leading-5 text-amber-200">{managedStorageBlockerLabel(view.managedBlocker)}</p>}
|
||||
{!view.managedCapabilityValid && <p className="mt-1 text-xs text-amber-200">De controller leverde geen geldige readinessbevestiging; acties blijven geblokkeerd.</p>}
|
||||
<p className="mt-2 text-xs leading-5 text-slate-400">De rootkeuze geldt voor toekomstige registraties. Bestaande targets blijven aan hun eerder vastgelegde root gekoppeld en worden niet verplaatst.</p>
|
||||
</div>
|
||||
|
||||
{view.managedStatus === 'initialization_required' && (
|
||||
<div className="space-y-3 rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-100">Eenmalige controlleractie</p>
|
||||
<p className="mt-1 text-xs leading-5 text-slate-400">Alleen beschikbaar als de exacte handmatige high-risk policy-preview dit toestaat. Een aanvraag is geen bewijs dat de volume klaar is.</p>
|
||||
</div>
|
||||
{snapshot.previewError && <p role="alert" className="text-sm text-amber-200">Policy-preview niet beschikbaar: {snapshot.previewError}</p>}
|
||||
{snapshot.preview && (
|
||||
<div className="space-y-2 rounded-md border border-slate-800 p-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-400">Policy: {snapshot.preview.policyDecision === 'allowed' ? 'Toegestaan' : snapshot.preview.policyDecision === 'approval_required' ? 'Approval vereist' : 'Geblokkeerd'}</p>
|
||||
<p className="break-words text-sm text-slate-200">{snapshot.preview.auditPreview}</p>
|
||||
{snapshot.preview.preChecks.length > 0 && (
|
||||
<ul className="list-disc space-y-1 pl-5 text-xs text-slate-400">
|
||||
{snapshot.preview.preChecks.map((check, index) => <li key={`${index}-${check}`} className="break-words">{check}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{(snapshot.preview.action !== MANAGED_STORAGE_INITIALIZE_ACTION || snapshot.preview.target !== MANAGED_STORAGE_TARGET) && (
|
||||
<p role="alert" className="text-xs text-rose-200">De preview bevestigt niet de vaste actie en controllerdoel; uitvoering blijft geblokkeerd.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{initializeAllowed && (
|
||||
<Button type="button" onClick={() => void initialize()} disabled={initializing || saving || refreshing} className="w-full sm:w-auto">
|
||||
{initializing ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : <ShieldAlert className="mr-2 h-4 w-4" aria-hidden="true" />}
|
||||
{initializing ? 'Actieverzoek indienen…' : 'Vraag volume-initialisatie aan'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{actionNotice && (
|
||||
<div role={actionNotice.tone === 'alert' ? 'alert' : 'status'} aria-live={actionNotice.tone === 'status' ? 'polite' : undefined} className={`rounded-lg border p-3 text-sm ${actionNotice.tone === 'alert' ? 'border-amber-500/30 bg-amber-500/5 text-amber-100' : 'border-blue-500/25 bg-blue-500/5 text-blue-100'}`}>
|
||||
{actionNotice.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{conflict && <div role="alert" className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-sm text-amber-100">{conflict}</div>}
|
||||
{saveError && <div role="alert" className="rounded-lg border border-rose-500/30 bg-rose-500/5 p-3 text-sm text-rose-100">{saveError}</div>}
|
||||
{saveNotice && <div role="status" aria-live="polite" className="flex items-start gap-2 rounded-lg border border-emerald-500/25 bg-emerald-500/5 p-3 text-sm text-emerald-100"><CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />{saveNotice}</div>}
|
||||
|
||||
{view.configured && canManage && (
|
||||
<div className="flex flex-col gap-3 border-t border-slate-800 pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-xs text-slate-400">Opslaan wijzigt alleen de trustinstelling voor volgende registraties.</p>
|
||||
<Button type="button" onClick={() => void save()} disabled={saveDisabled} className="w-full sm:w-auto">
|
||||
{saving ? <LoaderCircle className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> : null}
|
||||
{saving ? 'Opslaan…' : dirty ? 'Opslagkeuze opslaan' : 'Geen wijzigingen'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
buildEnrollmentStorageView,
|
||||
buildStorageRootSelection,
|
||||
canInitializeManagedStorage,
|
||||
canRequestManagedStorageInitialization,
|
||||
canSaveStorageRootSelection,
|
||||
LEGACY_APPDATA_ROOT,
|
||||
MANAGED_APPDATA_ROOT,
|
||||
MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
MANAGED_STORAGE_TARGET,
|
||||
normalizeManagedStoragePreview,
|
||||
normalizeManagedStorageActionOutcome,
|
||||
} from './enrollmentStorageView.ts';
|
||||
|
||||
const baseTrust = {
|
||||
configured: true,
|
||||
status: 'ready',
|
||||
trustedAppdataRoot: MANAGED_APPDATA_ROOT,
|
||||
managedAppdataBackupAcknowledged: true,
|
||||
};
|
||||
const baseCapabilities = {
|
||||
availableTrustedAppdataRoots: [
|
||||
{ root: LEGACY_APPDATA_ROOT, status: 'ready' },
|
||||
{ root: MANAGED_APPDATA_ROOT, status: 'needs_initialization' },
|
||||
],
|
||||
managedStorage: {
|
||||
status: 'initialization_required',
|
||||
blockerCode: 'managed_appdata_initialization_required',
|
||||
root: MANAGED_APPDATA_ROOT,
|
||||
backupScope: 'dockerRootDir',
|
||||
backupAcknowledged: true,
|
||||
},
|
||||
};
|
||||
|
||||
test('malformed or unknown roots fail closed without echoing untrusted paths', () => {
|
||||
const view = buildEnrollmentStorageView({ ...baseTrust, trustedAppdataRoot: '/tmp/unknown-secret-path' }, {
|
||||
...baseCapabilities,
|
||||
availableTrustedAppdataRoots: [{ root: '/tmp/private', status: 'ready' }, ...baseCapabilities.availableTrustedAppdataRoots],
|
||||
});
|
||||
|
||||
assert.equal(view.currentRoot, null);
|
||||
assert.equal(view.configured, false);
|
||||
assert.equal(view.rootReadiness[LEGACY_APPDATA_ROOT], 'unavailable');
|
||||
assert.equal(view.rootReadiness[MANAGED_APPDATA_ROOT], 'unavailable');
|
||||
assert.equal(view.managedStatus, 'unavailable');
|
||||
assert.equal(view.managedCapabilityValid, false);
|
||||
});
|
||||
|
||||
test('readiness and saved selection fail closed when root evidence or acknowledgement contradicts', () => {
|
||||
const unavailableManagedRoot = buildEnrollmentStorageView(baseTrust, {
|
||||
...baseCapabilities,
|
||||
availableTrustedAppdataRoots: [
|
||||
{ root: LEGACY_APPDATA_ROOT, status: 'ready' },
|
||||
{ root: MANAGED_APPDATA_ROOT, status: 'unavailable' },
|
||||
],
|
||||
managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' },
|
||||
});
|
||||
assert.equal(unavailableManagedRoot.managedStatus, 'unavailable');
|
||||
assert.equal(unavailableManagedRoot.managedCapabilityValid, false);
|
||||
|
||||
const contradictoryManagedAcknowledgement = buildEnrollmentStorageView(baseTrust, {
|
||||
...baseCapabilities,
|
||||
managedStorage: { ...baseCapabilities.managedStorage, backupAcknowledged: false },
|
||||
});
|
||||
assert.equal(contradictoryManagedAcknowledgement.configured, false);
|
||||
assert.equal(contradictoryManagedAcknowledgement.currentRoot, null);
|
||||
assert.equal(contradictoryManagedAcknowledgement.trustStatus, 'unknown');
|
||||
assert.equal(contradictoryManagedAcknowledgement.managedStatus, 'unavailable');
|
||||
|
||||
const malformedRootEvidence = buildEnrollmentStorageView(baseTrust, {
|
||||
...baseCapabilities,
|
||||
availableTrustedAppdataRoots: [{ root: LEGACY_APPDATA_ROOT, status: 'ready' }],
|
||||
managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' },
|
||||
});
|
||||
assert.equal(malformedRootEvidence.managedStatus, 'unavailable');
|
||||
});
|
||||
|
||||
test('managed-root choice requires explicit backup acknowledgement and sends only the two contract fields', () => {
|
||||
assert.equal(buildStorageRootSelection(MANAGED_APPDATA_ROOT, false), null);
|
||||
assert.deepEqual(buildStorageRootSelection(MANAGED_APPDATA_ROOT, true), {
|
||||
trusted_appdata_root: MANAGED_APPDATA_ROOT,
|
||||
managed_appdata_backup_acknowledged: true,
|
||||
});
|
||||
assert.deepEqual(buildStorageRootSelection(LEGACY_APPDATA_ROOT, true), {
|
||||
trusted_appdata_root: LEGACY_APPDATA_ROOT,
|
||||
managed_appdata_backup_acknowledged: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('a failed refresh disables a dirty storage-root draft from being saved', () => {
|
||||
const gate = {
|
||||
canManage: true,
|
||||
configured: true,
|
||||
payloadAvailable: true,
|
||||
dirty: true,
|
||||
loadError: '',
|
||||
refreshing: false,
|
||||
saving: false,
|
||||
initializing: false,
|
||||
};
|
||||
|
||||
assert.equal(canSaveStorageRootSelection(gate), true);
|
||||
assert.equal(canSaveStorageRootSelection({ ...gate, loadError: 'Refresh failed; showing last-known configuration.' }), false);
|
||||
assert.equal(canSaveStorageRootSelection({ ...gate, refreshing: true }), false);
|
||||
assert.equal(canSaveStorageRootSelection({ ...gate, initializing: true }), false);
|
||||
});
|
||||
|
||||
test('initialization requires selected managed root, capability evidence, exact high-risk action and allowed manual policy', () => {
|
||||
const view = buildEnrollmentStorageView(baseTrust, baseCapabilities);
|
||||
const preview = {
|
||||
action: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
risk: 'high',
|
||||
policyDecision: 'allowed',
|
||||
};
|
||||
|
||||
assert.equal(canInitializeManagedStorage(view, preview), true);
|
||||
assert.equal(canInitializeManagedStorage(view, { ...preview, policyDecision: 'blocked' }), false);
|
||||
assert.equal(canInitializeManagedStorage(view, { ...preview, target: 'tower/other' }), false);
|
||||
assert.equal(canInitializeManagedStorage(view, { ...preview, risk: 'low' }), false);
|
||||
assert.equal(canInitializeManagedStorage(view, { ...view, policyDecision: 'allowed' }), false);
|
||||
assert.equal(canInitializeManagedStorage(
|
||||
buildEnrollmentStorageView({ ...baseTrust, managedAppdataBackupAcknowledged: false }, baseCapabilities),
|
||||
preview,
|
||||
), false);
|
||||
assert.equal(canInitializeManagedStorage(
|
||||
buildEnrollmentStorageView(baseTrust, { ...baseCapabilities, managedStorage: { ...baseCapabilities.managedStorage, status: 'ready' } }),
|
||||
preview,
|
||||
), false);
|
||||
});
|
||||
|
||||
test('initialization stays disabled during stale refresh, conflict recovery, dirty drafts and mutations', () => {
|
||||
const view = buildEnrollmentStorageView(baseTrust, baseCapabilities);
|
||||
const gate = {
|
||||
canManage: true,
|
||||
view,
|
||||
preview: {
|
||||
action: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
risk: 'high',
|
||||
policyDecision: 'allowed',
|
||||
},
|
||||
dirty: false,
|
||||
loadError: '',
|
||||
conflict: '',
|
||||
refreshing: false,
|
||||
saving: false,
|
||||
initializing: false,
|
||||
};
|
||||
|
||||
assert.equal(canRequestManagedStorageInitialization(gate), true);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, loadError: 'Refresh failed; showing last known data.' }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, conflict: 'The saved selection changed.' }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, dirty: true }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, refreshing: true }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, saving: true }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, initializing: true }), false);
|
||||
assert.equal(canRequestManagedStorageInitialization({ ...gate, canManage: false }), false);
|
||||
});
|
||||
|
||||
test('malformed runtime policy preview is rejected before UI uses its arrays or text', () => {
|
||||
assert.equal(normalizeManagedStoragePreview({
|
||||
action: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
risk: 'high',
|
||||
policyDecision: 'allowed',
|
||||
auditPreview: 'Initialize managed storage',
|
||||
affectedApps: [],
|
||||
dependencies: [],
|
||||
preChecks: 'not-an-array',
|
||||
postChecks: [],
|
||||
}), null);
|
||||
assert.equal(normalizeManagedStoragePreview({
|
||||
action: MANAGED_STORAGE_INITIALIZE_ACTION,
|
||||
target: MANAGED_STORAGE_TARGET,
|
||||
risk: 'high',
|
||||
policyDecision: 'allowed',
|
||||
auditPreview: 42,
|
||||
affectedApps: [],
|
||||
dependencies: [],
|
||||
preChecks: [],
|
||||
postChecks: [],
|
||||
}), null);
|
||||
});
|
||||
|
||||
test('malformed initializer results never produce a success notice or stringify arbitrary messages', () => {
|
||||
const malformedResults: unknown[] = [
|
||||
{ success: true, jobId: {}, auditId: 'audit-1', message: 'Started' },
|
||||
{ success: true, jobId: 'job-1', auditId: ' ', message: 'Started' },
|
||||
{ success: true, jobId: `${' '.repeat(201)}job-1`, auditId: 'audit-1', message: 'Started' },
|
||||
{ success: true, jobId: 'job-1', auditId: `${' '.repeat(201)}audit-1`, message: 'Started' },
|
||||
{ success: true, jobId: 'job-1', auditId: 'audit-1', message: { detail: 'Started' } },
|
||||
{ success: true, jobId: 'job-1', auditId: 'audit-1' },
|
||||
{ success: 'true', jobId: 'job-1', auditId: 'audit-1', message: 'Started' },
|
||||
{ success: false, message: { detail: 'Denied' } },
|
||||
];
|
||||
|
||||
for (const result of malformedResults) {
|
||||
assert.deepEqual(normalizeManagedStorageActionOutcome(result), {
|
||||
tone: 'alert',
|
||||
message: 'Het initialisatieverzoek is niet bevestigd met geldige referenties.',
|
||||
});
|
||||
}
|
||||
|
||||
assert.deepEqual(normalizeManagedStorageActionOutcome({
|
||||
success: true,
|
||||
jobId: 'job-123',
|
||||
auditId: 'audit-456',
|
||||
message: 'Accepted',
|
||||
}), {
|
||||
tone: 'status',
|
||||
message: 'Initialisatieverzoek geregistreerd. Job job-123; audit audit-456. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,277 @@
|
||||
import type {
|
||||
PreflightPreview,
|
||||
EnrollmentStorageRootSelection,
|
||||
ManagedStorageStatus,
|
||||
TrustedAppdataRoot,
|
||||
TrustedAppdataRootReadinessStatus,
|
||||
} from '@/types/appops';
|
||||
|
||||
export const LEGACY_APPDATA_ROOT: TrustedAppdataRoot = '/srv/example/appdata';
|
||||
export const MANAGED_APPDATA_ROOT: TrustedAppdataRoot = '/var/lib/docker/volumes/itworx-managed-appdata/_data';
|
||||
export const MANAGED_STORAGE_INITIALIZE_ACTION = 'controller.managed-storage.initialize';
|
||||
export const MANAGED_STORAGE_TARGET = 'tower/deployment-controller';
|
||||
|
||||
export interface EnrollmentStorageView {
|
||||
configured: boolean;
|
||||
trustStatus: 'ready' | 'blocked' | 'needs_setup' | 'unknown';
|
||||
currentRoot: TrustedAppdataRoot | null;
|
||||
backupAcknowledged: boolean;
|
||||
rootReadiness: Record<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>;
|
||||
managedStatus: ManagedStorageStatus;
|
||||
managedBlocker: string | null;
|
||||
managedCapabilityValid: boolean;
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => (
|
||||
typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
);
|
||||
|
||||
const isRoot = (value: unknown): value is TrustedAppdataRoot => (
|
||||
value === LEGACY_APPDATA_ROOT || value === MANAGED_APPDATA_ROOT
|
||||
);
|
||||
|
||||
const isRootReadiness = (value: unknown): value is TrustedAppdataRootReadinessStatus => (
|
||||
value === 'ready' || value === 'needs_initialization' || value === 'unavailable'
|
||||
);
|
||||
|
||||
const isManagedStatus = (value: unknown): value is ManagedStorageStatus => (
|
||||
value === 'not_selected' || value === 'blocked' || value === 'ready' || value === 'initialization_required' || value === 'unavailable'
|
||||
);
|
||||
|
||||
export const buildEnrollmentStorageView = (trust: unknown, capabilities: unknown): EnrollmentStorageView => {
|
||||
const safeTrust = isRecord(trust) ? trust : {};
|
||||
const safeCapabilities = isRecord(capabilities) ? capabilities : {};
|
||||
const trustedRoot = isRoot(safeTrust.trustedAppdataRoot) ? safeTrust.trustedAppdataRoot : null;
|
||||
const managed = isRecord(safeCapabilities.managedStorage) ? safeCapabilities.managedStorage : null;
|
||||
const reportedRoots = safeCapabilities.availableTrustedAppdataRoots;
|
||||
let rootReadiness: Record<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus> = {
|
||||
[LEGACY_APPDATA_ROOT]: 'unavailable',
|
||||
[MANAGED_APPDATA_ROOT]: 'unavailable',
|
||||
};
|
||||
let rootsValid = false;
|
||||
|
||||
if (Array.isArray(reportedRoots) && reportedRoots.length === 2) {
|
||||
const entries = new Map<TrustedAppdataRoot, TrustedAppdataRootReadinessStatus>();
|
||||
const valid = reportedRoots.every((entry) => {
|
||||
if (!isRecord(entry) || !isRoot(entry.root) || !isRootReadiness(entry.status) || entries.has(entry.root)) return false;
|
||||
entries.set(entry.root, entry.status);
|
||||
return true;
|
||||
});
|
||||
if (valid && entries.has(LEGACY_APPDATA_ROOT) && entries.has(MANAGED_APPDATA_ROOT)) {
|
||||
rootsValid = true;
|
||||
rootReadiness = {
|
||||
[LEGACY_APPDATA_ROOT]: entries.get(LEGACY_APPDATA_ROOT)!,
|
||||
[MANAGED_APPDATA_ROOT]: entries.get(MANAGED_APPDATA_ROOT)!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const backupAcknowledged = safeTrust.managedAppdataBackupAcknowledged;
|
||||
const trustSelectionConsistent = typeof backupAcknowledged === 'boolean' && (
|
||||
trustedRoot === MANAGED_APPDATA_ROOT
|
||||
? backupAcknowledged && managed?.root === MANAGED_APPDATA_ROOT && managed.backupAcknowledged === backupAcknowledged
|
||||
: trustedRoot === LEGACY_APPDATA_ROOT && !backupAcknowledged
|
||||
);
|
||||
const managedStatusConsistent = Boolean(
|
||||
managed && (
|
||||
(managed.status !== 'ready' || rootReadiness[MANAGED_APPDATA_ROOT] === 'ready') &&
|
||||
(managed.status !== 'initialization_required' || rootReadiness[MANAGED_APPDATA_ROOT] === 'needs_initialization') &&
|
||||
(trustedRoot !== MANAGED_APPDATA_ROOT || managed.status !== 'not_selected') &&
|
||||
(trustedRoot !== MANAGED_APPDATA_ROOT || managed.backupAcknowledged === backupAcknowledged)
|
||||
)
|
||||
);
|
||||
const managedCapabilityValid = Boolean(
|
||||
rootsValid &&
|
||||
trustSelectionConsistent &&
|
||||
managed &&
|
||||
managed.root === MANAGED_APPDATA_ROOT &&
|
||||
managed.backupScope === 'dockerRootDir' &&
|
||||
typeof managed.backupAcknowledged === 'boolean' &&
|
||||
isManagedStatus(managed.status) &&
|
||||
managedStatusConsistent,
|
||||
);
|
||||
const rawTrustStatus = safeTrust.status === 'ready' || safeTrust.status === 'blocked' || safeTrust.status === 'needs_setup'
|
||||
? safeTrust.status
|
||||
: 'unknown';
|
||||
const configured = safeTrust.configured === true && trustedRoot !== null && trustSelectionConsistent;
|
||||
const trustStatus = rawTrustStatus === 'needs_setup' || rawTrustStatus === 'blocked'
|
||||
? rawTrustStatus
|
||||
: configured && rawTrustStatus === 'ready' ? 'ready' : 'unknown';
|
||||
|
||||
return {
|
||||
configured,
|
||||
trustStatus,
|
||||
currentRoot: configured ? trustedRoot : null,
|
||||
backupAcknowledged: backupAcknowledged === true,
|
||||
rootReadiness,
|
||||
managedStatus: managedCapabilityValid ? managed!.status as ManagedStorageStatus : 'unavailable',
|
||||
managedBlocker: managedCapabilityValid && typeof managed!.blockerCode === 'string' && managed!.blockerCode.length <= 100
|
||||
? managed!.blockerCode || null
|
||||
: null,
|
||||
managedCapabilityValid,
|
||||
};
|
||||
};
|
||||
|
||||
const isBoundedStringArray = (value: unknown): value is string[] => (
|
||||
Array.isArray(value) && value.length <= 100 && value.every((item) => typeof item === 'string' && item.length <= 500)
|
||||
);
|
||||
|
||||
export const normalizeManagedStoragePreview = (value: unknown): PreflightPreview | null => {
|
||||
if (!isRecord(value)) return null;
|
||||
const rollbackAvailable = value.rollbackAvailable;
|
||||
if (
|
||||
typeof value.action !== 'string' || value.action.length > 120 ||
|
||||
typeof value.target !== 'string' || value.target.length > 200 ||
|
||||
(value.risk !== 'low' && value.risk !== 'medium' && value.risk !== 'high' && value.risk !== 'critical') ||
|
||||
(value.policyDecision !== 'allowed' && value.policyDecision !== 'approval_required' && value.policyDecision !== 'blocked') ||
|
||||
typeof value.auditPreview !== 'string' || value.auditPreview.length > 2000 ||
|
||||
!isBoundedStringArray(value.affectedApps) ||
|
||||
!isBoundedStringArray(value.dependencies) ||
|
||||
!isBoundedStringArray(value.preChecks) ||
|
||||
!isBoundedStringArray(value.postChecks) ||
|
||||
(rollbackAvailable !== undefined && typeof rollbackAvailable !== 'boolean')
|
||||
) return null;
|
||||
|
||||
const normalizedRollbackAvailable: boolean | undefined = typeof rollbackAvailable === 'boolean'
|
||||
? rollbackAvailable
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
action: value.action,
|
||||
target: value.target,
|
||||
risk: value.risk,
|
||||
policyDecision: value.policyDecision,
|
||||
auditPreview: value.auditPreview,
|
||||
affectedApps: value.affectedApps,
|
||||
dependencies: value.dependencies,
|
||||
preChecks: value.preChecks,
|
||||
postChecks: value.postChecks,
|
||||
...(normalizedRollbackAvailable === undefined ? {} : { rollbackAvailable: normalizedRollbackAvailable }),
|
||||
};
|
||||
};
|
||||
|
||||
export const buildStorageRootSelection = (
|
||||
root: TrustedAppdataRoot,
|
||||
backupAcknowledged: boolean,
|
||||
): EnrollmentStorageRootSelection | null => {
|
||||
if (root === MANAGED_APPDATA_ROOT && !backupAcknowledged) return null;
|
||||
return {
|
||||
trusted_appdata_root: root,
|
||||
managed_appdata_backup_acknowledged: root === MANAGED_APPDATA_ROOT,
|
||||
};
|
||||
};
|
||||
|
||||
export const canInitializeManagedStorage = (
|
||||
view: EnrollmentStorageView,
|
||||
preview: unknown,
|
||||
): boolean => (
|
||||
view.configured &&
|
||||
view.currentRoot === MANAGED_APPDATA_ROOT &&
|
||||
view.backupAcknowledged &&
|
||||
view.managedCapabilityValid &&
|
||||
view.managedStatus === 'initialization_required' &&
|
||||
isRecord(preview) &&
|
||||
preview.action === MANAGED_STORAGE_INITIALIZE_ACTION &&
|
||||
preview.target === MANAGED_STORAGE_TARGET &&
|
||||
preview.risk === 'high' &&
|
||||
preview.policyDecision === 'allowed'
|
||||
);
|
||||
|
||||
export interface ManagedStorageInitializationGate {
|
||||
canManage: boolean;
|
||||
view: EnrollmentStorageView | null | undefined;
|
||||
preview: unknown;
|
||||
dirty: boolean;
|
||||
loadError: string;
|
||||
conflict: string;
|
||||
refreshing: boolean;
|
||||
saving: boolean;
|
||||
initializing: boolean;
|
||||
}
|
||||
|
||||
export const canRequestManagedStorageInitialization = (gate: ManagedStorageInitializationGate): boolean => (
|
||||
gate.canManage &&
|
||||
Boolean(gate.view && canInitializeManagedStorage(gate.view, gate.preview)) &&
|
||||
!gate.dirty &&
|
||||
!gate.loadError &&
|
||||
!gate.conflict &&
|
||||
!gate.refreshing &&
|
||||
!gate.saving &&
|
||||
!gate.initializing
|
||||
);
|
||||
|
||||
export interface StorageRootSaveGate {
|
||||
canManage: boolean;
|
||||
configured: boolean;
|
||||
payloadAvailable: boolean;
|
||||
dirty: boolean;
|
||||
loadError: string;
|
||||
refreshing: boolean;
|
||||
saving: boolean;
|
||||
initializing: boolean;
|
||||
}
|
||||
|
||||
export const canSaveStorageRootSelection = (gate: StorageRootSaveGate): boolean => (
|
||||
gate.canManage &&
|
||||
gate.configured &&
|
||||
gate.payloadAvailable &&
|
||||
gate.dirty &&
|
||||
!gate.loadError &&
|
||||
!gate.refreshing &&
|
||||
!gate.saving &&
|
||||
!gate.initializing
|
||||
);
|
||||
|
||||
export interface ManagedStorageActionOutcome {
|
||||
tone: 'status' | 'alert';
|
||||
message: string;
|
||||
}
|
||||
|
||||
const UNCONFIRMED_INITIALIZATION_MESSAGE = 'Het initialisatieverzoek is niet bevestigd met geldige referenties.';
|
||||
const isBoundedIdentifier = (value: unknown): value is string => (
|
||||
typeof value === 'string' && value.trim().length > 0 && value.length <= 200
|
||||
);
|
||||
|
||||
export const normalizeManagedStorageActionOutcome = (value: unknown): ManagedStorageActionOutcome => {
|
||||
if (!isRecord(value) || typeof value.message !== 'string' || value.message.trim().length === 0 || value.message.length > 500) {
|
||||
return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE };
|
||||
}
|
||||
|
||||
if (value.success === true && isBoundedIdentifier(value.jobId) && isBoundedIdentifier(value.auditId)) {
|
||||
return {
|
||||
tone: 'status',
|
||||
message: `Initialisatieverzoek geregistreerd. Job ${value.jobId.trim()}; audit ${value.auditId.trim()}. De opslag wordt pas als gereed getoond nadat de controller dat bevestigt.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (value.success === false) {
|
||||
return { tone: 'alert', message: value.message.trim() };
|
||||
}
|
||||
|
||||
return { tone: 'alert', message: UNCONFIRMED_INITIALIZATION_MESSAGE };
|
||||
};
|
||||
|
||||
export const rootReadinessLabel = (status: TrustedAppdataRootReadinessStatus): string => {
|
||||
if (status === 'ready') return 'Controller confirms ready';
|
||||
if (status === 'needs_initialization') return 'Controller reports initialization needed';
|
||||
return 'Not currently confirmed';
|
||||
};
|
||||
|
||||
export const managedStorageStatusLabel = (status: ManagedStorageStatus): string => {
|
||||
switch (status) {
|
||||
case 'not_selected': return 'Not selected for future targets';
|
||||
case 'blocked': return 'Blocked by a required safety condition';
|
||||
case 'ready': return 'Controller reports ready';
|
||||
case 'initialization_required': return 'Controller requires one-time initialization';
|
||||
default: return 'Unavailable or not confirmed';
|
||||
}
|
||||
};
|
||||
|
||||
export const managedStorageBlockerLabel = (blocker: string | null): string | null => {
|
||||
switch (blocker) {
|
||||
case 'managed_appdata_backup_acknowledgement_required': return 'Confirm the Docker-root backup responsibility before continuing.';
|
||||
case 'managed_appdata_initialization_required': return 'The controller reports that the managed storage volume needs initialization.';
|
||||
case 'managed_appdata_volume_unavailable': return 'The controller cannot currently verify the managed storage volume.';
|
||||
default: return blocker ? 'The controller reports an additional storage blocker.' : null;
|
||||
}
|
||||
};
|
||||
@@ -15,6 +15,7 @@ import { usePermission } from "@/hooks/useUserSession";
|
||||
import { UserAccessSettings } from "@/components/UserAccessSettings";
|
||||
import { ExposureSettings } from "@/components/ExposureSettings";
|
||||
import { ManagedAiSettings } from "@/components/ManagedAiSettings";
|
||||
import { ManagedStorageSettings } from "@/components/ManagedStorageSettings";
|
||||
|
||||
const settingsTabs = ["general", "security", "ai", "notifications", "audit", "voice", "maintenance", "backups"] as const;
|
||||
type SettingsTab = typeof settingsTabs[number];
|
||||
@@ -194,6 +195,7 @@ const Settings = () => {
|
||||
</div>
|
||||
|
||||
<TabsContent value="general">
|
||||
<ManagedStorageSettings />
|
||||
<Card className="signal-rail border-slate-700 bg-slate-900/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-bold uppercase tracking-wider text-slate-400">Runtime Profiel</CardTitle>
|
||||
|
||||
@@ -121,9 +121,37 @@ export interface EnrollmentCapabilities {
|
||||
policyProvisioning: EnrollmentCapability;
|
||||
rollback: EnrollmentCapability;
|
||||
verification: EnrollmentCapability;
|
||||
availableTrustedAppdataRoots?: TrustedAppdataRootReadiness[];
|
||||
managedStorage?: ManagedStorageCapability;
|
||||
profiles: Array<{ name: string; version: string; public: boolean; authentication: string }>;
|
||||
}
|
||||
|
||||
export type TrustedAppdataRoot = '/srv/example/appdata' | '/var/lib/docker/volumes/itworx-managed-appdata/_data';
|
||||
export type TrustedAppdataRootReadinessStatus = 'ready' | 'needs_initialization' | 'unavailable';
|
||||
export interface TrustedAppdataRootReadiness {
|
||||
root: TrustedAppdataRoot;
|
||||
status: TrustedAppdataRootReadinessStatus;
|
||||
}
|
||||
export type ManagedStorageStatus = 'not_selected' | 'blocked' | 'ready' | 'initialization_required' | 'unavailable';
|
||||
export interface ManagedStorageCapability {
|
||||
status: ManagedStorageStatus;
|
||||
blockerCode?: string;
|
||||
root: '/var/lib/docker/volumes/itworx-managed-appdata/_data';
|
||||
backupScope: 'dockerRootDir';
|
||||
backupAcknowledged: boolean;
|
||||
}
|
||||
export interface EnrollmentTrustStatus {
|
||||
status: 'ready' | 'blocked' | 'needs_setup' | string;
|
||||
blockers?: string[];
|
||||
configured?: boolean;
|
||||
trustedAppdataRoot: TrustedAppdataRoot | string;
|
||||
managedAppdataBackupAcknowledged: boolean;
|
||||
}
|
||||
export interface EnrollmentStorageRootSelection {
|
||||
trusted_appdata_root: TrustedAppdataRoot;
|
||||
managed_appdata_backup_acknowledged: boolean;
|
||||
}
|
||||
|
||||
export interface Enrollment {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
|
||||
Reference in new issue
Block a user