From 7a8bcacbfe956fa25a0c34dae610516f21fb9003 Mon Sep 17 00:00:00 2001 From: mptyl Date: Wed, 5 Aug 2026 05:53:28 +0200 Subject: [PATCH] feat: add Pi management interface --- frontend/src/api/pi-management.test.ts | 67 +++++ frontend/src/api/pi-management.ts | 82 ++++++ .../src/shell/AppShell.session-mgmt.test.tsx | 29 +++ frontend/src/shell/AppShell.tsx | 24 +- frontend/src/shell/PiManagement.test.tsx | 170 ++++++++++++ frontend/src/shell/PiManagement.tsx | 244 ++++++++++++++++++ 6 files changed, 606 insertions(+), 10 deletions(-) create mode 100644 frontend/src/api/pi-management.test.ts create mode 100644 frontend/src/api/pi-management.ts create mode 100644 frontend/src/shell/PiManagement.test.tsx create mode 100644 frontend/src/shell/PiManagement.tsx diff --git a/frontend/src/api/pi-management.test.ts b/frontend/src/api/pi-management.test.ts new file mode 100644 index 00000000..8f33a803 --- /dev/null +++ b/frontend/src/api/pi-management.test.ts @@ -0,0 +1,67 @@ +import { http, HttpResponse } from "msw"; +import { server } from "../test/msw"; +import { + asPiManagementApiError, + getPiManagementLogs, + getPiManagementOptions, + getPiManagementStatus, + runPiManagementTest, + savePiManagementConfig, +} from "./pi-management"; + +test("Pi management client calls only the sanctioned sanitized endpoints", async () => { + const calls: Array<{ method: string; path: string; body?: unknown }> = []; + server.use( + http.get("/api/pi-management/status", ({ request }) => { + calls.push({ method: request.method, path: new URL(request.url).pathname }); + return HttpResponse.json({ ready: true, version: "0.80.3", config: {}, checkedAt: "2026-08-05T10:00:00.000Z" }); + }), + http.get("/api/pi-management/options", ({ request }) => { + calls.push({ method: request.method, path: new URL(request.url).pathname }); + return HttpResponse.json({ providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z" }); + }), + http.put("/api/pi-management/config", async ({ request }) => { + calls.push({ method: request.method, path: new URL(request.url).pathname, body: await request.json() }); + return HttpResponse.json({ provider: "zai", model: "glm-5.2", reasoning: "high", updatedAt: "2026-08-05T10:00:00.000Z" }); + }), + http.post("/api/pi-management/test", ({ request }) => { + calls.push({ method: request.method, path: new URL(request.url).pathname }); + return HttpResponse.json({ ready: true, checkedAt: "2026-08-05T10:00:00.000Z" }); + }), + http.get("/api/pi-management/logs", ({ request }) => { + calls.push({ method: request.method, path: new URL(request.url).pathname }); + return HttpResponse.json({ lines: ["Pi smoke check succeeded"], checkedAt: "2026-08-05T10:00:00.000Z" }); + }), + ); + + await expect(getPiManagementStatus()).resolves.toMatchObject({ version: "0.80.3", ready: true }); + await expect(getPiManagementOptions()).resolves.toMatchObject({ providers: ["zai"] }); + await expect(savePiManagementConfig({ provider: "zai", model: "glm-5.2", reasoning: "high" })).resolves.toMatchObject({ reasoning: "high" }); + await expect(runPiManagementTest()).resolves.toMatchObject({ ready: true }); + await expect(getPiManagementLogs()).resolves.toMatchObject({ lines: ["Pi smoke check succeeded"] }); + + expect(calls).toEqual([ + { method: "GET", path: "/api/pi-management/status" }, + { method: "GET", path: "/api/pi-management/options" }, + { method: "PUT", path: "/api/pi-management/config", body: { provider: "zai", model: "glm-5.2", reasoning: "high" } }, + { method: "POST", path: "/api/pi-management/test" }, + { method: "GET", path: "/api/pi-management/logs" }, + ]); + expect(calls.some((call) => /update|terminal|shell/i.test(call.path))).toBe(false); +}); + +test("Pi management client exposes the stable forbidden message without raw response text", async () => { + server.use(http.get("/api/pi-management/status", () => + HttpResponse.json({ code: "pi_management_forbidden", error: "Pi management is not permitted", raw: "token=do-not-show" }, { status: 403 }), + )); + + await expect(getPiManagementStatus()).rejects.toSatisfy((error: unknown) => { + expect(asPiManagementApiError(error)).toEqual({ + status: 403, + code: "pi_management_forbidden", + message: "Pi management is not permitted", + }); + expect(asPiManagementApiError(error)?.message).not.toContain("token"); + return true; + }); +}); diff --git a/frontend/src/api/pi-management.ts b/frontend/src/api/pi-management.ts new file mode 100644 index 00000000..39a13538 --- /dev/null +++ b/frontend/src/api/pi-management.ts @@ -0,0 +1,82 @@ +import { ApiError, apiFetch } from "./client"; + +export type PiReasoning = "low" | "medium" | "high"; + +export interface PiInstallationConfig { + provider: string; + model: string; + reasoning: PiReasoning; +} + +export interface PiManagementStatus { + version?: string; + ready: boolean; + config: Partial; + checkedAt: string; + message?: string; +} + +export interface PiManagementOptions { + providers: string[]; + models: Array<{ provider: string; id: string }>; + reasoning: PiReasoning[]; + checkedAt: string; +} + +export interface PiManagementTestResult { + ready: boolean; + checkedAt: string; + message?: string; +} + +export interface PiManagementLogs { + lines: string[]; + checkedAt: string; +} + +export type PiManagementApiErrorCode = + | "pi_management_forbidden" + | "pi_management_unavailable" + | "pi_management_invalid_config" + | "pi_management_write_failed"; + +export interface PiManagementApiError { + status: number; + code: PiManagementApiErrorCode; + message: string; +} + +const errorCodes = new Set([ + "pi_management_forbidden", + "pi_management_unavailable", + "pi_management_invalid_config", + "pi_management_write_failed", +]); + +function object(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : undefined; +} + +/** Narrows the sanctioned error payload without ever surfacing its raw response body. */ +export function asPiManagementApiError(error: unknown): PiManagementApiError | undefined { + if (!(error instanceof ApiError)) return undefined; + const payload = object(error.payload); + const code = payload?.code; + const message = payload?.error; + if (typeof code !== "string" || !errorCodes.has(code as PiManagementApiErrorCode) || typeof message !== "string") { + return undefined; + } + return { status: error.status, code: code as PiManagementApiErrorCode, message }; +} + +export const getPiManagementStatus = () => apiFetch("/pi-management/status"); +export const getPiManagementOptions = () => apiFetch("/pi-management/options"); +export const savePiManagementConfig = (config: PiInstallationConfig) => + apiFetch("/pi-management/config", { + method: "PUT", body: JSON.stringify(config), + }); +export const runPiManagementTest = () => + apiFetch("/pi-management/test", { method: "POST" }); +export const getPiManagementLogs = () => apiFetch("/pi-management/logs"); diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 71f909f9..947a9a35 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -80,6 +80,35 @@ test("regular users load only their sessions and never see administrator control expect(screen.queryByText(/administrator view/i)).not.toBeInTheDocument(); }); +test("opens Pi management from the session rail without replacing session controls", async () => { + const user = userEvent.setup(); + server.use( + http.get("/api/me", () => HttpResponse.json({ issuer: "portal", subject: "alice", displayName: "Alice", isAdmin: false })), + http.get("/api/sessions", () => HttpResponse.json(LIST)), + http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })), + http.get("/api/settings", () => HttpResponse.json({})), + http.get("/api/workspaces", () => HttpResponse.json([])), + http.get("/api/models", () => HttpResponse.json([])), + http.get("/api/pi-management/status", () => HttpResponse.json({ + version: "0.80.3", ready: true, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", + })), + http.get("/api/pi-management/options", () => HttpResponse.json({ + providers: ["zai"], models: [{ provider: "zai", id: "glm-5.2" }], + reasoning: ["low", "medium", "high"], checkedAt: "2026-08-05T10:00:00.000Z", + })), + ); + wrap(); + + expect(await screen.findByText("Attiva uno")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Pi management" })); + expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); + const shell = document.querySelector('[data-testid="app-shell"]'); + expect(shell).toHaveTextContent("Workspace management"); + expect(shell).toHaveTextContent("Active sessions"); +}); + test("administrators can explicitly switch to all sessions and see owners", async () => { let scope = ""; server.use( diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 295a1dcc..21af9116 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -4,6 +4,7 @@ import { WidgetHost } from "./WidgetHost"; import { CentralStatus } from "./CentralStatus"; import { ModelActivityPanel } from "./ModelActivityPanel"; import { WorkspaceManager } from "./WorkspaceManager"; +import { PiManagement } from "./PiManagement"; import { useActivityPanelResize } from "./useActivityPanelResize"; import { useSessionPanelResize } from "./useSessionPanelResize"; import { NavSessions } from "./NavSessions"; @@ -32,12 +33,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useMemo, useRef, useState } from "react"; import type { CSSProperties } from "react"; -/** - * The page is designed to live INSIDE the Omics Portal chrome (its left sidebar - * + topbar) once embedded, so this shell intentionally has no left rail and no - * top header of its own. The session rail sits on the RIGHT, mirroring the - * portal's left sidebar, and the workflow phases ride a slim strip above the chat. - */ export function AppShell() { const [panelSession, setPanelSession] = useState(null); const { @@ -53,9 +48,8 @@ export function AppShell() { resizing: sessionResizing, separatorProps: sessionSeparatorProps, } = useSessionPanelResize(containerRef, panelSession !== null); - // Publish the app area's horizontal geometry as CSS vars on : when the app - // is embedded beside the portal's left sidebar, viewport-fixed dialogs (dialog.tsx) - // must center on the ThothII area, not on the whole browser window. + // Publish the app area's horizontal geometry so viewport-fixed dialogs center on + // the application area rather than the whole browser window. useEffect(() => { const el = containerRef.current; if (!el) return; @@ -101,6 +95,7 @@ export function AppShell() { const queryClient = useQueryClient(); const [showActivity, setShowActivity] = useState(false); const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false); + const [piManagementOpen, setPiManagementOpen] = useState(false); const [activeOpen, setActiveOpen] = useState(true); const [archiveOpen, setArchiveOpen] = useState(false); const [renameTarget, setRenameTarget] = useState(null); @@ -587,7 +582,7 @@ export function AppShell() { - {/* Right session rail — symmetric to the portal's left sidebar */} + {/* Right session rail */} {!showActivity && ( )} setWorkspaceManagerOpen(false)} /> + setPiManagementOpen(false)} /> diff --git a/frontend/src/shell/PiManagement.test.tsx b/frontend/src/shell/PiManagement.test.tsx new file mode 100644 index 00000000..272061a0 --- /dev/null +++ b/frontend/src/shell/PiManagement.test.tsx @@ -0,0 +1,170 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import { server } from "../test/msw"; +import { PiManagement } from "./PiManagement"; + +const readyStatus = { + version: "0.80.3", + ready: true, + config: { provider: "zai", model: "glm-5.2", reasoning: "medium" }, + checkedAt: "2026-08-05T10:00:00.000Z", +}; + +const options = { + providers: ["zai", "deepseek"], + models: [ + { provider: "zai", id: "glm-5.2" }, + { provider: "deepseek", id: "deepseek-v4" }, + ], + reasoning: ["low", "medium", "high"], + checkedAt: "2026-08-05T10:00:00.000Z", +}; + +function renderManagement() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( undefined} />); +} + +beforeEach(() => { + server.use( + http.get("/api/pi-management/status", () => HttpResponse.json(readyStatus)), + http.get("/api/pi-management/options", () => HttpResponse.json(options)), + ); +}); + +test("loads Pi version and readiness as an accessible operational rail", async () => { + renderManagement(); + + expect(screen.getByText("Loading Pi management…")).toBeVisible(); + expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible(); + await screen.findByLabelText("Provider"); + expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("sm:max-w-[min(94vw,58rem)]"); + expect(screen.getByTestId("pi-readiness-rail")).toHaveTextContent("Runtime ready"); + expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready"); + expect(screen.getByText("Pi 0.80.3")).toBeVisible(); + expect(screen.getByText("Defaults ready")).toBeVisible(); + expect(screen.getByRole("button", { name: "Run smoke test" })).toBeVisible(); +}); + +test("uses closed provider, model, and reasoning choices without a secret field or terminal", async () => { + renderManagement(); + + const provider = await screen.findByLabelText("Provider"); + expect(provider).toHaveValue("zai"); + expect(screen.getByLabelText("Model")).toHaveValue("glm-5.2"); + expect(screen.getByLabelText("Reasoning level")).toHaveValue("medium"); + expect(within(provider).getAllByRole("option").map((option) => option.textContent)).toEqual(["zai", "deepseek"]); + 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: "Update Pi" })).not.toBeInTheDocument(); +}); + +test("saves only a selected non-secret configuration", async () => { + const user = userEvent.setup(); + let saved: unknown; + server.use(http.put("/api/pi-management/config", async ({ request }) => { + saved = await request.json(); + return HttpResponse.json({ ...saved as object, updatedAt: "2026-08-05T10:02:00.000Z" }); + })); + renderManagement(); + + await user.selectOptions(await screen.findByLabelText("Provider"), "deepseek"); + await user.selectOptions(screen.getByLabelText("Reasoning level"), "high"); + await user.click(screen.getByRole("button", { name: "Save defaults" })); + + await waitFor(() => expect(saved).toEqual({ provider: "deepseek", model: "deepseek-v4", reasoning: "high" })); + expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Defaults saved"); +}); + +test("runs a provider smoke test and reports credentials only as present or missing", async () => { + const user = userEvent.setup(); + let tests = 0; + server.use(http.post("/api/pi-management/test", () => { + tests += 1; + return HttpResponse.json(tests === 1 + ? { ready: true, checkedAt: "2026-08-05T10:02:00.000Z" } + : { ready: false, message: "Pi provider smoke check failed", checkedAt: "2026-08-05T10:03:00.000Z" }); + })); + renderManagement(); + + await user.click(await screen.findByRole("button", { name: "Run smoke test" })); + expect(await screen.findByText("Credentials present")).toBeVisible(); + await user.click(screen.getByRole("button", { name: "Run smoke test" })); + expect(await screen.findByText("Credentials missing")).toBeVisible(); + expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Pi provider smoke check failed"); +}); + +test("fetches and displays bounded sanitized diagnostic logs only on request", async () => { + const user = userEvent.setup(); + let logRequests = 0; + server.use(http.get("/api/pi-management/logs", () => { + logRequests += 1; + return HttpResponse.json({ lines: ["Pi smoke check succeeded", "provider token=[REDACTED]"], checkedAt: "2026-08-05T10:04:00.000Z" }); + })); + renderManagement(); + + await screen.findByLabelText("Provider"); + expect(logRequests).toBe(0); + await user.click(screen.getByRole("button", { name: "Show sanitized logs" })); + expect(await screen.findByLabelText("Sanitized Pi diagnostics")).toHaveTextContent("provider token=[REDACTED]"); + expect(logRequests).toBe(1); + expect(screen.queryByText("raw-provider-token")).not.toBeInTheDocument(); +}); + +test("explains forbidden management access without offering mutation controls", async () => { + server.use( + http.get("/api/pi-management/status", () => + HttpResponse.json({ code: "pi_management_forbidden", error: "Pi management is not permitted" }, { status: 403 })), + ); + renderManagement(); + + expect(await screen.findByRole("alert", { name: "Pi management unavailable" })).toHaveTextContent("Pi management is not permitted"); + expect(screen.queryByLabelText("Provider")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Save defaults" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Run smoke test" })).not.toBeInTheDocument(); +}); + +test("offers a copyable host-side Pi update instruction without an update action", 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(); +}); + +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(await screen.findByRole("button", { name: "Copy update command" })); + expect(screen.getByRole("status", { name: "Pi management feedback" })).toHaveTextContent("Could not copy the update command"); +}); + +test("reloads installation defaults when the panel is reopened", async () => { + let statusCalls = 0; + server.use(http.get("/api/pi-management/status", () => { + statusCalls += 1; + return HttpResponse.json(statusCalls === 1 + ? readyStatus + : { ...readyStatus, config: { provider: "deepseek", model: "deepseek-v4", reasoning: "high" } }); + })); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const view = render( undefined} />); + + expect(await screen.findByLabelText("Provider")).toHaveValue("zai"); + view.rerender( undefined} />); + await waitFor(() => expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument()); + view.rerender( undefined} />); + + await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek")); +}); diff --git a/frontend/src/shell/PiManagement.tsx b/frontend/src/shell/PiManagement.tsx new file mode 100644 index 00000000..770bbb15 --- /dev/null +++ b/frontend/src/shell/PiManagement.tsx @@ -0,0 +1,244 @@ +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, +} 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 CredentialState = "present" | "missing" | undefined; + +function configFrom(status: { config: Partial }, 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 errorMessage(error: unknown, fallback: string): string { + return asPiManagementApiError(error)?.message ?? fallback; +} + +function ReadinessRail({ ready, configured, credentialState }: { + ready: boolean; + configured: boolean; + credentialState: CredentialState; +}) { + return ( +
+ + + +
+ ); +} + +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
+

{label}

+

+ {state === "ready" ? : state === "attention" ? : } + {value} +

+
; +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ; +} + +export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) { + const queryClient = useQueryClient(); + const [draft, setDraft] = useState(); + const [feedback, setFeedback] = useState(); + const [credentialState, setCredentialState] = useState(); + 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 configured = Boolean(draft?.provider && draft.model && draft.reasoning); + const valid = Boolean( + draft + && optionsQuery.data?.providers.includes(draft.provider) + && optionsQuery.data.models.some((model) => model.provider === draft.provider && model.id === draft.model) + && optionsQuery.data.reasoning.includes(draft.reasoning), + ); + + useEffect(() => { + if (!open) { + setDraft(undefined); + setFeedback(undefined); + setCredentialState(undefined); + setLogsRequested(false); + queryClient.removeQueries({ queryKey: ["pi-management"] }); + } + }, [open, queryClient]); + + useEffect(() => { + if (draft || !statusQuery.data || !optionsQuery.data) return; + setDraft(configFrom(statusQuery.data, optionsQuery.data)); + }, [draft, optionsQuery.data, statusQuery.data]); + + const saveMutation = useMutation({ + mutationFn: savePiManagementConfig, + onSuccess: () => { + setFeedback({ tone: "success", message: "Defaults saved." }); + void queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] }); + }, + onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }), + }); + const smokeMutation = useMutation({ + mutationFn: runPiManagementTest, + onSuccess: (result) => { + setCredentialState(result.ready ? "present" : "missing"); + setFeedback({ tone: result.ready ? "success" : "error", message: result.message ?? (result.ready ? "Pi smoke test passed." : "Pi smoke test failed.") }); + }, + onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not run the Pi smoke test.") }), + }); + + 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 || !valid) { + setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." }); + return; + } + saveMutation.mutate(draft); + } + + const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden"; + const loading = statusQuery.isLoading || optionsQuery.isLoading || !draft; + const unavailable = statusQuery.isError || optionsQuery.isError; + + return ( + { if (!nextOpen) onClose(); }}> + + +

Installation controls

+ Pi management + Review the bundled runtime, set safe defaults, and run a sanitized provider check. +
+ +
+ {forbidden ? ( +
+

Pi management is not permitted

+

Ask an installation administrator to manage Pi defaults and diagnostics.

+
+ ) : unavailable ? ( +
+

{errorMessage(statusQuery.error ?? optionsQuery.error, "Pi management is unavailable")}

+ +
+ ) : loading ?

Loading Pi management…

: statusQuery.data && optionsQuery.data && draft && ( +
+
+
+

Bundled runtime

+

Pi {statusQuery.data.version ?? "version unavailable"}

+
+

+ {statusQuery.data.ready ? "Ready" : "Needs attention"} +

+
+ + + + {statusQuery.data.message &&

{statusQuery.data.message}

} + {feedback &&

+ {feedback.tone === "error" ? : } + {feedback.message} +

} + +
+
+

Installation defaults

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

+
+
+ + + + + + + + + +
+
+ + +
+
+ +
+
+

Sanitized diagnostics

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

+ +
+ {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} +
+
+ )} +
+
+
+ ); +}