feat(frontend): simplify Pi operator workflow

This commit is contained in:
2026-08-14 18:52:11 +02:00
parent dc83a55555
commit 1df881d98d
2 changed files with 192 additions and 39 deletions
+47 -16
View File
@@ -60,7 +60,7 @@ test("uses closed provider, model, and reasoning choices without a secret field
expect(screen.getByLabelText("Model")).toHaveTextContent("GLM 5.2");
expect(screen.queryByRole("textbox", { name: /provider|model|reasoning|credential/i })).not.toBeInTheDocument();
expect(document.querySelector('input[type="password"]')).toBeNull();
expect(screen.queryByText(/terminal|shell access/i)).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /terminal|shell access/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update Pi" })).not.toBeInTheDocument();
});
@@ -167,26 +167,57 @@ test("explains forbidden management access without offering mutation controls",
expect(screen.queryByRole("button", { name: "Test saved defaults" })).not.toBeInTheDocument();
});
test("offers a copyable host-side Pi update instruction without an update action", async () => {
test("shows a seven-step host-terminal workflow in scrollable platform tabs", async () => {
const user = userEvent.setup();
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
renderManagement();
expect(await screen.findByText("thothctl pi update")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Copy update command" }));
expect(writeText).toHaveBeenCalledWith("thothctl pi update");
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Update command copied");
expect(screen.queryByRole("button", { name: /update Pi/i })).not.toBeInTheDocument();
});
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
expect(within(tablist).getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("max-h-[calc(100vh-6rem)]");
expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll");
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
expect(within(tablist).getAllByRole("tab").every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
expect(screen.getByText("Using the host terminal:")).toBeVisible();
expect(screen.queryByText(/not this browser page/i)).not.toBeInTheDocument();
expect(screen.queryByRole("region", { name: "Host update" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Copy update command" })).not.toBeInTheDocument();
expect(screen.queryByText(/:5173/)).not.toBeInTheDocument();
expect(await screen.findByText("Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.")).toBeVisible();
expect(screen.getByText("Shows at most 200 recent lines with declared secret values removed.")).toBeVisible();
test("reports when the browser cannot copy the host-side update command", async () => {
const user = userEvent.setup();
Object.defineProperty(navigator, "clipboard", { configurable: true, value: undefined });
renderManagement();
await user.click(within(tablist).getByRole("tab", { name: "Linux" }));
const linux = screen.getByRole("tabpanel", { name: "Linux" });
expect(within(linux).getAllByRole("listitem")).toHaveLength(7);
expect(linux).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(linux).toHaveTextContent("deploy/pi/models.json");
expect(linux).toHaveTextContent("deploy/pi/settings.json");
expect(linux).toHaveTextContent("baseUrl is the provider API endpoint");
expect(linux).toHaveTextContent("enabledModels uses provider/model identifiers");
expect(linux).toHaveTextContent("PI_AUTH_FILE is a setting in the installation environment file");
expect(linux).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(linux).toHaveTextContent("pi update --version <VERSION> --source build --yes --drain");
expect(linux).toHaveTextContent("pi rollback --yes");
expect(linux).not.toHaveTextContent("~/.pi/agent/");
await user.click(await screen.findByRole("button", { name: "Copy update command" }));
expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Could not copy the update command");
await user.click(within(tablist).getByRole("tab", { name: "macOS" }));
const macos = screen.getByRole("tabpanel", { name: "macOS" });
expect(within(macos).getAllByRole("listitem")).toHaveLength(7);
expect(macos).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(macos).toHaveTextContent("deploy/pi/models.json");
expect(macos).toHaveTextContent("deploy/pi/settings.json");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain");
expect(macos).toHaveTextContent("~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain");
expect(macos).toHaveTextContent("pi maintenance recover --yes");
await user.click(within(tablist).getByRole("tab", { name: "Windows" }));
const windows = screen.getByRole("tabpanel", { name: "Windows" });
expect(within(windows).getAllByRole("listitem")).toHaveLength(7);
expect(windows).toHaveTextContent("The deploy directory is in the ThothII project root, beside compose.yaml");
expect(windows).toHaveTextContent("deploy\\pi\\models.json");
expect(windows).toHaveTextContent("deploy\\pi\\settings.json");
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi restart --yes --drain');
expect(windows).toHaveTextContent('& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain');
expect(windows).toHaveTextContent("pi rollback --yes");
});
test("reloads installation defaults when the panel is reopened", async () => {
+145 -23
View File
@@ -1,6 +1,6 @@
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 { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react";
import {
asPiManagementApiError,
getPiManagementLogs,
@@ -14,7 +14,6 @@ import {
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;
@@ -102,6 +101,146 @@ 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>;
}
type PiPlatform = "linux" | "macos" | "windows";
type PiPlatformDetails = {
modelsPath: string;
settingsPath: string;
terminal: string;
credentialProtection: string;
restartCommand: string;
updateCommand: string;
pullCommand: string;
recoveryCommands: string;
};
const piPlatforms: Array<{ id: PiPlatform; label: string; details: PiPlatformDetails }> = [
{
id: "linux",
label: "Linux",
details: {
modelsPath: "deploy/pi/models.json",
settingsPath: "deploy/pi/settings.json",
terminal: "a terminal",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain",
updateCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain",
pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes\n~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes",
},
},
{
id: "macos",
label: "macOS",
details: {
modelsPath: "deploy/pi/models.json",
settingsPath: "deploy/pi/settings.json",
terminal: "Terminal",
credentialProtection: "a protected host file with mode 0600",
restartCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi restart --yes --drain",
updateCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source build --yes --drain",
pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
recoveryCommands: "~/bin/thothctl --installation ~/thothii-installation.yaml pi rollback --yes\n~/bin/thothctl --installation ~/thothii-installation.yaml pi maintenance recover --yes",
},
},
{
id: "windows",
label: "Windows",
details: {
modelsPath: "deploy\\pi\\models.json",
settingsPath: "deploy\\pi\\settings.json",
terminal: "PowerShell",
credentialProtection: "a protected host file with a user-only ACL",
restartCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi restart --yes --drain',
updateCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source build --yes --drain',
pullCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain',
recoveryCommands: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi rollback --yes\n& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi maintenance recover --yes',
},
},
];
function PiCodeBlock({ children, className = "" }: { children: string; className?: string }) {
return <pre className={`overflow-x-auto rounded bg-muted p-2 ${className}`}><code>{children}</code></pre>;
}
function PiInstructionSteps({ details }: { details: PiPlatformDetails }) {
return <ol className="grid gap-4 pl-5 marker:font-semibold marker:text-muted-foreground">
<li>
<h4 className="font-semibold text-foreground">Open the project root</h4>
<p className="mt-1 text-muted-foreground">Open {details.terminal} in the ThothII project root. The deploy directory is in the ThothII project root, beside <code>compose.yaml</code>.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Edit the provider catalog</h4>
<p className="mt-1 text-muted-foreground">Edit <code>{details.modelsPath}</code>. It is the provider catalog.</p>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
<dt className="font-mono text-foreground">baseUrl </dt><dd>is the provider API endpoint.</dd>
<dt className="font-mono text-foreground">api </dt><dd>selects the provider API format.</dd>
<dt className="font-mono text-foreground">models </dt><dd>lists that provider&apos;s available models.</dd>
<dt className="font-mono text-foreground">id </dt><dd>is the model identifier.</dd>
<dt className="font-mono text-foreground">name </dt><dd>is the model name shown to operators.</dd>
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Enable the model</h4>
<p className="mt-1 text-muted-foreground">Edit <code>{details.settingsPath}</code>. It is the enabled-model policy, not the provider catalog.</p>
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-2 gap-y-1 text-muted-foreground">
<dt className="font-mono text-foreground">enabledModels </dt><dd>uses provider/model identifiers to choose the models available for new Pi work.</dd>
</dl>
</li>
<li>
<h4 className="font-semibold text-foreground">Set the provider credential</h4>
<p className="mt-1 text-muted-foreground"><code>PI_AUTH_FILE</code> is a setting in the installation environment file. It selects {details.credentialProtection}; Docker mounts the selected host credential file read-only for Pi.</p>
</li>
<li>
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
<p className="mt-1 text-muted-foreground">After changing the catalog, policy, or selected credential file, reload Pi configuration.</p>
<PiCodeBlock className="mt-2">{details.restartCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Update the Pi version</h4>
<p className="mt-1 text-muted-foreground">Use a build update only when changing the bundled Pi version.</p>
<PiCodeBlock className="mt-2">{details.updateCommand}</PiCodeBlock>
<p className="mt-2 text-[11px] text-muted-foreground">Advanced: pull an immutable, digest-pinned image.</p>
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.pullCommand}</PiCodeBlock>
</li>
<li>
<h4 className="font-semibold text-foreground">Recover a failed update</h4>
<p className="mt-1 text-muted-foreground">For a failed update, use rollback. For restart or update maintenance recovery after repairing the reported problem, use maintenance recovery.</p>
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.recoveryCommands}</PiCodeBlock>
</li>
</ol>;
}
function PiPlatformInstructions() {
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
const activeDetails = piPlatforms.find((platform) => platform.id === activePlatform);
return (
<section aria-label="Pi operator workflow" className="mt-2 grid gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
<p className="text-muted-foreground">Using the host terminal:</p>
<div role="tablist" aria-label="Pi host platform" className="grid grid-cols-3 gap-1 rounded-md bg-muted p-1">
{piPlatforms.map((platform) => (
<button
key={platform.id}
id={`pi-${platform.id}-tab`}
type="button"
role="tab"
aria-selected={activePlatform === platform.id}
aria-controls={`pi-${platform.id}-panel`}
className={activePlatform === platform.id ? "rounded bg-background px-2 py-1.5 font-semibold text-foreground shadow-sm" : "rounded px-2 py-1.5 font-semibold text-muted-foreground hover:bg-background/70 hover:text-foreground"}
onClick={() => setActivePlatform(platform.id)}
>
{platform.label}
</button>
))}
</div>
<div data-testid="pi-platform-instructions-scroll" className="min-h-0 max-h-[min(34vh,22rem)] overflow-y-scroll overscroll-contain pr-2">
{activeDetails && <div id={`pi-${activeDetails.id}-panel`} role="tabpanel" aria-label={activeDetails.label} aria-labelledby={`pi-${activeDetails.id}-tab`} tabIndex={0} className="outline-none"><PiInstructionSteps details={activeDetails.details} /></div>}
</div>
</section>
);
}
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
const queryClient = useQueryClient();
const [draft, setDraft] = useState<PiInstallationConfig>();
@@ -168,16 +307,6 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
},
});
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." });
@@ -206,11 +335,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
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)]">
<DialogContent showCloseButton={false} className="max-h-[calc(100vh-6rem)] max-w-[min(94vw,58rem)] overflow-y-auto 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>
<PiPlatformInstructions />
</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">
@@ -251,7 +381,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<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><h3 className="font-heading text-sm font-semibold">Installation defaults</h3><p className="mt-1 text-sm text-muted-foreground">Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.</p></div>
</div>
{draft ? (
<>
@@ -306,20 +436,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
<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>
<div><h3 className="font-heading text-sm font-semibold">Sanitized diagnostics</h3><p className="mt-1 text-sm text-muted-foreground">Shows at most 200 recent lines with declared secret values removed.</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>