From 1df881d98d130c005088485f142fc2df2bc9fe37 Mon Sep 17 00:00:00 2001 From: mptyl Date: Fri, 14 Aug 2026 18:52:11 +0200 Subject: [PATCH] feat(frontend): simplify Pi operator workflow --- frontend/src/shell/PiManagement.test.tsx | 63 ++++++--- frontend/src/shell/PiManagement.tsx | 168 +++++++++++++++++++---- 2 files changed, 192 insertions(+), 39 deletions(-) diff --git a/frontend/src/shell/PiManagement.test.tsx b/frontend/src/shell/PiManagement.test.tsx index 34d22e1c..5be1850f 100644 --- a/frontend/src/shell/PiManagement.test.tsx +++ b/frontend/src/shell/PiManagement.test.tsx @@ -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 --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 --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 --source build --yes --drain'); + expect(windows).toHaveTextContent("pi rollback --yes"); }); test("reloads installation defaults when the panel is reopened", async () => { diff --git a/frontend/src/shell/PiManagement.tsx b/frontend/src/shell/PiManagement.tsx index dd5a7816..319c5c3c 100644 --- a/frontend/src/shell/PiManagement.tsx +++ b/frontend/src/shell/PiManagement.tsx @@ -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 ; } +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 --source build --yes --drain", + pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version --source pull --image @sha256: --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 --source build --yes --drain", + pullCommand: "~/bin/thothctl --installation ~/thothii-installation.yaml pi update --version --source pull --image @sha256: --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 --source build --yes --drain', + pullCommand: '& (Resolve-Path "~\\bin\\thothctl-windows-amd64.exe") --installation (Resolve-Path "~\\thothii-installation.yaml") pi update --version --source pull --image @sha256: --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
{children}
; +} + +function PiInstructionSteps({ details }: { details: PiPlatformDetails }) { + return
    +
  1. +

    Open the project root

    +

    Open {details.terminal} in the ThothII project root. The deploy directory is in the ThothII project root, beside compose.yaml.

    +
  2. +
  3. +

    Edit the provider catalog

    +

    Edit {details.modelsPath}. It is the provider catalog.

    +
    +
    baseUrl
    is the provider API endpoint.
    +
    api
    selects the provider API format.
    +
    models
    lists that provider's available models.
    +
    id
    is the model identifier.
    +
    name
    is the model name shown to operators.
    +
    +
  4. +
  5. +

    Enable the model

    +

    Edit {details.settingsPath}. It is the enabled-model policy, not the provider catalog.

    +
    +
    enabledModels
    uses provider/model identifiers to choose the models available for new Pi work.
    +
    +
  6. +
  7. +

    Set the provider credential

    +

    PI_AUTH_FILE is a setting in the installation environment file. It selects {details.credentialProtection}; Docker mounts the selected host credential file read-only for Pi.

    +
  8. +
  9. +

    Reload Pi configuration

    +

    After changing the catalog, policy, or selected credential file, reload Pi configuration.

    + {details.restartCommand} +
  10. +
  11. +

    Update the Pi version

    +

    Use a build update only when changing the bundled Pi version.

    + {details.updateCommand} +

    Advanced: pull an immutable, digest-pinned image.

    + {details.pullCommand} +
  12. +
  13. +

    Recover a failed update

    +

    For a failed update, use rollback. For restart or update maintenance recovery after repairing the reported problem, use maintenance recovery.

    + {details.recoveryCommands} +
  14. +
; +} + +function PiPlatformInstructions() { + const [activePlatform, setActivePlatform] = useState(); + const activeDetails = piPlatforms.find((platform) => platform.id === activePlatform); + + return ( +
+

Using the host terminal:

+
+ {piPlatforms.map((platform) => ( + + ))} +
+
+ {activeDetails &&
} +
+
+ ); +} + export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) { const queryClient = useQueryClient(); const [draft, setDraft] = useState(); @@ -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 ( { if (!nextOpen) onClose(); }}> - +

Installation controls

Pi management Review the bundled runtime, set safe defaults, and test the saved provider configuration. +
@@ -251,7 +381,7 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
-

Installation defaults

These choices apply to new Pi work. Credentials remain outside this browser.

+

Installation defaults

Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.

{draft ? ( <> @@ -306,20 +436,12 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
-

Sanitized diagnostics

Inspect up to the latest 200 sanitized lines. Raw runtime output is never shown here.

+

Sanitized diagnostics

Shows at most 200 recent lines with declared secret values removed.

{logsQuery.isError &&

Could not load sanitized Pi diagnostics.

} {logsQuery.data &&
{logsQuery.data.lines.join("\n") || "No diagnostic lines are available."}
}
- -
-
-

Update Pi on the host

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.

- -
- {UPDATE_COMMAND} -
)}