|
| 1 | +import { useCallback, useEffect, useMemo, useState } from "react"; |
| 2 | +import { Download, ShieldCheck } from "lucide-react"; |
| 3 | +import { Badge } from "@/components/ui/badge"; |
| 4 | +import { Button } from "@/components/ui/button"; |
| 5 | +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 6 | +import { Input } from "@/components/ui/input"; |
| 7 | +import { Textarea } from "@/components/ui/textarea"; |
| 8 | +import { EmptyState, InlineNotice, SectionHeader } from "@/components/ds"; |
| 9 | +import { useOrg } from "@/lib/org-context"; |
| 10 | +import { describeApiError } from "@/lib/authz/permissions"; |
| 11 | +import { activateComputePolicy, createComputePolicy, listComputePoliciesForPool, listComputePools } from "@/lib/api/client"; |
| 12 | +import type { ComputePolicy, ComputePool, ComputePriorityClass, ComputeResourceLimit, SignedComputePolicyRequest } from "@/lib/api/types"; |
| 13 | + |
| 14 | +const PRIORITIES: ComputePriorityClass[] = ["interactive", "imaging", "scheduled", "background", "maintenance"]; |
| 15 | + |
| 16 | +const EMPTY_LIMIT: ComputeResourceLimit = {}; |
| 17 | + |
| 18 | +function limitField(limit: ComputeResourceLimit, onChange: (next: ComputeResourceLimit) => void, disabled: boolean) { |
| 19 | + const numberField = (label: string, key: keyof ComputeResourceLimit) => ( |
| 20 | + <label key={key} className="flex flex-col gap-1 text-xs text-muted-foreground"> |
| 21 | + {label} |
| 22 | + <Input type="number" min={0} disabled={disabled} className="h-8 text-xs" value={(limit[key] as number) ?? ""} |
| 23 | + placeholder="unset" |
| 24 | + onChange={(e) => onChange({ ...limit, [key]: e.target.value === "" ? undefined : Number(e.target.value) })} /> |
| 25 | + </label> |
| 26 | + ); |
| 27 | + return <div className="grid gap-2 sm:grid-cols-4"> |
| 28 | + {numberField("Max CPU threads", "maxCpuThreads")} |
| 29 | + {numberField("Max RAM (MB)", "maxRamMB")} |
| 30 | + {numberField("Max pinned mem (MB)", "maxPinnedMemoryMB")} |
| 31 | + {numberField("Max accelerators", "maxAccelerators")} |
| 32 | + {numberField("Max VRAM/device (MB)", "maxVramMBPerDevice")} |
| 33 | + {numberField("Max concurrency/device", "maxConcurrencyPerDevice")} |
| 34 | + {numberField("Max temperature (C)", "maxTemperatureC")} |
| 35 | + {numberField("Max power (W)", "maxPowerWatts")} |
| 36 | + <label className="flex flex-col gap-1 text-xs text-muted-foreground">Allow CPU fallback |
| 37 | + <select className="h-8 rounded-md border bg-background px-2 text-xs" disabled={disabled} value={limit.allowCpuFallback === undefined ? "unset" : String(limit.allowCpuFallback)} |
| 38 | + onChange={(e) => onChange({ ...limit, allowCpuFallback: e.target.value === "unset" ? undefined : e.target.value === "true" })}> |
| 39 | + <option value="unset">unset</option><option value="true">Allowed</option><option value="false">Forbidden</option> |
| 40 | + </select> |
| 41 | + </label> |
| 42 | + </div>; |
| 43 | +} |
| 44 | + |
| 45 | +function mergeLimits(hard: ComputeResourceLimit, classLimit: ComputeResourceLimit | undefined): ComputeResourceLimit { |
| 46 | + return { ...hard, ...classLimit }; |
| 47 | +} |
| 48 | + |
| 49 | +export default function ComputePolicies() { |
| 50 | + const { organizationId, permissions } = useOrg(); |
| 51 | + const canManage = permissions?.["compute:managePolicies"] ?? false; |
| 52 | + const [pools, setPools] = useState<ComputePool[]>([]); |
| 53 | + const [selectedPool, setSelectedPool] = useState<string>(""); |
| 54 | + const [policies, setPolicies] = useState<ComputePolicy[]>([]); |
| 55 | + const [error, setError] = useState<string>(); |
| 56 | + const [busy, setBusy] = useState<string>(); |
| 57 | + |
| 58 | + const [draftName, setDraftName] = useState("New resource policy"); |
| 59 | + const [draftExpiresInDays, setDraftExpiresInDays] = useState(30); |
| 60 | + const [draftHardLimits, setDraftHardLimits] = useState<ComputeResourceLimit>(EMPTY_LIMIT); |
| 61 | + const [draftClassLimits, setDraftClassLimits] = useState<Partial<Record<ComputePriorityClass, ComputeResourceLimit>>>({}); |
| 62 | + const [previewPriority, setPreviewPriority] = useState<ComputePriorityClass>("background"); |
| 63 | + const [signedPaste, setSignedPaste] = useState(""); |
| 64 | + |
| 65 | + const load = useCallback(async () => { |
| 66 | + setError(undefined); |
| 67 | + try { |
| 68 | + const nextPools = await listComputePools(organizationId); |
| 69 | + setPools(nextPools); |
| 70 | + const poolId = selectedPool || nextPools[0]?.id || ""; |
| 71 | + if (poolId && !selectedPool) setSelectedPool(poolId); |
| 72 | + if (poolId) setPolicies(await listComputePoliciesForPool(organizationId, poolId)); |
| 73 | + } catch (reason) { setError(describeApiError(reason, organizationId)); } |
| 74 | + // eslint-disable-next-line react-hooks/exhaustive-deps -- selectedPool intentionally read, not a re-trigger dependency (see the explicit reload below) |
| 75 | + }, [organizationId]); |
| 76 | + |
| 77 | + useEffect(() => { void load(); }, [load]); |
| 78 | + useEffect(() => { |
| 79 | + if (!selectedPool) return; |
| 80 | + listComputePoliciesForPool(organizationId, selectedPool).then(setPolicies).catch((reason) => setError(describeApiError(reason, organizationId))); |
| 81 | + }, [organizationId, selectedPool]); |
| 82 | + |
| 83 | + const draftJson = useMemo(() => JSON.stringify({ |
| 84 | + name: draftName, ...(selectedPool ? { poolId: selectedPool } : {}), expiresInDays: draftExpiresInDays, |
| 85 | + hardLimits: draftHardLimits, workloadClassLimits: draftClassLimits, |
| 86 | + }, null, 2), [draftName, selectedPool, draftExpiresInDays, draftHardLimits, draftClassLimits]); |
| 87 | + |
| 88 | + function downloadDraft() { |
| 89 | + const blob = new Blob([draftJson], { type: "application/json" }); |
| 90 | + const url = URL.createObjectURL(blob); |
| 91 | + const link = document.createElement("a"); |
| 92 | + link.href = url; link.download = "compute-policy-draft.json"; |
| 93 | + link.click(); |
| 94 | + URL.revokeObjectURL(url); |
| 95 | + } |
| 96 | + |
| 97 | + async function submitSigned() { |
| 98 | + setBusy("submit"); setError(undefined); |
| 99 | + try { |
| 100 | + const parsed = JSON.parse(signedPaste) as SignedComputePolicyRequest; |
| 101 | + await createComputePolicy(organizationId, parsed); |
| 102 | + setSignedPaste(""); |
| 103 | + if (selectedPool) setPolicies(await listComputePoliciesForPool(organizationId, selectedPool)); |
| 104 | + } catch (reason) { setError(reason instanceof SyntaxError ? "That isn't valid JSON." : describeApiError(reason, organizationId)); } |
| 105 | + finally { setBusy(undefined); } |
| 106 | + } |
| 107 | + |
| 108 | + async function activate(policyId: string) { |
| 109 | + setBusy(policyId); setError(undefined); |
| 110 | + try { |
| 111 | + await activateComputePolicy(organizationId, policyId); |
| 112 | + if (selectedPool) setPolicies(await listComputePoliciesForPool(organizationId, selectedPool)); |
| 113 | + } catch (reason) { setError(describeApiError(reason, organizationId)); } |
| 114 | + finally { setBusy(undefined); } |
| 115 | + } |
| 116 | + |
| 117 | + return <div className="mx-auto flex max-w-6xl flex-col gap-5 p-6"> |
| 118 | + <SectionHeader title="Compute resource policies" description="Signed hard CPU/GPU guardrails for the compute fleet. Signing happens offline (server/scripts/sign-compute-policy.js) — the private key never touches this console." /> |
| 119 | + {error && <InlineNotice variant="destructive" title="Compute policy request failed">{error}</InlineNotice>} |
| 120 | + |
| 121 | + <Card><CardHeader><CardTitle className="text-base">Pool</CardTitle></CardHeader><CardContent> |
| 122 | + <select className="h-9 w-full max-w-sm rounded-md border bg-background px-2 text-sm" value={selectedPool} onChange={(e) => setSelectedPool(e.target.value)}> |
| 123 | + {pools.length === 0 && <option value="">No pools</option>} |
| 124 | + {pools.map((pool) => <option key={pool.id} value={pool.id}>{pool.name}</option>)} |
| 125 | + </select> |
| 126 | + </CardContent></Card> |
| 127 | + |
| 128 | + <Card><CardHeader><CardTitle className="text-base">Existing versions</CardTitle></CardHeader><CardContent className="space-y-2"> |
| 129 | + {policies.length === 0 && <EmptyState icon={<ShieldCheck className="size-6" />} title="No policy versions for this pool" description="Compose a draft below, sign it offline, and submit it to create the first one." />} |
| 130 | + {policies.sort((a, b) => b.version - a.version).map((policy) => <div key={policy.id} className="flex flex-wrap items-center gap-2 rounded border p-3 text-sm"> |
| 131 | + <strong>{policy.name}</strong><Badge variant={policy.status === "active" ? "default" : policy.status === "retired" ? "outline" : "secondary"}>{policy.status}</Badge> |
| 132 | + <span className="text-xs text-muted-foreground">v{policy.version} · issued {new Date(policy.issuedAt).toLocaleDateString()} · expires {new Date(policy.expiresAt).toLocaleDateString()}</span> |
| 133 | + {canManage && policy.status !== "active" && <Button size="sm" variant="outline" className="ml-auto" disabled={busy === policy.id} onClick={() => void activate(policy.id)}>{policy.status === "retired" ? "Activate (rollback)" : "Activate"}</Button>} |
| 134 | + </div>)} |
| 135 | + </CardContent></Card> |
| 136 | + |
| 137 | + <Card><CardHeader><CardTitle className="text-base">Compose a draft</CardTitle></CardHeader><CardContent className="space-y-4"> |
| 138 | + <div className="grid gap-2 sm:grid-cols-2"> |
| 139 | + <label className="flex flex-col gap-1 text-xs text-muted-foreground">Name<Input className="h-8 text-xs" value={draftName} onChange={(e) => setDraftName(e.target.value)} /></label> |
| 140 | + <label className="flex flex-col gap-1 text-xs text-muted-foreground">Expires in (days)<Input type="number" min={1} className="h-8 text-xs" value={draftExpiresInDays} onChange={(e) => setDraftExpiresInDays(Math.max(1, Number(e.target.value)))} /></label> |
| 141 | + </div> |
| 142 | + |
| 143 | + <div> |
| 144 | + <div className="mb-2 text-xs font-medium text-muted-foreground">Hard limits (apply to every workload class unless overridden below)</div> |
| 145 | + {limitField(draftHardLimits, setDraftHardLimits, false)} |
| 146 | + </div> |
| 147 | + |
| 148 | + <div> |
| 149 | + <div className="mb-2 text-xs font-medium text-muted-foreground">Per-priority overrides</div> |
| 150 | + <div className="space-y-3"> |
| 151 | + {PRIORITIES.map((priority) => <div key={priority} className="rounded border p-2"> |
| 152 | + <div className="mb-1 text-xs font-medium capitalize">{priority}</div> |
| 153 | + {limitField(draftClassLimits[priority] ?? EMPTY_LIMIT, (next) => setDraftClassLimits((cur) => ({ ...cur, [priority]: next })), false)} |
| 154 | + </div>)} |
| 155 | + </div> |
| 156 | + </div> |
| 157 | + |
| 158 | + <div className="rounded border bg-muted/30 p-3"> |
| 159 | + <div className="mb-2 flex items-center gap-2 text-xs font-medium text-muted-foreground"> |
| 160 | + Effective policy preview for |
| 161 | + <select className="h-7 rounded-md border bg-background px-2 text-xs" value={previewPriority} onChange={(e) => setPreviewPriority(e.target.value as ComputePriorityClass)}> |
| 162 | + {PRIORITIES.map((p) => <option key={p} value={p}>{p}</option>)} |
| 163 | + </select> |
| 164 | + </div> |
| 165 | + <pre className="overflow-x-auto text-xs">{JSON.stringify(mergeLimits(draftHardLimits, draftClassLimits[previewPriority]), null, 2)}</pre> |
| 166 | + </div> |
| 167 | + |
| 168 | + <Button size="sm" variant="outline" onClick={downloadDraft}><Download className="mr-2 size-3.5" />Download draft JSON to sign offline</Button> |
| 169 | + </CardContent></Card> |
| 170 | + |
| 171 | + {canManage && <Card><CardHeader><CardTitle className="text-base">Submit a signed policy</CardTitle></CardHeader><CardContent className="space-y-3"> |
| 172 | + <Textarea rows={6} placeholder="Paste the JSON produced by server/scripts/sign-compute-policy.js" value={signedPaste} onChange={(e) => setSignedPaste(e.target.value)} /> |
| 173 | + <Button size="sm" disabled={!signedPaste.trim() || busy === "submit"} onClick={() => void submitSigned()}>{busy === "submit" ? "Submitting…" : "Create policy"}</Button> |
| 174 | + </CardContent></Card>} |
| 175 | + </div>; |
| 176 | +} |
0 commit comments