feat: add Pi management interface
This commit is contained in:
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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<PiInstallationConfig>;
|
||||
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<PiManagementApiErrorCode>([
|
||||
"pi_management_forbidden",
|
||||
"pi_management_unavailable",
|
||||
"pi_management_invalid_config",
|
||||
"pi_management_write_failed",
|
||||
]);
|
||||
|
||||
function object(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: 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<PiManagementStatus>("/pi-management/status");
|
||||
export const getPiManagementOptions = () => apiFetch<PiManagementOptions>("/pi-management/options");
|
||||
export const savePiManagementConfig = (config: PiInstallationConfig) =>
|
||||
apiFetch<PiInstallationConfig & { updatedAt: string }>("/pi-management/config", {
|
||||
method: "PUT", body: JSON.stringify(config),
|
||||
});
|
||||
export const runPiManagementTest = () =>
|
||||
apiFetch<PiManagementTestResult>("/pi-management/test", { method: "POST" });
|
||||
export const getPiManagementLogs = () => apiFetch<PiManagementLogs>("/pi-management/logs");
|
||||
@@ -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(
|
||||
|
||||
@@ -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<SessionSummary | null>(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 <html>: 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<SessionSummary | null>(null);
|
||||
@@ -587,7 +582,7 @@ export function AppShell() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right session rail — symmetric to the portal's left sidebar */}
|
||||
{/* Right session rail */}
|
||||
{!showActivity && (
|
||||
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
|
||||
<div className="relative px-4 pb-3 pt-5 text-center">
|
||||
@@ -618,6 +613,14 @@ export function AppShell() {
|
||||
>
|
||||
Workspace management
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setPiManagementOpen(true)}
|
||||
>
|
||||
Pi management
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{principal?.isAdmin && (
|
||||
@@ -769,6 +772,7 @@ export function AppShell() {
|
||||
</aside>
|
||||
)}
|
||||
<WorkspaceManager open={workspaceManagerOpen} onClose={() => setWorkspaceManagerOpen(false)} />
|
||||
<PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />
|
||||
|
||||
<Toaster />
|
||||
|
||||
|
||||
@@ -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(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
}
|
||||
|
||||
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(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
|
||||
expect(await screen.findByLabelText("Provider")).toHaveValue("zai");
|
||||
view.rerender(<QueryClientProvider client={client}><PiManagement open={false} onClose={() => undefined} /></QueryClientProvider>);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Pi management" })).not.toBeInTheDocument());
|
||||
view.rerender(<QueryClientProvider client={client}><PiManagement open onClose={() => undefined} /></QueryClientProvider>);
|
||||
|
||||
await waitFor(() => expect(screen.getByLabelText("Provider")).toHaveValue("deepseek"));
|
||||
});
|
||||
@@ -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<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 errorMessage(error: unknown, fallback: string): string {
|
||||
return asPiManagementApiError(error)?.message ?? fallback;
|
||||
}
|
||||
|
||||
function ReadinessRail({ ready, configured, credentialState }: {
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
credentialState: CredentialState;
|
||||
}) {
|
||||
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-3"
|
||||
>
|
||||
<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="Provider smoke"
|
||||
value={credentialState === "present" ? "Credentials present" : credentialState === "missing" ? "Credentials missing" : "Run smoke test"}
|
||||
state={credentialState === "missing" ? "attention" : credentialState === "present" ? "ready" : "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 [credentialState, setCredentialState] = useState<CredentialState>();
|
||||
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 (
|
||||
<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 run a sanitized provider check.</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 && draft && (
|
||||
<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={configured} credentialState={credentialState} />
|
||||
|
||||
{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>
|
||||
<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;
|
||||
setDraft((current) => current ? { ...current, provider, model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "" } : current);
|
||||
}}>
|
||||
{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) => setDraft((current) => current ? { ...current, model: event.target.value } : current)}>
|
||||
{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) => setDraft((current) => current ? { ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] } : current)}>
|
||||
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={saveMutation.isPending} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
||||
<Button size="sm" variant="outline" disabled={smokeMutation.isPending} onClick={() => smokeMutation.mutate()}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Run smoke test</Button>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user