feat(frontend): simplify Pi operator workflow
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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'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>
|
||||
|
||||
Reference in New Issue
Block a user