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
+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>
`;
}