feat(plugins): turn Info panel into a PI WEB status view

Rework the bundled Info plugin from a demo into an always-available
status view: running and installed versions, installation details,
release state, and per-service health rendered from host-provided
state, plus machine and workspace details. Replace the window.alert
demo action with a Copy PI WEB Diagnostics action that copies a
plain-text summary for bug reports.

Add state.selectedMachine to the stable plugin runtime state so
actions and other runtime callbacks can read the selected machine's
identity, and split the plugin into a copy-paste-friendly skeleton
(pi-web-plugin.ts) and replaceable internals (infoInternals.ts).
This commit is contained in:
Federico Jaramillo Martinez
2026-07-27 18:03:25 +02:00
parent 0a35748478
commit 8517800a24
9 changed files with 496 additions and 25 deletions
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from "vitest";
import type { PiWebComponentStatus, PiWebReleaseStatus, PiWebStatusResponse, PluginMachine, Workspace } from "@jmfederico/pi-web/plugin-api";
import { componentDetails, componentHealth, diagnosticsSummary, formatVersion, installationLabel, machineKindLabel, releaseSummary, workspaceFlags } from "./infoInternals.js";
describe("componentHealth", () => {
it("reports current when the component is available and not stale", () => {
expect(componentHealth(componentStatus())).toBe("current");
});
it("reports restart needed when the installed version is newer than the running one", () => {
expect(componentHealth(componentStatus({ stale: true }))).toBe("restart needed");
});
it("reports unavailable when the component cannot be reached", () => {
expect(componentHealth(componentStatus({ available: false, stale: true }))).toBe("unavailable");
});
});
describe("releaseSummary", () => {
it("names the latest version when an update is available", () => {
expect(releaseSummary(release({ updateAvailable: true, latestVersion: "1.2.3" }))).toBe("Update available: 1.2.3");
});
it("still reports an update when the latest version is unknown", () => {
expect(releaseSummary(release({ updateAvailable: true }))).toBe("Update available");
});
it("surfaces a failed release check", () => {
expect(releaseSummary(release({ error: "registry unreachable" }))).toBe("Update check failed: registry unreachable");
});
it("reports skipped checks distinctly from an up-to-date install", () => {
expect(releaseSummary(release({ skipped: true }))).toBe("Update check skipped");
expect(releaseSummary(release())).toBe("Up to date");
});
});
describe("componentDetails", () => {
it("combines versions, health, and installation into one line", () => {
expect(componentDetails(componentStatus())).toBe("running 1.0.0 · installed 1.0.0 · current · global npm package · /usr/lib/node_modules");
});
it("includes the component error when present", () => {
const details = componentDetails(componentStatus({ available: false, error: "connection refused" }));
expect(details).toContain("unavailable");
expect(details).toContain("error: connection refused");
});
});
describe("diagnosticsSummary", () => {
it("renders a full status block for bug reports", () => {
const summary = diagnosticsSummary({ status: statusResponse(), machine: machineFixture(), workspace: workspaceFixture() });
expect(summary).toBe([
"PI WEB diagnostics",
"Package: @jmfederico/pi-web",
"Web/UI: running 1.0.0 · installed 1.0.1 · restart needed · global npm package · /usr/lib/node_modules",
"Session daemon: running 1.0.0 · installed 1.0.0 · current · local checkout · /srv/dev/pi-web",
"Release: Update available: 1.1.0 (checked 2025-01-02T03:04:05Z)",
"Status generated: 2025-01-02T03:04:06Z",
"Machine: devbox (local machine)",
"Workspace: pi-web — /srv/dev/pi-web (branch main, git worktree, main workspace)",
].join("\n"));
});
it("degrades gracefully when the status and workspace are unavailable", () => {
const summary = diagnosticsSummary({ status: undefined });
expect(summary).toBe([
"PI WEB diagnostics",
"Status: unavailable",
"Workspace: none selected",
].join("\n"));
});
});
describe("small formatters", () => {
it("formats missing versions as unknown", () => {
expect(formatVersion(undefined)).toBe("unknown");
expect(formatVersion("")).toBe("unknown");
expect(formatVersion("1.0.0")).toBe("1.0.0");
});
it("labels installations", () => {
expect(installationLabel(undefined)).toBe("installation unknown");
expect(installationLabel({ kind: "pi-package", source: "Pi package", scope: "user" })).toBe("Pi package · user");
expect(installationLabel({ kind: "docker", dockerMode: "dev" })).toBe("Docker development runtime");
expect(installationLabel({ kind: "docker" })).toBe("Docker runtime");
expect(installationLabel({ kind: "unknown" })).toBe("installation unknown");
});
it("labels machine kinds", () => {
expect(machineKindLabel("local")).toBe("local machine");
expect(machineKindLabel("remote")).toBe("remote machine");
});
it("describes workspaces without git metadata", () => {
expect(workspaceFlags({
id: "ws-1",
projectId: "proj-1",
path: "/srv/dev/plain",
label: "plain",
isMain: false,
isGitRepo: false,
isGitWorktree: false,
})).toEqual(["not a git repo"]);
});
});
function componentStatus(patch: Partial<PiWebComponentStatus> = {}): PiWebComponentStatus {
return {
component: "web",
label: "Web/UI",
runtimeVersion: "1.0.0",
installedVersion: "1.0.0",
stale: false,
available: true,
installation: { kind: "npm-global", path: "/usr/lib/node_modules" },
...patch,
};
}
function release(patch: Partial<PiWebReleaseStatus> = {}): PiWebReleaseStatus {
return {
packageName: "@jmfederico/pi-web",
updateAvailable: false,
checkedAt: "2025-01-02T03:04:05Z",
...patch,
};
}
function statusResponse(): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt: "2025-01-02T03:04:06Z",
components: {
web: componentStatus({ installedVersion: "1.0.1", stale: true }),
sessiond: componentStatus({ component: "sessiond", label: "Session daemon", installation: { kind: "local", path: "/srv/dev/pi-web" } }),
},
release: release({ updateAvailable: true, latestVersion: "1.1.0" }),
commands: {},
messages: [],
};
}
function machineFixture(): PluginMachine {
return { id: "local", name: "devbox", kind: "local" };
}
function workspaceFixture(patch: Partial<Workspace> = {}): Workspace {
return {
id: "ws-1",
projectId: "proj-1",
path: "/srv/dev/pi-web",
label: "pi-web",
branch: "main",
isMain: true,
isGitRepo: true,
isGitWorktree: true,
...patch,
};
}
+219
View File
@@ -0,0 +1,219 @@
// Implementation details of the bundled Info plugin.
//
// This file is NOT part of the plugin skeleton. If you copied the Info plugin
// as a starting point for your own plugin, replace everything here with your
// own content — the plugin contract (metadata and contribution definitions)
// lives in pi-web-plugin.ts.
import type { TemplateResult } from "lit";
import type { HtmlTemplateTag, MachineKind, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusResponse, PluginMachine, PluginRuntimeContext, Workspace, WorkspacePanelContext } from "@jmfederico/pi-web/plugin-api";
export type ComponentHealth = "current" | "restart needed" | "unavailable";
export function componentHealth(component: PiWebComponentStatus): ComponentHealth {
if (!component.available) return "unavailable";
if (component.stale) return "restart needed";
return "current";
}
export function formatVersion(version: string | undefined): string {
return version === undefined || version === "" ? "unknown" : version;
}
export function installationLabel(installation: PiWebInstallationInfo | undefined): string {
if (installation === undefined) return "installation unknown";
if (installation.kind === "pi-package") {
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
const source = installation.source ?? "Pi package";
return `${source}${scope}`;
}
if (installation.kind === "npm-global") return "global npm package";
if (installation.kind === "local") return "local checkout";
if (installation.kind === "docker") return installation.dockerMode === "dev" ? "Docker development runtime" : "Docker runtime";
return "installation unknown";
}
export function machineKindLabel(kind: MachineKind): string {
return kind === "local" ? "local machine" : "remote machine";
}
export function releaseSummary(release: PiWebReleaseStatus): string {
if (release.updateAvailable) {
return release.latestVersion === undefined || release.latestVersion === ""
? "Update available"
: `Update available: ${release.latestVersion}`;
}
if (release.error !== undefined && release.error !== "") return `Update check failed: ${release.error}`;
if (release.skipped === true) return "Update check skipped";
return "Up to date";
}
/** One-line component summary used by the panel rows and the clipboard diagnostics. */
export function componentDetails(component: PiWebComponentStatus): string {
const parts = [
`running ${formatVersion(component.runtimeVersion)}`,
`installed ${formatVersion(component.installedVersion)}`,
componentHealth(component),
installationLabel(component.installation),
];
if (component.installation?.path !== undefined && component.installation.path !== "") parts.push(component.installation.path);
if (component.error !== undefined && component.error !== "") parts.push(`error: ${component.error}`);
return parts.join(" · ");
}
export function workspaceFlags(workspace: Workspace): string[] {
return [
workspace.branch === undefined || workspace.branch === "" ? undefined : `branch ${workspace.branch}`,
workspace.isGitWorktree ? "git worktree" : workspace.isGitRepo ? "git repo" : "not a git repo",
workspace.isMain ? "main workspace" : undefined,
].filter((flag): flag is string => flag !== undefined);
}
export interface DiagnosticsInput {
status: PiWebStatusResponse | undefined;
machine?: PluginMachine | undefined;
workspace?: Workspace | undefined;
}
/** Plain-text status block suitable for pasting into a bug report. */
export function diagnosticsSummary({ status, machine, workspace }: DiagnosticsInput): string {
const lines: string[] = ["PI WEB diagnostics"];
if (status === undefined) {
lines.push("Status: unavailable");
} else {
lines.push(`Package: ${status.packageName}`);
lines.push(`${status.components.web.label}: ${componentDetails(status.components.web)}`);
lines.push(`${status.components.sessiond.label}: ${componentDetails(status.components.sessiond)}`);
const checked = status.release.checkedAt === undefined || status.release.skipped === true ? "" : ` (checked ${status.release.checkedAt})`;
lines.push(`Release: ${releaseSummary(status.release)}${checked}`);
lines.push(`Status generated: ${status.generatedAt}`);
}
if (machine !== undefined) lines.push(`Machine: ${machine.name} (${machineKindLabel(machine.kind)})`);
if (workspace === undefined) {
lines.push("Workspace: none selected");
} else {
lines.push(`Workspace: ${workspace.label}${workspace.path} (${workspaceFlags(workspace).join(", ")})`);
}
return lines.join("\n");
}
/** Action body: copy the diagnostics summary for the current runtime context. */
export async function copyDiagnostics(context: PluginRuntimeContext): Promise<void> {
const summary = diagnosticsSummary({
status: context.state.piWebStatus,
machine: context.state.selectedMachine,
workspace: context.state.selectedWorkspace,
});
await navigator.clipboard.writeText(summary);
}
function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
const health = componentHealth(component);
return html`
<div class="info-component">
<strong>${component.label}</strong>
<span class=${health === "current" ? "info-health-ok" : "info-health-attention"}>${health}</span>
<small>${componentDetails(component)}</small>
</div>
`;
}
function renderStatusSection(html: HtmlTemplateTag, status: PiWebStatusResponse | undefined): TemplateResult {
if (status === undefined) {
return html`
<section>
<strong>PI WEB</strong>
<p class="muted">PI WEB status is not available yet. It refreshes automatically in the background.</p>
</section>
`;
}
const web = status.components.web;
const messageCount = status.messages.length;
return html`
<section>
<strong>PI WEB</strong>
<div class="info-row">
<span>Version</span>
<span>${formatVersion(web.runtimeVersion)}</span>
${web.installedVersion === undefined || web.installedVersion === web.runtimeVersion ? null : html`<small>installed ${formatVersion(web.installedVersion)}</small>`}
</div>
<div class="info-row">
<span>Package</span>
<span>${status.packageName}</span>
</div>
<div class="info-row">
<span>Installation</span>
<span>${installationLabel(web.installation)}</span>
${web.installation?.path === undefined || web.installation.path === "" ? null : html`<small>${web.installation.path}</small>`}
</div>
<div class="info-row">
<span>Release</span>
<span>${releaseSummary(status.release)}</span>
${status.release.checkedAt === undefined || status.release.skipped === true ? null : html`<small>checked ${status.release.checkedAt}</small>`}
</div>
${messageCount === 0 ? null : html`<p class="muted">${String(messageCount)} status ${messageCount === 1 ? "message" : "messages"} — open the Updates tab for details.</p>`}
<p class="muted">Status generated ${status.generatedAt}</p>
</section>
<section>
<strong>Services</strong>
${renderComponent(html, status.components.web)}
${renderComponent(html, status.components.sessiond)}
</section>
`;
}
function renderMachineSection(html: HtmlTemplateTag, machine: PluginMachine): TemplateResult {
return html`
<section>
<strong>Machine</strong>
<div class="info-row">
<span>Name</span>
<span>${machine.name}</span>
</div>
<div class="info-row">
<span>Type</span>
<span>${machineKindLabel(machine.kind)}</span>
</div>
</section>
`;
}
function renderWorkspaceSection(html: HtmlTemplateTag, workspace: Workspace): TemplateResult {
return html`
<section>
<strong>Workspace</strong>
<div class="info-row">
<span>Name</span>
<span>${workspace.label}</span>
</div>
<div class="info-row">
<span>Path</span>
<span class="info-path">${workspace.path}</span>
${workspaceFlags(workspace).length === 0 ? null : html`<small>${workspaceFlags(workspace).join(" · ")}</small>`}
</div>
</section>
`;
}
/** Panel body: render the Info tab for the current workspace panel context. */
export function renderInfoPanel(html: HtmlTemplateTag, context: WorkspacePanelContext): TemplateResult {
return html`
<style>
.viewer.info-status { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 12px; overflow-y: auto; overflow-x: hidden; }
.viewer.info-status section { flex: 0 0 auto; min-width: 0; display: grid; gap: 8px; align-content: start; }
.viewer.info-status p { margin: 0; }
.info-row { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr); gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; overflow-wrap: anywhere; }
.info-row small { grid-column: 1 / -1; color: var(--pi-muted); }
.info-component { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
.info-component small { grid-column: 1 / -1; color: var(--pi-muted); overflow-wrap: anywhere; }
.info-health-ok { color: var(--pi-success); }
.info-health-attention { color: var(--pi-warning); }
</style>
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer info-status">
${renderStatusSection(html, context.state?.piWebStatus)}
${renderMachineSection(html, context.machine)}
${renderWorkspaceSection(html, context.workspace)}
</section>
`;
}
+71
View File
@@ -0,0 +1,71 @@
import { html, svg } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntimeContext } from "@jmfederico/pi-web/plugin-api";
import plugin from "./pi-web-plugin.js";
describe("Info plugin copy-diagnostics action", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("copies a diagnostics summary to the clipboard", async () => {
const writeText = vi.fn((text: string) => { void text; return Promise.resolve(); });
vi.stubGlobal("navigator", { clipboard: { writeText } });
const action = findCopyDiagnosticsAction();
const context = runtimeContext({
state: {
selectedMachine: { id: "local", name: "devbox", kind: "local" },
selectedWorkspace: {
id: "ws-1",
projectId: "proj-1",
path: "/srv/dev/pi-web",
label: "pi-web",
branch: "main",
isMain: true,
isGitRepo: true,
isGitWorktree: false,
},
},
});
await action.run(context);
expect(writeText).toHaveBeenCalledOnce();
const summary = writeText.mock.calls[0]?.[0];
expect(summary).toContain("PI WEB diagnostics");
expect(summary).toContain("Status: unavailable");
expect(summary).toContain("Machine: devbox (local machine)");
expect(summary).toContain("Workspace: pi-web — /srv/dev/pi-web (branch main, git repo, main workspace)");
});
});
function findCopyDiagnosticsAction() {
const action = plugin.activate({ apiVersion: 1, pluginId: "info", html, svg }).contributions.actions?.find((candidate) => candidate.id === "copy-diagnostics");
if (action === undefined) throw new Error("Expected copy-diagnostics action");
return action;
}
function runtimeContext(patch: Partial<PluginRuntimeContext> = {}): PluginRuntimeContext {
const noop = () => undefined;
return {
state: {},
prompt: { insertText: noop, getText: () => "", getSelection: () => null },
openActionPalette: noop,
focusPrompt: noop,
addProject: noop,
configureAuth: noop,
logoutAuth: noop,
openThemePicker: noop,
selectMainView: noop,
selectWorkspaceTool: noop,
openTerminal: noop,
refreshFiles: noop,
refreshGit: noop,
refreshAppData: noop,
reloadPage: noop,
startSession: noop,
archiveSession: noop,
stopActiveWork: noop,
...patch,
};
}
+13 -15
View File
@@ -1,4 +1,12 @@
// Skeleton of a PI WEB plugin: metadata plus contribution definitions.
//
// Everything the bundled Info panel and action actually render lives in
// infoInternals.ts. That file is replaceable implementation detail — when
// copying this plugin as a starting point, keep this file's shape and swap
// the internals for your own.
import type { PiWebPlugin } from "@jmfederico/pi-web/plugin-api";
import { copyDiagnostics, renderInfoPanel } from "./infoInternals.js";
const plugin: PiWebPlugin = {
apiVersion: 1,
@@ -7,14 +15,11 @@ const plugin: PiWebPlugin = {
contributions: {
actions: [
{
id: "workspace.show-path",
title: "Show Current Workspace Path",
id: "copy-diagnostics",
title: "Copy PI WEB Diagnostics",
description: "Copy version, installation, and status details for this machine, ready to paste into a bug report",
group: "Info",
enabled: (context) => context.state.selectedWorkspace !== undefined,
run: (context) => {
const path = context.state.selectedWorkspace?.path ?? "No workspace selected";
window.alert(path);
},
run: (context) => copyDiagnostics(context),
},
],
workspaceLabels: [
@@ -36,14 +41,7 @@ const plugin: PiWebPlugin = {
</svg>
`,
order: 1000,
render: (context) => html`
<section class="toolbar"><strong>Info</strong></section>
<section class="viewer">
<p><strong>Workspace</strong></p>
<p class="muted">${context.workspace.label}</p>
<p class="muted">${context.workspace.path}</p>
</section>
`,
render: (context) => renderInfoPanel(html, context),
},
],
},