"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { Card, CardContent, CardHeader, CardTitle } from "./ui/card" import { Badge } from "./ui/badge" import { Button } from "./ui/button" import { Boxes, CheckCircle2, HardDrive, Loader2, Settings2, SlidersHorizontal } from "lucide-react" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./ui/select" import { fetchApi } from "../lib/api-config" import { useT } from "../lib/i18n/provider" /** * Declares what is expected of this host. * * An assessment can see what the host does; it cannot see what it is * for. Everything on this page answers a question the host has no way of * answering itself — does this guest need a backup, must this one come * back by itself, is this storage essential — and each answer is what * turns an observation in the report into a warning, or takes it out of * the count entirely. * * Nothing here is required. A host with no declaration produces a * complete report; it just describes rather than judges. */ interface Guest { vmid: number; name: string; type: string } interface Storage { id: string; type: string } interface GuestRule { backup?: string; autostart?: string recovery_objective_hours?: number; note?: string } interface Policy { guests: Record storages: Record defaults: Record thresholds: Record } interface Vocabulary { expectations: string[] roles: string[] thresholds: Record } const EMPTY: Policy = { guests: {}, storages: {}, defaults: {}, thresholds: {} } function PolicySelect({ value, options, prefix, onChange, inherited, inheritedKey, label, disabled }: { value: string; options: string[]; prefix: string onChange: (value: string) => void; inherited: string; inheritedKey?: string label: (key: string) => string; disabled?: boolean }) { // One component behind every dropdown on this tab, so it is also the // one place that decides they look like the rest of the interface. return ( ) } export function AuditPolicy() { const t = useT() const [policy, setPolicy] = useState(EMPTY) const [vocabulary, setVocabulary] = useState(null) const [guests, setGuests] = useState([]) const [storages, setStorages] = useState([]) const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [dirty, setDirty] = useState(false) const [error, setError] = useState(null) const [saved, setSaved] = useState(false) const [revision, setRevision] = useState(null) const [conflict, setConflict] = useState(false) // The declaration is what turns an observation into a warning, so the // form stays locked until the reader says they are changing it. const [editing, setEditing] = useState(false) const locked = !editing || saving || !revision || conflict const load = useCallback(async () => { setLoading(true) setRevision(null) try { const [current, inventory]: any[] = await Promise.all([ fetchApi("/api/audit/policy"), // The declaration is about this host's own guests and storages, // so they are listed rather than typed in by identifier. fetchApi("/api/audit/inventory?profile=inventory"), ]) if (current?.success) { setPolicy({ ...EMPTY, ...current.policy }) setVocabulary(current.vocabulary) setRevision(current.summary.revision) setDirty(false); setSaved(false); setConflict(false) setError(null) } else { setError(current?.message || t("audit.policy.failed")) } const sections = inventory?.inventory?.sections setGuests((sections?.guests || []).map((g: any) => ({ vmid: g.vmid, name: g.name, type: g.type, }))) setStorages((sections?.storages || []).map((s: any) => ({ id: s.id, type: s.type, }))) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setLoading(false) } }, [t]) useEffect(() => { load() }, [load]) const setGuestRule = (vmid: number, field: keyof GuestRule, value: unknown) => { setPolicy((prev) => { const guests = { ...prev.guests } const rule: GuestRule = { ...(guests[String(vmid)] || {}) } // Absence inherits; explicit "unspecified" overrides the site default. if (value === "inherit" || value === "" || value === undefined) { delete rule[field] } else { ;(rule as Record)[field] = value } if (Object.keys(rule).length === 0) delete guests[String(vmid)] else guests[String(vmid)] = rule return { ...prev, guests } }) setDirty(true); setSaved(false) } const setStorageRole = (id: string, role: string) => { setPolicy((prev) => { const storages = { ...prev.storages } if (role === "inherit" || !role) delete storages[id] else storages[id] = { role } return { ...prev, storages } }) setDirty(true); setSaved(false) } const setThreshold = (name: string, raw: string) => { setPolicy((prev) => { const thresholds = { ...prev.thresholds } const value = Number(raw) if (!raw.trim()) delete thresholds[name] else thresholds[name] = value return { ...prev, thresholds } }) setDirty(true); setSaved(false) } const save = async () => { if (!revision || saving || conflict) return setSaving(true) try { const res: any = await fetchApi("/api/audit/policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...policy, expected_revision: revision }), }) if (res?.success) { setRevision(res.summary.revision) setDirty(false); setSaved(true); setError(null); setEditing(false) } else setError(res?.message || t("audit.policy.failed")) } catch (e) { if ((e as { status?: number }).status === 409) { setConflict(true) setError(t("audit.policy.conflict")) } else setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } } const declared = useMemo( () => Object.keys(policy.guests).length + Object.keys(policy.storages).length + Object.keys(policy.thresholds).length + Object.keys(policy.defaults).length, [policy], ) if (loading) { return (
{t("audit.policy.loading")}
) } const expectations = vocabulary?.expectations || ["required", "not_required", "unspecified"] const roles = vocabulary?.roles || ["essential", "optional", "unspecified"] return (
{ event.preventDefault(); void save() }}> {error &&

{error}

} {(conflict || !revision) && }

{t("audit.policy.intro")}

{t("audit.policy.declaredCount", { count: String(declared) })} {saved && {t("audit.policy.saved")}} {editing ? (
) : ( )}
{t("audit.inventory.guests")}

{t("audit.policy.guestsNote")}

{guests.map((guest) => { const rule = policy.guests[String(guest.vmid)] || {} return (
{guest.vmid} {guest.name || "—"} {guest.type}
) })} {guests.length === 0 && (

{t("audit.policy.noGuests")}

)}
{t("audit.inventory.storage")}

{t("audit.policy.storagesNote")}

{storages.map((storage) => (
{storage.id} {storage.type}
setStorageRole(storage.id, v)} />
))}
{t("audit.policy.thresholds")}

{t("audit.policy.thresholdsNote")}

{Object.entries(vocabulary?.thresholds || {}).map(([name, shipped]) => ( ))}
) }