534 lines
31 KiB
TypeScript
534 lines
31 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } 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 { WorkAreaPanel } from "./WorkAreaPanel";
|
|
|
|
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 SmokeState = "passed" | "failed" | undefined;
|
|
|
|
function suggestedConfig(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 isSupportedConfig(
|
|
config: Partial<PiInstallationConfig> | undefined,
|
|
options: PiManagementOptions | undefined,
|
|
): config is PiInstallationConfig {
|
|
return Boolean(
|
|
config?.provider
|
|
&& config.model
|
|
&& config.reasoning
|
|
&& options?.providers.includes(config.provider)
|
|
&& options.models.some((model) => model.provider === config.provider && model.id === config.model)
|
|
&& options.reasoning.includes(config.reasoning),
|
|
);
|
|
}
|
|
|
|
function sameConfig(a: Partial<PiInstallationConfig> | undefined, b: PiInstallationConfig | undefined): boolean {
|
|
return Boolean(
|
|
a?.provider === b?.provider
|
|
&& a?.model === b?.model
|
|
&& a?.reasoning === b?.reasoning,
|
|
);
|
|
}
|
|
|
|
function errorMessage(error: unknown, fallback: string): string {
|
|
return asPiManagementApiError(error)?.message ?? fallback;
|
|
}
|
|
|
|
function ReadinessRail({ ready, configured, credentials, smokeState }: {
|
|
ready: boolean;
|
|
configured: boolean;
|
|
credentials: "present" | "missing";
|
|
smokeState: SmokeState;
|
|
}) {
|
|
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-4"
|
|
>
|
|
<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="Credentials"
|
|
value={`Credentials ${credentials}`}
|
|
state={credentials === "present" ? "ready" : "attention"}
|
|
/>
|
|
<RailItem
|
|
label="Configuration test"
|
|
value={smokeState === "passed" ? "Saved configuration test passed" : smokeState === "failed" ? "Saved configuration test failed" : "Saved configuration test not run"}
|
|
state={smokeState === "passed" ? "ready" : smokeState === "failed" ? "attention" : "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>;
|
|
}
|
|
|
|
type PiPlatform = "linux" | "macos" | "windows";
|
|
|
|
type PiPlatformDetails = {
|
|
modelsPath: string;
|
|
settingsPath: string;
|
|
terminal: string;
|
|
changeDirectoryCommand: string;
|
|
credentialProtection: string;
|
|
restartCommand: string;
|
|
updateCommand: string;
|
|
pullCommand: string;
|
|
diagnosticCommands: string;
|
|
rollbackCommand: string;
|
|
recoverCommand: string;
|
|
verificationCommands: 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",
|
|
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
|
|
credentialProtection: "a protected host file with mode 0600",
|
|
restartCommand: "tht pi restart --yes --drain",
|
|
updateCommand: "tht pi update",
|
|
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
|
|
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
|
|
rollbackCommand: "tht pi rollback --yes",
|
|
recoverCommand: "tht pi maintenance recover --yes",
|
|
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
|
|
},
|
|
},
|
|
{
|
|
id: "macos",
|
|
label: "macOS",
|
|
details: {
|
|
modelsPath: "deploy/pi/models.json",
|
|
settingsPath: "deploy/pi/settings.json",
|
|
terminal: "Terminal",
|
|
changeDirectoryCommand: "cd /absolute/path/to/ThothII",
|
|
credentialProtection: "a protected host file with mode 0600",
|
|
restartCommand: "tht pi restart --yes --drain",
|
|
updateCommand: "tht pi update",
|
|
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
|
|
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
|
|
rollbackCommand: "tht pi rollback --yes",
|
|
recoverCommand: "tht pi maintenance recover --yes",
|
|
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
|
|
},
|
|
},
|
|
{
|
|
id: "windows",
|
|
label: "Windows",
|
|
details: {
|
|
modelsPath: "deploy\\pi\\models.json",
|
|
settingsPath: "deploy\\pi\\settings.json",
|
|
terminal: "PowerShell",
|
|
changeDirectoryCommand: "Set-Location C:\\absolute\\path\\to\\ThothII",
|
|
credentialProtection: "a protected host file with a user-only ACL",
|
|
restartCommand: "tht pi restart --yes --drain",
|
|
updateCommand: "tht pi update",
|
|
pullCommand: "tht pi update --version <VERSION> --source pull --image <IMAGE>@sha256:<DIGEST> --yes --drain",
|
|
diagnosticCommands: "tht pi maintenance status\ntht pi status\ntht pi doctor\ntht pi logs",
|
|
rollbackCommand: "tht pi rollback --yes",
|
|
recoverCommand: "tht pi maintenance recover --yes",
|
|
verificationCommands: "tht pi maintenance status\ntht pi doctor\ntht pi test",
|
|
},
|
|
},
|
|
];
|
|
|
|
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">Using {details.terminal}, before editing files or running any lifecycle command, enter the exact checkout or worktree root. Run the platform command below, then run every command in this list from that directory: <code>tht</code> resolves the installation descriptor and configuration from the selected project. A worktree is an independent checkout, so this prevents changing a different copy by mistake.</p>
|
|
<PiCodeBlock className="mt-2">{details.changeDirectoryCommand}</PiCodeBlock>
|
|
<p className="mt-2 text-muted-foreground">The relevant layout is:</p>
|
|
<PiCodeBlock className="mt-1">project-root/
|
|
├── compose.yaml
|
|
├── deploy/
|
|
│ └── pi/
|
|
│ ├── models.json
|
|
│ └── settings.json
|
|
└── docker/</PiCodeBlock>
|
|
<p className="mt-2 text-muted-foreground">The <code>deploy/</code> directory contains the selected installation descriptor and profile files. You do not need to create or manage a <code>bin/</code> directory: <code>tht</code> is the installed host CLI. If discovery finds multiple descriptors, pass the intended one explicitly with <code>--installation <absolute-path>/thothii-installation.yaml</code>. If <code>tht</code> is not on your <code>PATH</code>, install it using the installation guide.</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> only when adding or correcting a provider/model definition. The provider catalog is an address book/map of the services Pi can call: each entry supplies the API endpoint and format, and lists the model identifiers offered there. It does not enable a model and never contains credentials. Provider integrations must remain declarative; do not add model-provider code under <code>harness/.pi/extensions/</code>.</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">Enable a model after the provider setup is ready. For a custom provider, first define the provider and model ID in <code>{details.modelsPath}</code>; built-in Pi models may already be known without a local catalog entry. Then edit <code>{details.settingsPath}</code> in the project root, add the exact <code>provider/model</code> identifier to <code>enabledModels</code>, and reload Pi. Do not edit it merely to choose the default provider/model: use this page or <code>tht pi configure</code>.</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">Complete the provider setup</h4>
|
|
<p className="mt-1 text-muted-foreground">During the initial installation, run <code>tht setup</code> and complete the prompt named “Pi credentials file location”; it can create the empty protected template. On an existing installation with a missing credential, correct the protected credential file selected during setup by following the installation guide. Keep {details.credentialProtection}, then restart and run <code>tht pi doctor</code> and <code>tht pi test</code>. Never paste credentials into either JSON file, the page, a command, or a log.</p>
|
|
</li>
|
|
<li>
|
|
<h4 className="font-semibold text-foreground">Reload Pi configuration</h4>
|
|
<p className="mt-1 text-muted-foreground">After changing <code>models.json</code>, <code>settings.json</code>, or the provider credential, reload the running <code>core</code> service so it reads the new files. This is a configuration reload, not a Pi version update; it keeps the current image.</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 <code>tht pi update</code> for the routine build: it resolves the latest stable Pi release from the registry, builds it locally, drains active sessions, and recreates only <code>core</code>. Add <code>--version <VERSION></code> when an explicitly reviewed version is required. The digest-pinned <code>--source pull</code> form is for an already-built, reviewed registry image; its declared version must match the digest-pinned image. Configuration changes use Reload above, not update.</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">Diagnose and recover a failed operation</h4>
|
|
<p className="mt-1 text-muted-foreground">Start with these diagnostics, in order:</p>
|
|
<PiCodeBlock className="mt-2 text-[11px] text-muted-foreground">{details.diagnosticCommands}</PiCodeBlock>
|
|
<dl className="mt-2 grid gap-2 text-muted-foreground">
|
|
<dt className="font-semibold text-foreground">Configuration</dt><dd>Invalid JSON: fix the reported file and validate it. A provider/model ID mismatch or a model missing from <code>enabledModels</code>: correct the IDs or policy, then reload.</dd>
|
|
<dt className="font-semibold text-foreground">Credentials</dt><dd>Missing or unreadable credential: correct the protected credential file selected during setup and its permissions, without printing the file.</dd>
|
|
<dt className="font-semibold text-foreground">Provider connectivity</dt><dd>Wrong <code>baseUrl</code>, network, or provider error: correct the endpoint or network, then retry.</dd>
|
|
<dt className="font-semibold text-foreground">Docker and disk</dt><dd>Unhealthy Docker or insufficient disk: restore Docker health or free space before retrying.</dd>
|
|
</dl>
|
|
<p className="mt-2 text-muted-foreground">If maintenance is inactive, no recovery is needed: correct the cause and retry the original command.</p>
|
|
<p className="mt-2 text-muted-foreground">If maintenance is active after an update, rollback the previous image:</p>
|
|
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.rollbackCommand}</PiCodeBlock>
|
|
<p className="mt-2 text-muted-foreground">If maintenance is active after a restart, fix the cause first, then recover the captured lifecycle state:</p>
|
|
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.recoverCommand}</PiCodeBlock>
|
|
<p className="mt-2 text-muted-foreground">After the selected remedy, verify the installation:</p>
|
|
<PiCodeBlock className="mt-1 text-[11px] text-muted-foreground">{details.verificationCommands}</PiCodeBlock>
|
|
</li>
|
|
</ol>;
|
|
}
|
|
|
|
function PiPlatformInstructions() {
|
|
const [activePlatform, setActivePlatform] = useState<PiPlatform>();
|
|
const [focusedPlatform, setFocusedPlatform] = useState<PiPlatform>("linux");
|
|
const tabRefs = useRef<Record<PiPlatform, HTMLButtonElement | null>>({ linux: null, macos: null, windows: null });
|
|
|
|
function activateAndFocus(platform: PiPlatform) {
|
|
setFocusedPlatform(platform);
|
|
setActivePlatform(platform);
|
|
tabRefs.current[platform]?.focus();
|
|
}
|
|
|
|
function handleTabKeyDown(event: React.KeyboardEvent<HTMLButtonElement>, platform: PiPlatform) {
|
|
const index = piPlatforms.findIndex((item) => item.id === platform);
|
|
const nextPlatform = event.key === "ArrowRight"
|
|
? piPlatforms[(index + 1) % piPlatforms.length]
|
|
: event.key === "ArrowLeft"
|
|
? piPlatforms[(index - 1 + piPlatforms.length) % piPlatforms.length]
|
|
: event.key === "Home"
|
|
? piPlatforms[0]
|
|
: event.key === "End"
|
|
? piPlatforms[piPlatforms.length - 1]
|
|
: undefined;
|
|
if (!nextPlatform) return;
|
|
event.preventDefault();
|
|
activateAndFocus(nextPlatform.id);
|
|
}
|
|
|
|
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`}
|
|
ref={(element) => { tabRefs.current[platform.id] = element; }}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={activePlatform === platform.id}
|
|
aria-controls={`pi-${platform.id}-panel`}
|
|
tabIndex={focusedPlatform === platform.id ? 0 : -1}
|
|
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"}
|
|
onFocus={() => setFocusedPlatform(platform.id)}
|
|
onKeyDown={(event) => handleTabKeyDown(event, platform.id)}
|
|
onClick={() => {
|
|
setFocusedPlatform(platform.id);
|
|
setActivePlatform((current) => current === platform.id ? undefined : 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">
|
|
{piPlatforms.map((platform) => (
|
|
<div
|
|
key={platform.id}
|
|
id={`pi-${platform.id}-panel`}
|
|
role="tabpanel"
|
|
aria-label={platform.label}
|
|
aria-labelledby={`pi-${platform.id}-tab`}
|
|
tabIndex={activePlatform === platform.id ? 0 : -1}
|
|
hidden={activePlatform !== platform.id}
|
|
className="outline-none"
|
|
>
|
|
<PiInstructionSteps details={platform.details} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export function PiManagement({ open, onClose }: { open: boolean; onClose: () => void }) {
|
|
const queryClient = useQueryClient();
|
|
const [draft, setDraft] = useState<PiInstallationConfig>();
|
|
const [feedback, setFeedback] = useState<Feedback>();
|
|
const [smokeState, setSmokeState] = useState<SmokeState>();
|
|
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 initialDraft = useMemo(
|
|
() => statusQuery.data && optionsQuery.data
|
|
? suggestedConfig(statusQuery.data, optionsQuery.data)
|
|
: undefined,
|
|
[optionsQuery.data, statusQuery.data],
|
|
);
|
|
const persistedReady = isSupportedConfig(statusQuery.data?.config, optionsQuery.data);
|
|
const validDraft = isSupportedConfig(draft, optionsQuery.data);
|
|
const dirty = Boolean(draft && !sameConfig(statusQuery.data?.config, draft));
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setDraft(undefined);
|
|
setFeedback(undefined);
|
|
setSmokeState(undefined);
|
|
setLogsRequested(false);
|
|
queryClient.removeQueries({ queryKey: ["pi-management"] });
|
|
}
|
|
}, [open, queryClient]);
|
|
|
|
useEffect(() => {
|
|
if (draft || !initialDraft) return;
|
|
setDraft(initialDraft);
|
|
}, [draft, initialDraft]);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: savePiManagementConfig,
|
|
onSuccess: async (saved) => {
|
|
const config = { provider: saved.provider, model: saved.model, reasoning: saved.reasoning };
|
|
setDraft(config);
|
|
setSmokeState(undefined);
|
|
await queryClient.invalidateQueries({ queryKey: ["pi-management", "status"] });
|
|
setFeedback({ tone: "success", message: "Defaults saved." });
|
|
},
|
|
onError: (error) => setFeedback({ tone: "error", message: errorMessage(error, "Could not save Pi defaults.") }),
|
|
});
|
|
const smokeMutation = useMutation({
|
|
mutationFn: runPiManagementTest,
|
|
onSuccess: (result) => {
|
|
setSmokeState(result.ready ? "passed" : "failed");
|
|
setFeedback({
|
|
tone: result.ready ? "success" : "error",
|
|
message: result.ready
|
|
? "Saved configuration test passed."
|
|
: `Saved configuration test failed.${result.message ? ` ${result.message}` : ""}`,
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
setSmokeState("failed");
|
|
setFeedback({ tone: "error", message: errorMessage(error, "Could not test the saved Pi defaults.") });
|
|
},
|
|
});
|
|
|
|
function saveDefaults() {
|
|
if (!draft || !validDraft) {
|
|
setFeedback({ tone: "error", message: "Choose a supported provider, model, and reasoning level." });
|
|
return;
|
|
}
|
|
saveMutation.mutate(draft);
|
|
}
|
|
|
|
function updateDraft(update: (current: PiInstallationConfig) => PiInstallationConfig) {
|
|
setDraft((current) => current ? update(current) : current);
|
|
setSmokeState(undefined);
|
|
setFeedback(undefined);
|
|
}
|
|
|
|
function testSavedDefaults() {
|
|
if (!persistedReady || dirty) {
|
|
setFeedback({ tone: "error", message: "Save supported defaults before running the test." });
|
|
return;
|
|
}
|
|
smokeMutation.mutate();
|
|
}
|
|
|
|
const forbidden = asPiManagementApiError(statusQuery.error)?.code === "pi_management_forbidden";
|
|
const loading = statusQuery.isLoading || optionsQuery.isLoading || Boolean(!draft && initialDraft);
|
|
const unavailable = statusQuery.isError || optionsQuery.isError;
|
|
|
|
return (
|
|
<WorkAreaPanel
|
|
open={open}
|
|
ariaLabel="Pi management"
|
|
eyebrow="Installation controls"
|
|
title="Pi management"
|
|
description="Review the bundled runtime, set safe defaults, and test the saved provider configuration."
|
|
onClose={onClose}
|
|
closeLabel="Close Pi management"
|
|
>
|
|
<PiPlatformInstructions />
|
|
<div className="mt-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 && (
|
|
<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={persistedReady}
|
|
credentials={statusQuery.data.credentials ?? "missing"}
|
|
smokeState={smokeState}
|
|
/>
|
|
|
|
{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">Select the provider, model, and reasoning used for new Pi work. Credentials stay in protected host files.</p></div>
|
|
</div>
|
|
{draft ? (
|
|
<>
|
|
<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;
|
|
updateDraft((current) => ({
|
|
...current,
|
|
provider,
|
|
model: optionsQuery.data.models.find((model) => model.provider === provider)?.id ?? "",
|
|
}));
|
|
}}>
|
|
{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) => updateDraft((current) => ({ ...current, model: event.target.value }))}>
|
|
{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) => updateDraft((current) => ({ ...current, reasoning: event.target.value as PiInstallationConfig["reasoning"] }))}>
|
|
{optionsQuery.data.reasoning.map((reasoning) => <option key={reasoning} value={reasoning}>{reasoning}</option>)}
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
{dirty && <p className="mt-3 text-sm text-amber-800 dark:text-amber-300">
|
|
{persistedReady ? "Changes are not saved yet." : "Suggested choices are not saved yet."}
|
|
</p>}
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" disabled={saveMutation.isPending || !validDraft || !dirty} onClick={saveDefaults}>{saveMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <ClipboardCheck />}Save defaults</Button>
|
|
<Button size="sm" variant="outline" disabled={smokeMutation.isPending || dirty || !persistedReady} onClick={testSavedDefaults}>{smokeMutation.isPending ? <LoaderCircle className="animate-spin motion-reduce:animate-none" /> : <FlaskConical />}Test saved defaults</Button>
|
|
</div>
|
|
{dirty && <p className="mt-2 text-xs text-muted-foreground">Save these changes before testing. The test always uses saved defaults.</p>}
|
|
{!dirty && !persistedReady && <p className="mt-2 text-xs text-muted-foreground">Save supported defaults before testing. The test always uses saved defaults.</p>}
|
|
</>
|
|
) : (
|
|
<div className="mt-4">
|
|
<div role="alert" aria-label="Pi configuration incomplete" className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm text-amber-900 dark:text-amber-200">
|
|
<p className="font-semibold">No provider or model options are available.</p>
|
|
<p className="mt-1">Check the host-managed Pi model configuration, then retry this panel.</p>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap gap-2">
|
|
<Button size="sm" variant="outline" disabled><ClipboardCheck />Save defaults</Button>
|
|
<Button size="sm" variant="outline" disabled><FlaskConical />Test saved defaults</Button>
|
|
<Button size="sm" variant="ghost" onClick={() => { void statusQuery.refetch(); void optionsQuery.refetch(); }}>Retry choices</Button>
|
|
</div>
|
|
</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">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>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</WorkAreaPanel>
|
|
);
|
|
}
|