Files
ThothII/frontend/src/shell/PiManagement.tsx
T

333 lines
19 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleAlert, Clipboard, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react";
import {
asPiManagementApiError,
getPiManagementLogs,
getPiManagementOptions,
getPiManagementStatus,
runPiManagementTest,
savePiManagementConfig,
type PiInstallationConfig,
type PiManagementOptions,
type PiManagementStatus,
} from "../api/pi-management";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
const UPDATE_COMMAND = "thothctl pi update";
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
type Feedback = { tone: "success" | "error"; message: string } | undefined;
type SmokeState = "passed" | "failed" | undefined;
function suggestedConfig(status: { config: Partial<PiInstallationConfig> }, options: PiManagementOptions): PiInstallationConfig | undefined {
const provider = status.config.provider && options.providers.includes(status.config.provider)
? status.config.provider
: options.providers[0];
const model = status.config.model && options.models.some((item) => item.provider === provider && item.id === status.config.model)
? status.config.model
: options.models.find((item) => item.provider === provider)?.id;
const reasoning = status.config.reasoning && options.reasoning.includes(status.config.reasoning)
? status.config.reasoning
: options.reasoning[0];
return provider && model && reasoning ? { provider, model, reasoning } : undefined;
}
function isSupportedConfig(
config: Partial<PiInstallationConfig> | undefined,
options: PiManagementOptions | undefined,
): config is PiInstallationConfig {
return Boolean(
config?.provider
&& config.model
&& config.reasoning
&& options?.providers.includes(config.provider)
&& options.models.some((model) => model.provider === config.provider && model.id === config.model)
&& options.reasoning.includes(config.reasoning),
);
}
function sameConfig(a: Partial<PiInstallationConfig> | undefined, b: PiInstallationConfig | undefined): boolean {
return Boolean(
a?.provider === b?.provider
&& a?.model === b?.model
&& a?.reasoning === b?.reasoning,
);
}
function errorMessage(error: unknown, fallback: string): string {
return asPiManagementApiError(error)?.message ?? fallback;
}
function ReadinessRail({ ready, configured, credentials, smokeState }: {
ready: boolean;
configured: boolean;
credentials: "present" | "missing";
smokeState: SmokeState;
}) {
return (
<section
data-testid="pi-readiness-rail"
aria-label="Pi readiness sequence"
className="grid gap-3 border-l-2 border-border pl-4 sm:grid-cols-4"
>
<RailItem label="Runtime" value={ready ? "Runtime ready" : "Runtime needs attention"} state={ready ? "ready" : "attention"} />
<RailItem label="Defaults" value={configured ? "Defaults ready" : "Defaults incomplete"} state={configured ? "ready" : "attention"} />
<RailItem
label="Credentials"
value={`Credentials ${credentials}`}
state={credentials === "present" ? "ready" : "attention"}
/>
<RailItem
label="Saved-config test"
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "idle"}
/>
</section>
);
}
function RailItem({ label, value, state }: { label: string; value: string; state: "ready" | "attention" | "idle" }) {
const iconClass = state === "ready" ? "text-[oklch(var(--success))]" : state === "attention" ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground";
return <div className="min-w-0">
<p className="thot-label">{label}</p>
<p className={`mt-1 flex items-start gap-1.5 text-sm font-semibold ${iconClass}`}>
{state === "ready" ? <CheckCircle2 className="mt-0.5 size-3.5 shrink-0" /> : state === "attention" ? <CircleAlert className="mt-0.5 size-3.5 shrink-0" /> : <span className="mt-1.5 size-1.5 shrink-0 rounded-full bg-current" />}
<span>{value}</span>
</p>
</div>;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return <label className="grid gap-1.5 text-xs font-semibold text-foreground"><span>{label}</span>{children}</label>;
}
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<PiInstallationConfig>();
const [feedback, setFeedback] = useState<Feedback>();
const [smokeState, setSmokeState] = useState<SmokeState>();
const [logsRequested, setLogsRequested] = useState(false);
const statusQuery = useQuery({ queryKey: ["pi-management", "status"], queryFn: getPiManagementStatus, enabled: open });
const optionsQuery = useQuery({ queryKey: ["pi-management", "options"], queryFn: getPiManagementOptions, enabled: open });
const logsQuery = useQuery({ queryKey: ["pi-management", "logs"], queryFn: getPiManagementLogs, enabled: open && logsRequested });
const models = useMemo(
() => optionsQuery.data?.models.filter((model) => model.provider === draft?.provider) ?? [],
[draft?.provider, optionsQuery.data?.models],
);
const initialDraft = useMemo(
() => statusQuery.data && optionsQuery.data
? suggestedConfig(statusQuery.data, optionsQuery.data)
: undefined,
[optionsQuery.data, statusQuery.data],
);
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
const validDraft = isSupportedConfig(draft, optionsQuery.data);
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
useEffect(() => {
if (!open) {
setDraft(undefined);
setFeedback(undefined);
setSmokeState(undefined);
setLogsRequested(false);
queryClient.removeQueries({ queryKey: ["pi-management"] });
}
}, [open, queryClient]);
useEffect(() => {
if (draft || !initialDraft) return;
setDraft(initialDraft);
}, [draft, initialDraft]);
const saveMutation = useMutation({
mutationFn: savePiManagementConfig,
onSuccess: (saved) => {
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
queryClient.setQueryData<PiManagementStatus>(["pi-management", "status"], (current) => (
current ? { ...current, config } : current
));
setDraft(config);
setSmokeState(undefined);
setFeedback({ tone: "success", message: "Defaults saved." });
},
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
});
const smokeMutation = useMutation({
mutationFn: runPiManagementTest,
onSuccess: (result) => {
setSmokeState(result.ready ? "passed" : "failed");
setFeedback({
tone: result.ready ? "success" : "error",
message: result.ready
? "Saved configuration test passed."
: `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`,
});
},
onError: (error) => {
setSmokeState("failed");
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") });
},
});
async function copyUpdateCommand() {
try {
if (!navigator.clipboard?.writeText) throw new Error("Clipboard unavailable");
await navigator.clipboard.writeText(UPDATE_COMMAND);
setFeedback({ tone: "success", message: "Update command copied." });
} catch {
setFeedback({ tone: "error", message: "Could not copy the update command." });
}
}
function saveDefaults() {
if (!draft || !validDraft) {
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
return;
}
saveMutation.mutate(draft);
}
function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) {
setDraft((current) => current ? update(current) : current);
setSmokeState(undefined);
setFeedback(undefined);
}
function testSavedDefaults() {
if (!persistedReady || dirty) {
setFeedback({ tone: "error", message: "Save supported defaults before running the test." });
return;
}
smokeMutation.mutate();
}
const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden";
const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
const unavailable = statusQuery.isError || optionsQuery.isError;
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent showCloseButton={false} className="max-h-[calc(100vh-2rem)] max-w-[min(94vw,58rem)] overflow-hidden p-0 sm:max-w-[min(94vw,58rem)]">
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<p className="thot-label">Installation controls</p>
<DialogTitle>Pi management</DialogTitle>
<DialogDescription>Review the bundled runtime, set safe defaults, and test the saved provider configuration.</DialogDescription>
</DialogHeader>
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
{forbidden ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">Pi management is not permitted</p>
<p className="mt-1 text-muted-foreground">Ask an installation administrator to manage Pi defaults and diagnostics.</p>
</div>
) : unavailable ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}</p>
<Button className="mt-3" size="sm" variant="outline" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry</Button>
</div>
) : loading ? <p className="text-sm text-muted-foreground">Loading Pi management…</p> : statusQuery.data && optionsQuery.data && (
<div className="grid gap-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="thot-label">Bundled runtime</p>
<p className="mt-1 font-mono text-sm font-semibold">Pi {statusQuery.data.version ?? "version unavailable"}</p>
</div>
<p role="status" aria-label="Pi readiness" className={`rounded-md border px-2.5 py-1 text-xs font-semibold ${statusQuery.data.ready ? "border-[oklch(var(--success)/0.4)] bg-[oklch(var(--success)/0.1)] text-foreground" : "border-amber-500/35 bg-amber-500/10 text-amber-800 dark:text-amber-300"}`}>
{statusQuery.data.ready ? "Ready" : "Needs attention"}
</p>
</div>
<ReadinessRail
ready={statusQuery.data.ready}
configured={persistedReady}
credentials={statusQuery.data.credentials ?? "missing"}
smokeState={smokeState}
/>
{statusQuery.data.message && <p role="status" aria-live="polite" className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-sm text-amber-900 dark:text-amber-200">{statusQuery.data.message}</p>}
{feedback && <p role="status" aria-label="Pi management feedback" aria-live="polite" className={`flex items-start gap-2 rounded-md border px-3 py-2 text-sm ${feedback.tone === "error" ? "border-destructive/30 bg-destructive/5" : "border-border bg-muted/50"}`}>
{feedback.tone === "error" ? <CircleAlert className="mt-0.5 size-4 shrink-0 text-destructive" /> : <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[oklch(var(--success))]" />}
{feedback.message}
</p>}
<section aria-label="Installation defaults" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">These choices apply to new Pi work. Credentials remain outside this browser.</p></div>
</div>
{draft ? (
<>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
<Field label="Provider">
<select aria-label="Provider" className={fieldClass} value={draft.provider} onChange={(event) => {
const provider = event.target.value;
updateDraft((current) => ({
...current,
provider,
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
}));
}}>
{optionsQuery.data.providers.map((provider) => <option key={provider} value={provider}>{provider}</option>)}
</select>
</Field>
<Field label="Model">
<select aria-label="Model" className={fieldClass} value={draft.model} onChange={(event) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
{models.map((model) => <option key={model.id} value={model.id}>{model.id === "glm-5.2" ? "GLM 5.2" : model.id}</option>)}
</select>
</Field>
<Field label="Reasoning level">
<select aria-label="Reasoning level" className={fieldClass} value={draft.reasoning} onChange={(event) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
</select>
</Field>
</div>
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
</p>}
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
</div>
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
</>
) : (
<div className="mt-4">
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
<p className="font-semibold">No enabled provider and model choices are available.</p>
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
</div>
</div>
)}
</section>
<section aria-label="Sanitized diagnostics" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="font-heading text-sm font-semibold">Sanitized diagnostics</h3><p className="mt-1 text-sm text-muted-foreground">Inspect up to the latest 200 sanitized lines. Raw runtime output is never shown here.</p></div>
<Button size="sm" variant="outline" onClick={() => setLogsRequested(true)} disabled={logsQuery.isFetching}>{logsQuery.isFetching ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ScrollText />}Show sanitized logs</Button>
</div>
{logsQuery.isError && <p role="alert" className="mt-3 text-sm text-destructive">Could not load sanitized Pi diagnostics.</p>}
{logsQuery.data && <pre aria-label="Sanitized Pi diagnostics" className="mt-3 max-h-48 overflow-auto rounded-md border border-border bg-muted/50 p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap">{logsQuery.data.lines.join("\n") || "No diagnostic lines are available."}</pre>}
</section>
<section aria-label="Host update" className="border-t border-border/70 pt-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div><h3 className="font-heading text-sm font-semibold">Update Pi on the host</h3><p className="mt-1 max-w-xl text-sm text-muted-foreground">Pi is updated through the host-side control command so image changes remain reproducible and recoverable. This page cannot update Docker or the running image.</p></div>
<Button size="sm" variant="outline" onClick={() => { void copyUpdateCommand(); }}><Clipboard />Copy update command</Button>
</div>
<code className="mt-3 block w-fit rounded-md border border-border bg-muted px-2.5 py-1.5 text-xs">{UPDATE_COMMAND}</code>
</section>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}