feat: add Pi Web status panel

This commit is contained in:
Federico Jaramillo Martinez
2026-05-19 09:27:10 +02:00
parent c5dc6550cc
commit babb802912
19 changed files with 803 additions and 16 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Add a beta-labeled Pi Web status panel with update instructions tailored to global npm, Pi package, or local installs. The panel appears for update/restart messages and stays visible for local or unknown installs, while keeping the bundled Info plugin as the minimal documented plugin example.
+1 -1
View File
@@ -101,7 +101,7 @@ Pi Web keeps its own state intentionally small:
Pi Web production installs can load trusted local UI plugins without rebuilding Pi Web. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed. Pi Web production installs can load trusted local UI plugins without rebuilding Pi Web. Plugins are browser-side ES modules that can add action-palette actions, workspace panels, and workspace-label metadata. They do not run in the session daemon and are not sandboxed.
The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical real example. The supported package shape is intentionally singular: `piWeb.plugins` entries with explicit `id` and `module`, plus a browser module that exports `{ apiVersion: 1, name, activate }`. The bundled `pi-web-plugins/info` plugin is the canonical minimal real example, and `pi-web-plugins/pi-web` demonstrates a dynamic status panel.
A useful prompt for AI agents: A useful prompt for AI agents:
+6 -2
View File
@@ -140,8 +140,8 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<section id="example"> <section id="example">
<h2>Canonical example</h2> <h2>Canonical example</h2>
<p> <p>
Pi Web ships a real bundled <strong>Info</strong> plugin. It is the reference example because it uses all Pi Web ships a real bundled <strong>Info</strong> plugin. It is intentionally small while still using all
current contribution types: one action, one workspace label, and one workspace panel. core contribution types: one action, one workspace label, and one workspace panel.
</p> </p>
<ul> <ul>
<li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li> <li><code>pi-web-plugins/info/package.json</code> shows the required metadata shape.</li>
@@ -152,6 +152,10 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<a href="https://github.com/jmfederico/pi-web/tree/main/pi-web-plugins/info">pi-web-plugins/info</a>. <a href="https://github.com/jmfederico/pi-web/tree/main/pi-web-plugins/info">pi-web-plugins/info</a>.
If you copy it, choose a new plugin id so it does not conflict with the bundled <code>info</code> plugin. If you copy it, choose a new plugin id so it does not conflict with the bundled <code>info</code> plugin.
</p> </p>
<p>
The bundled <code>pi-web</code> status plugin demonstrates dynamic <code>visible</code> and <code>badge</code>
callbacks for tabs that only appear when the host has status messages or needs extra install visibility.
</p>
</section> </section>
<section id="production"> <section id="production">
+3 -1
View File
@@ -63,7 +63,7 @@ After editing, check the manifest endpoint and browser-console failure cases.
## Canonical example: bundled Info plugin ## Canonical example: bundled Info plugin
Pi Web ships a real bundled `info` plugin. Use it as the reference example because it exercises all current contribution types: an action, a workspace label, and a workspace panel. Pi Web ships a real bundled `info` plugin. Use it as the reference example because it is intentionally small while still exercising all core contribution types: an action, a workspace label, and a workspace panel.
Files: Files:
@@ -104,6 +104,8 @@ export default {
When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin. When copying the Info plugin, choose a new plugin id so it does not conflict with the bundled `info` plugin.
Pi Web also ships a `pi-web` status plugin that demonstrates dynamic `visible` and `badge` callbacks for tabs that only appear when the host has status messages or needs extra install visibility.
## Local plugin usage ## Local plugin usage
This works with the production npm/systemd install. Pi Web discovers plugins from `~/.pi-web/plugins/<plugin-package>/` on the web/API side; no Pi Web rebuild or session-daemon restart is required. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead. This works with the production npm/systemd install. Pi Web discovers plugins from `~/.pi-web/plugins/<plugin-package>/` on the web/API side; no Pi Web rebuild or session-daemon restart is required. If `PI_WEB_DATA_DIR` is set, use `$PI_WEB_DATA_DIR/plugins` instead.
+2 -2
View File
@@ -17,7 +17,7 @@ export default {
], ],
workspaceLabels: [ workspaceLabels: [
{ {
id: "workspace.path-label", id: "workspace.kind-label",
order: 100, order: 100,
items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }], items: (context) => [{ type: "text", text: context.workspace.isGitRepo ? "git" : "folder", title: context.workspace.path }],
}, },
@@ -26,7 +26,7 @@ export default {
{ {
id: "workspace.info", id: "workspace.info",
title: "Info", title: "Info",
order: 100, order: 1000,
render: (context) => html` render: (context) => html`
<section class="toolbar"><strong>Info</strong></section> <section class="toolbar"><strong>Info</strong></section>
<section class="viewer"> <section class="viewer">
+9
View File
@@ -0,0 +1,9 @@
{
"name": "@pi-web/status-plugin",
"private": true,
"piWeb": {
"plugins": [
{ "id": "pi-web", "module": "pi-web-plugin.js" }
]
}
}
+148
View File
@@ -0,0 +1,148 @@
function messagesFor(state) {
return state?.piWebStatus?.messages ?? [];
}
function statusFor(state) {
return state?.piWebStatus;
}
function messageCount(state) {
return messagesFor(state).length;
}
function isLocalOrUnknownInstallation(installation) {
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
}
function shouldShowStatusPanel(state) {
const status = statusFor(state);
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
return isLocalOrUnknownInstallation(status.components.web.installation)
|| isLocalOrUnknownInstallation(status.components.sessiond.installation);
}
function formatVersion(version) {
return version === undefined || version === "" ? "unknown" : version;
}
function installationLabel(installation) {
if (installation === undefined) return "installation unknown";
if (installation.kind === "pi-package") {
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
const source = installation.source === undefined ? "Pi package" : installation.source;
return `${source}${scope}`;
}
if (installation.kind === "npm-global") return "global npm package";
if (installation.kind === "local") return "local checkout";
return "installation unknown";
}
function renderComponent(html, component) {
const status = component.available === false
? "unavailable"
: component.stale
? "restart needed"
: "current";
return html`
<div class="pi-web-version-row">
<strong>${component.label}</strong>
<span>${status}</span>
<small>running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)}</small>
<small>${installationLabel(component.installation)}${component.installation?.path === undefined ? "" : ` · ${component.installation.path}`}</small>
</div>
`;
}
function renderCommand(html, label, command) {
return html`
<div class="pi-web-command">
<span>${label}</span>
<code>${command}</code>
<button @click=${() => { void navigator.clipboard?.writeText(command); }}>Copy</button>
</div>
`;
}
function renderStatusPanel(html, state) {
const status = statusFor(state);
if (status === undefined) {
return html`
<section class="toolbar"><strong>Pi Web</strong></section>
<section class="viewer"><p class="muted">Checking Pi Web status…</p></section>
`;
}
const messages = status.messages;
return html`
<style>
.pi-web-status { gap: 14px; padding: 12px; overflow: auto; }
.pi-web-status section { display: grid; gap: 8px; }
.pi-web-message { display: grid; gap: 5px; border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); }
.pi-web-message.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
.pi-web-message.error { border-color: var(--pi-danger); }
.pi-web-message-title { display: flex; gap: 8px; align-items: baseline; }
.pi-web-message-title span { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
.pi-web-version-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
.pi-web-version-row small { grid-column: 1 / -1; color: var(--pi-muted); }
.pi-web-command { display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; }
.pi-web-command code { overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); padding: 5px 7px; white-space: nowrap; }
.pi-web-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; }
</style>
<section class="toolbar"><strong>Pi Web</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
<section class="viewer pi-web-status">
<section>
${messages.length === 0 ? html`<p class="muted">No Pi Web update or restart messages.</p>` : messages.map((message) => html`
<article class=${`pi-web-message ${message.severity}`}>
<div class="pi-web-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
<p>${message.body}</p>
${message.command === undefined ? null : html`<code>${message.command}</code>`}
</article>
`)}
</section>
<section>
<strong>Installed services</strong>
${renderComponent(html, status.components.web)}
${renderComponent(html, status.components.sessiond)}
</section>
<section>
<strong>Commands</strong>
${renderCommand(html, "Update", status.commands.update)}
${renderCommand(html, "Restart", status.commands.restart)}
${renderCommand(html, "systemd", status.commands.restartSystemd)}
${renderCommand(html, "dev", status.commands.restartDev)}
</section>
<section class="pi-web-meta">
<span>Generated ${status.generatedAt}</span>
${status.release.latestVersion === undefined ? null : html`<span>Latest npm release ${status.release.latestVersion}</span>`}
${status.release.skipped === true ? html`<span>Remote version check skipped.</span>` : null}
${status.release.error === undefined ? null : html`<span>Remote version check failed: ${status.release.error}</span>`}
</section>
</section>
`;
}
export default {
apiVersion: 1,
name: "Pi Web Status",
activate: ({ html }) => ({
contributions: {
workspacePanels: [
{
id: "workspace.status",
title: "Pi Web",
order: 100,
visible: (context) => shouldShowStatusPanel(context.state),
badge: (context) => {
const count = messageCount(context.state);
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
},
render: (context) => renderStatusPanel(html, context.state),
},
],
},
}),
};
+2 -2
View File
@@ -1,3 +1,3 @@
export { api, filesApi, gitApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients"; export { api, filesApi, gitApi, piWebApi, projectsApi, sessionsApi, terminalsApi, workspacesApi } from "./api/clients";
export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets"; export { globalSessionEvents, realtimeEvents, sessionEvents, terminalSocket } from "./api/sockets";
export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes"; export type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebStatusMessage, PiWebStatusResponse, Project, QueuedSessionMessage, RealtimeEvent, SessionActivity, SessionInfo, SessionModel, SessionStatus, SlashCommand, SessionUiEvent, TerminalInfo, TerminalUiEvent, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../shared/apiTypes";
+6
View File
@@ -17,6 +17,7 @@ import {
parseMessagePage, parseMessagePage,
parseModelSelectionResponse, parseModelSelectionResponse,
parseOAuthFlowState, parseOAuthFlowState,
parsePiWebStatusResponse,
parseProject, parseProject,
parseRestored, parseRestored,
parseSessionInfo, parseSessionInfo,
@@ -29,6 +30,10 @@ import {
} from "./parsers"; } from "./parsers";
import { gitDiffUrl, messageUrl } from "./urls"; import { gitDiffUrl, messageUrl } from "./urls";
export const piWebApi = {
piWebStatus: () => request("/api/pi-web/status", parsePiWebStatusResponse),
};
export const projectsApi = { export const projectsApi = {
projects: () => request("/api/projects", arrayOf(parseProject)), projects: () => request("/api/projects", arrayOf(parseProject)),
addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }), addProject: (path: string, name?: string, create?: boolean) => request("/api/projects", parseProject, { method: "POST", body: JSON.stringify({ path, name, create }) }),
@@ -94,6 +99,7 @@ export const gitApi = {
}; };
export const api = { export const api = {
...piWebApi,
...projectsApi, ...projectsApi,
...workspacesApi, ...workspacesApi,
...sessionsApi, ...sessionsApi,
+86 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes"; import type { AuthProviderOption, AuthProviderStatus, AuthProvidersResponse, AuthStatusSource, AuthType, CommandOption, CommandResult, FileContentResponse, FileSuggestion, FileTreeEntry, FileTreeResponse, GitDiffResponse, GitFileState, GitStatusFile, GitStatusResponse, MessagePage, ModelSelectionResponse, OAuthFlowState, PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebStatusSeverity, Project, QueuedSessionMessage, SessionInfo, SessionModel, SessionStatus, SlashCommand, TerminalInfo, ThinkingLevel, ThinkingLevelsResponse, Workspace } from "../../../shared/apiTypes";
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
@@ -298,6 +298,91 @@ export function parseTerminalInfo(value: unknown): TerminalInfo {
return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) }; return { id: requireString(record, "id"), cwd: requireString(record, "cwd"), name: requireString(record, "name"), createdAt: requireString(record, "createdAt"), exited: requireBoolean(record, "exited"), ...optionalField("exitCode", optionalNumber(record, "exitCode")) };
} }
export function parsePiWebStatusResponse(value: unknown): PiWebStatusResponse {
const record = requireRecord(value);
return {
packageName: requireString(record, "packageName"),
generatedAt: requireString(record, "generatedAt"),
components: parsePiWebComponents(record["components"]),
release: parsePiWebReleaseStatus(record["release"]),
commands: parsePiWebCommands(record["commands"]),
messages: arrayOf(parsePiWebStatusMessage)(record["messages"]),
};
}
function parsePiWebComponents(value: unknown): PiWebStatusResponse["components"] {
const record = requireRecord(value);
return { web: parsePiWebComponentStatus(record["web"]), sessiond: parsePiWebComponentStatus(record["sessiond"]) };
}
function parsePiWebComponentStatus(value: unknown): PiWebComponentStatus {
const record = requireRecord(value);
return {
component: parsePiWebServiceComponent(record["component"]),
label: requireString(record, "label"),
...optionalField("runtimeVersion", optionalString(record, "runtimeVersion")),
...optionalField("installedVersion", optionalString(record, "installedVersion")),
stale: requireBoolean(record, "stale"),
available: requireBoolean(record, "available"),
...optionalField("installation", optionalPiWebInstallationInfo(record["installation"])),
...optionalField("error", optionalString(record, "error")),
};
}
function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid Pi Web installation kind");
const scope = record["scope"];
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid Pi Web installation scope");
return {
kind,
...optionalField("path", optionalString(record, "path")),
...optionalField("source", optionalString(record, "source")),
...(scope === undefined ? {} : { scope }),
...optionalField("npmRoot", optionalString(record, "npmRoot")),
};
}
function parsePiWebReleaseStatus(value: unknown): PiWebReleaseStatus {
const record = requireRecord(value);
return {
packageName: requireString(record, "packageName"),
...optionalField("latestVersion", optionalString(record, "latestVersion")),
updateAvailable: requireBoolean(record, "updateAvailable"),
...optionalField("checkedAt", optionalString(record, "checkedAt")),
...(record["skipped"] === true ? { skipped: true } : {}),
...optionalField("error", optionalString(record, "error")),
};
}
function parsePiWebCommands(value: unknown): PiWebStatusResponse["commands"] {
const record = requireRecord(value);
return { update: requireString(record, "update"), restart: requireString(record, "restart"), restartSystemd: requireString(record, "restartSystemd"), restartDev: requireString(record, "restartDev") };
}
function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
const record = requireRecord(value);
return {
id: requireString(record, "id"),
severity: parsePiWebStatusSeverity(record["severity"]),
title: requireString(record, "title"),
body: requireString(record, "body"),
...optionalField("command", optionalString(record, "command")),
};
}
function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
if (value !== "web" && value !== "sessiond") throw new Error("Invalid Pi Web service component");
return value;
}
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid Pi Web status severity");
return value;
}
export function parseCommandResult(value: unknown): CommandResult { export function parseCommandResult(value: unknown): CommandResult {
const record = requireRecord(value); const record = requireRecord(value);
const type = requireString(record, "type"); const type = requireString(record, "type");
+3 -1
View File
@@ -1,4 +1,4 @@
import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api"; import type { AuthProviderOption, CommandOption, CommandResult, FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, OAuthFlowState, PiWebStatusResponse, Project, SessionActivity, SessionInfo, SessionStatus, Workspace } from "./api";
import type { ChatLine } from "./components/shared"; import type { ChatLine } from "./components/shared";
import type { QualifiedContributionId } from "./plugins/types"; import type { QualifiedContributionId } from "./plugins/types";
@@ -38,6 +38,7 @@ export interface AppState {
selectedStagedDiff: GitDiffResponse | undefined; selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean; gitStale: boolean;
activeTerminalCount: number; activeTerminalCount: number;
piWebStatus: PiWebStatusResponse | undefined;
error: string; error: string;
} }
@@ -85,6 +86,7 @@ export function initialAppState(): AppState {
selectedStagedDiff: undefined, selectedStagedDiff: undefined,
gitStale: false, gitStale: false,
activeTerminalCount: 0, activeTerminalCount: 0,
piWebStatus: undefined,
error: "", error: "",
}; };
} }
+28 -5
View File
@@ -1,6 +1,6 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { customElement, query, state } from "lit/decorators.js"; import { customElement, query, state } from "lit/decorators.js";
import { terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api"; import { piWebApi, terminalsApi, type Project, type RealtimeEvent, type SessionInfo, type TerminalUiEvent, type ThinkingLevel, type Workspace } from "../api";
import type { AppAction } from "../actions"; import type { AppAction } from "../actions";
import { initialAppState, type AppState } from "../appState"; import { initialAppState, type AppState } from "../appState";
import { AuthController } from "../controllers/authController"; import { AuthController } from "../controllers/authController";
@@ -36,6 +36,8 @@ import { appStyles } from "./shared";
type NavigationSection = "projects" | "workspaces" | "sessions"; type NavigationSection = "projects" | "workspaces" | "sessions";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
@customElement("pi-web-app") @customElement("pi-web-app")
export class PiWebApp extends LitElement { export class PiWebApp extends LitElement {
@state() private state: AppState = initialAppState(); @state() private state: AppState = initialAppState();
@@ -78,15 +80,22 @@ export class PiWebApp extends LitElement {
private readonly activeTerminalIds = new Set<string>(); private readonly activeTerminalIds = new Set<string>();
private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined; private readonly mobileNavigationMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(max-width: 760px)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined; private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined;
private readonly plugins = createPluginRegistry(); private readonly plugins = createPluginRegistry();
private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID; private preferredThemeId: QualifiedContributionId = readStoredThemeId() ?? DEFAULT_THEME_ID;
@state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID; @state() private activeThemeId: QualifiedContributionId = DEFAULT_THEME_ID;
@state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false; @state() private isMobileNavigationLayout = this.mobileNavigationMedia?.matches ?? false;
@state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined; @state() private expandedMobileNavigationSection: NavigationSection | "none" | undefined;
private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false)); private readonly onPopState = () => void this.withChatScrollTransition(() => this.restoreRoute(false));
private readonly onFocus = () => { void this.sessions.refreshSelectedSession(); }; private readonly onFocus = () => {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
};
private readonly onVisibilityChange = () => { private readonly onVisibilityChange = () => {
if (document.visibilityState === "visible") void this.sessions.refreshSelectedSession(); if (document.visibilityState === "visible") {
void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus();
}
}; };
private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => { private readonly onMobileNavigationMediaChange = (event: MediaQueryListEvent) => {
this.isMobileNavigationLayout = event.matches; this.isMobileNavigationLayout = event.matches;
@@ -107,6 +116,8 @@ export class PiWebApp extends LitElement {
this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange); this.mobileNavigationMedia?.addEventListener("change", this.onMobileNavigationMediaChange);
this.applyPreferredTheme(false); this.applyPreferredTheme(false);
this.connectRealtime(); this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
void this.loadExternalPlugins(); void this.loadExternalPlugins();
void this.loadProjectsAndRestoreRoute(); void this.loadProjectsAndRestoreRoute();
} }
@@ -122,6 +133,8 @@ export class PiWebApp extends LitElement {
this.sessions.dispose(); this.sessions.dispose();
this.realtime.close(); this.realtime.close();
this.git.dispose(); this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined;
super.disconnectedCallback(); super.disconnectedCallback();
} }
@@ -138,6 +151,14 @@ export class PiWebApp extends LitElement {
await this.withChatScrollTransition(() => this.restoreRoute(false)); await this.withChatScrollTransition(() => this.restoreRoute(false));
} }
private async refreshPiWebStatus(): Promise<void> {
try {
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
} catch (error) {
console.warn("Failed to refresh Pi Web status", error);
}
}
private async restoreRoute(updateUrl: boolean) { private async restoreRoute(updateUrl: boolean) {
const route = readRoute(); const route = readRoute();
const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file"); const selectedFilePath = readNamespacedString(queryNamespace("core:workspace.files"), "file");
@@ -268,7 +289,7 @@ export class PiWebApp extends LitElement {
private renderWorkspacePanel() { private renderWorkspacePanel() {
const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace); const workspaceLabelItems = this.state.selectedWorkspace === undefined ? [] : this.plugins.getWorkspaceLabelItems(this.state, this.state.selectedWorkspace);
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`; return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .appState=${this.state} .tool=${this.state.workspaceTool} .panels=${this.visibleWorkspacePanels()} .workspaceLabelItems=${workspaceLabelItems} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .activeTerminalCount=${this.state.activeTerminalCount} .terminalAutoStart=${this.terminalAutoStartWorkspaceId === this.state.selectedWorkspace?.id} .onSelectTool=${(tool: QualifiedContributionId) => { this.openWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
} }
private renderNavigationPanel(autoSwitchToChat: boolean) { private renderNavigationPanel(autoSwitchToChat: boolean) {
@@ -351,7 +372,8 @@ export class PiWebApp extends LitElement {
private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] { private visibleWorkspacePanels(): QualifiedWorkspacePanelContribution[] {
const workspace = this.state.selectedWorkspace; const workspace = this.state.selectedWorkspace;
return this.plugins.getWorkspacePanels().filter((panel) => workspace === undefined || (panel.visible?.({ workspace, state: this.state }) ?? true)); if (workspace === undefined) return [];
return this.plugins.getWorkspacePanels().filter((panel) => panel.visible?.({ workspace, state: this.state }) ?? true);
} }
private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) { private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) {
@@ -365,6 +387,7 @@ export class PiWebApp extends LitElement {
private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext { private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext {
return { return {
workspace, workspace,
state: this.state,
fileTree: this.state.fileTree, fileTree: this.state.fileTree,
expandedDirs: this.state.expandedDirs, expandedDirs: this.state.expandedDirs,
selectedFilePath: this.state.selectedFilePath, selectedFilePath: this.state.selectedFilePath,
@@ -1,6 +1,7 @@
import { LitElement, html, type TemplateResult } from "lit"; import { LitElement, html, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js"; import { customElement, property } from "lit/decorators.js";
import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api"; import type { FileContentResponse, FileTreeEntry, GitDiffResponse, GitStatusResponse, Workspace } from "../api";
import type { AppState } from "../appState";
import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types"; import type { QualifiedContributionId, QualifiedWorkspacePanelContribution, WorkspaceLabelItem, WorkspacePanelContext } from "../plugins/types";
import { workspacePanelStyles } from "./shared"; import { workspacePanelStyles } from "./shared";
import { renderWorkspaceLabel } from "./workspaceLabel"; import { renderWorkspaceLabel } from "./workspaceLabel";
@@ -8,6 +9,7 @@ import { renderWorkspaceLabel } from "./workspaceLabel";
@customElement("workspace-panel") @customElement("workspace-panel")
export class WorkspacePanel extends LitElement { export class WorkspacePanel extends LitElement {
@property({ attribute: false }) workspace: Workspace | undefined; @property({ attribute: false }) workspace: Workspace | undefined;
@property({ attribute: false }) appState!: AppState;
@property() tool: QualifiedContributionId = "core:workspace.files"; @property() tool: QualifiedContributionId = "core:workspace.files";
@property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = []; @property({ attribute: false }) panels: QualifiedWorkspacePanelContribution[] = [];
@property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = []; @property({ attribute: false }) workspaceLabelItems: WorkspaceLabelItem[] = [];
@@ -65,6 +67,7 @@ export class WorkspacePanel extends LitElement {
private createPanelContext(workspace: Workspace): WorkspacePanelContext { private createPanelContext(workspace: Workspace): WorkspacePanelContext {
return { return {
workspace, workspace,
state: this.appState,
fileTree: this.fileTree, fileTree: this.fileTree,
expandedDirs: this.expandedDirs, expandedDirs: this.expandedDirs,
selectedFilePath: this.selectedFilePath, selectedFilePath: this.selectedFilePath,
+1
View File
@@ -75,6 +75,7 @@ export interface WorkspacePanelVisibilityContext {
export interface WorkspacePanelContext { export interface WorkspacePanelContext {
workspace: Workspace; workspace: Workspace;
state: AppState;
fileTree: FileTreeEntry[]; fileTree: FileTreeEntry[];
expandedDirs: Record<string, FileTreeEntry[]>; expandedDirs: Record<string, FileTreeEntry[]>;
selectedFilePath: string | undefined; selectedFilePath: string | undefined;
+3
View File
@@ -14,6 +14,7 @@ import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
import { registerGitRoutes } from "./gitRoutes.js"; import { registerGitRoutes } from "./gitRoutes.js";
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js"; import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { getPiWebStatus } from "./piWebStatus.js";
export interface AppDependencies { export interface AppDependencies {
projects?: ProjectService; projects?: ProjectService;
@@ -39,6 +40,8 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content); return reply.type(asset.contentType).send(asset.content);
}); });
app.get("/api/pi-web/status", async () => getPiWebStatus());
app.get("/api/projects", async () => projects.list()); app.get("/api/projects", async () => projects.list());
app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => { app.post<{ Body: { name?: string; path: string; create?: boolean } }>("/api/projects", async (request, reply) => {
+47
View File
@@ -0,0 +1,47 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js";
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
afterEach(() => {
if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"];
else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck;
vi.restoreAllMocks();
});
describe("Pi Web status", () => {
it("compares semver-shaped CalVer versions", () => {
expect(comparePackageVersions("1.202605.9", "1.202605.8")).toBeGreaterThan(0);
expect(comparePackageVersions("1.202605.8", "1.202605.8")).toBe(0);
expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0);
});
it("reports stale session daemon versions as messages", async () => {
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
const daemon = new SessionDaemonClient();
vi.spyOn(daemon, "request").mockResolvedValue({
statusCode: 200,
headers: { "content-type": "application/json" },
body: JSON.stringify({
version: {
component: "sessiond",
label: "Session daemon",
runtimeVersion: "1.202605.7",
installedVersion: "1.202605.8",
stale: true,
available: true,
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
},
}),
});
const status = await getPiWebStatus(daemon);
expect(status.release.skipped).toBe(true);
expect(status.components.sessiond.stale).toBe(true);
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" });
expect(status.commands.update).not.toBe("");
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
});
});
+386
View File
@@ -0,0 +1,386 @@
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js";
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
const DEFAULT_VERSION = "0.0.0-dev";
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
const VERSION_CHECK_TIMEOUT_MS = 5000;
const restartCommands = {
restart: "pi-web restart",
restartSystemd: "systemctl --user restart pi-web-sessiond.service pi-web.service",
restartDev: "systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service",
};
interface PackageInfo {
name: string;
version: string;
path: string;
}
let latestReleaseCache: { checkedAtMs: number; latestVersion?: string; error?: string } | undefined;
const runtimePackageInfo = readPackageInfoSync();
export async function getPiWebComponentStatus(component: PiWebServiceComponent): Promise<PiWebComponentStatus> {
const [installed, installation] = await Promise.all([
readInstalledPackageInfo(),
detectPiWebInstallation(),
]);
const runtimeVersion = runtimePackageInfo?.version ?? DEFAULT_VERSION;
const installedVersion = installed?.version;
return {
component,
label: component === "web" ? "Web/UI" : "Session daemon",
runtimeVersion,
...(installedVersion === undefined ? {} : { installedVersion }),
stale: isInstalledVersionNewer(installedVersion, runtimeVersion),
available: true,
installation,
};
}
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
const web = await getPiWebComponentStatus("web");
const [installed, sessiond] = await Promise.all([
readInstalledPackageInfo(),
getSessiondComponentStatus(daemon),
]);
const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond };
const commands = commandsFor(web.installation ?? sessiond.installation);
const messages = buildMessages(components, release, commands);
return {
packageName: PI_WEB_PACKAGE_NAME,
generatedAt: new Date().toISOString(),
components,
release,
commands,
messages,
};
}
export function comparePackageVersions(leftVersion: string, rightVersion: string): number | undefined {
const left = parsePackageVersion(leftVersion);
const right = parsePackageVersion(rightVersion);
if (left === undefined || right === undefined) return undefined;
if (left.major !== right.major) return left.major - right.major;
if (left.minor !== right.minor) return left.minor - right.minor;
if (left.patch !== right.patch) return left.patch - right.patch;
if (left.prerelease === right.prerelease) return 0;
if (left.prerelease === undefined) return 1;
if (right.prerelease === undefined) return -1;
return left.prerelease.localeCompare(right.prerelease);
}
function readPackageInfoSync(): PackageInfo | undefined {
const path = packageJsonPath();
try {
return parsePackageInfo(JSON.parse(readFileSync(path, "utf8")), path);
} catch {
return undefined;
}
}
async function readInstalledPackageInfo(): Promise<PackageInfo | undefined> {
const path = packageJsonPath();
try {
await stat(path);
return parsePackageInfo(JSON.parse(await readFile(path, "utf8")), path);
} catch {
return undefined;
}
}
function packageJsonPath(): string {
return join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
}
function parsePackageInfo(value: unknown, path: string): PackageInfo | undefined {
if (!isRecord(value)) return undefined;
const name = value["name"];
const version = value["version"];
if (typeof name !== "string" || name === "" || typeof version !== "string" || version === "") return undefined;
return { name, version, path };
}
async function detectPiWebInstallation(): Promise<PiWebInstallationInfo> {
const root = packageRootPath();
const realRoot = await realPathOrSelf(root);
const piPackage = await detectPiPackageInstallation(realRoot, root);
if (piPackage !== undefined) return piPackage;
const npmGlobal = await detectNpmGlobalInstallation(realRoot, root);
if (npmGlobal !== undefined) return npmGlobal;
return { kind: "local", path: root };
}
async function detectPiPackageInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
try {
const agentDir = getAgentDir();
const packageManager = new DefaultPackageManager({
cwd: process.cwd(),
agentDir,
settingsManager: SettingsManager.create(process.cwd(), agentDir),
});
for (const configuredPackage of packageManager.listConfiguredPackages()) {
const installedPath = configuredPackage.installedPath ?? packageManager.getInstalledPath(configuredPackage.source, configuredPackage.scope);
if (installedPath === undefined) continue;
const realInstalledPath = await realPathOrSelf(installedPath);
if (isSameOrWithin(realInstalledPath, realRoot) || isSameOrWithin(realRoot, realInstalledPath)) {
return { kind: "pi-package", path: displayPath, source: configuredPackage.source, scope: configuredPackage.scope };
}
}
} catch {
return undefined;
}
return undefined;
}
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
const npmRoot = npmGlobalRoot();
if (npmRoot === undefined) return undefined;
const realNpmRoot = await realPathOrSelf(npmRoot);
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
return { kind: "npm-global", path: displayPath, npmRoot };
}
function npmGlobalRoot(): string | undefined {
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" });
if (result.status !== 0) return undefined;
const root = result.stdout.trim();
return root === "" ? undefined : root;
}
function packageRootPath(): string {
return dirname(packageJsonPath());
}
async function realPathOrSelf(path: string): Promise<string> {
return realpath(path).catch(() => resolve(path));
}
function isSameOrWithin(parent: string, candidate: string): boolean {
const rel = relative(parent, candidate);
return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
}
async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<PiWebComponentStatus> {
try {
const upstream = await daemon.request("GET", "/health");
if (upstream.statusCode < 200 || upstream.statusCode >= 300) {
return unavailableSessiond(`health check returned HTTP ${String(upstream.statusCode)}`);
}
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
const version = isRecord(parsed) ? parsed["version"] : undefined;
const component = parseComponentStatus(version);
return component ?? unavailableSessiond("health response did not include version information");
} catch (error) {
return unavailableSessiond(error instanceof Error ? error.message : String(error));
}
}
function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined {
if (!isRecord(value)) return undefined;
const component = value["component"];
const label = value["label"];
const runtimeVersion = value["runtimeVersion"];
const installedVersion = value["installedVersion"];
const stale = value["stale"];
const available = value["available"];
const error = value["error"];
const installation = parseInstallationInfo(value["installation"]);
if (component !== "web" && component !== "sessiond") return undefined;
if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
return {
component,
label,
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
...(typeof installedVersion === "string" ? { installedVersion } : {}),
stale,
available,
...(installation === undefined ? {} : { installation }),
...(typeof error === "string" ? { error } : {}),
};
}
function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
if (!isRecord(value)) return undefined;
const kind = value["kind"];
const path = value["path"];
const source = value["source"];
const scope = value["scope"];
const npmRoot = value["npmRoot"];
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
return {
kind,
...(typeof path === "string" ? { path } : {}),
...(typeof source === "string" ? { source } : {}),
...(scope === "user" || scope === "project" ? { scope } : {}),
...(typeof npmRoot === "string" ? { npmRoot } : {}),
};
}
function unavailableSessiond(error: string): PiWebComponentStatus {
return {
component: "sessiond",
label: "Session daemon",
stale: false,
available: false,
error,
};
}
async function getLatestReleaseStatus(currentVersion: string): Promise<PiWebReleaseStatus> {
const checkedAtMs = Date.now();
if (skipVersionCheck()) {
return { packageName: PI_WEB_PACKAGE_NAME, updateAvailable: false, checkedAt: new Date(checkedAtMs).toISOString(), skipped: true };
}
if (latestReleaseCache !== undefined && checkedAtMs - latestReleaseCache.checkedAtMs < LATEST_RELEASE_CACHE_MS) {
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
try {
latestReleaseCache = { checkedAtMs, latestVersion: await fetchLatestNpmVersion(currentVersion) };
} catch (error) {
latestReleaseCache = { checkedAtMs, error: error instanceof Error ? error.message : String(error) };
}
return releaseStatusFromCache(latestReleaseCache, currentVersion);
}
function releaseStatusFromCache(cache: { checkedAtMs: number; latestVersion?: string; error?: string }, currentVersion: string): PiWebReleaseStatus {
return {
packageName: PI_WEB_PACKAGE_NAME,
...(cache.latestVersion === undefined ? {} : { latestVersion: cache.latestVersion }),
updateAvailable: cache.latestVersion === undefined ? false : isNewerPackageVersion(cache.latestVersion, currentVersion),
checkedAt: new Date(cache.checkedAtMs).toISOString(),
...(cache.error === undefined ? {} : { error: cache.error }),
};
}
async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(PI_WEB_PACKAGE_NAME)}/latest`, {
headers: {
accept: "application/json",
"user-agent": `${PI_WEB_PACKAGE_NAME}/${currentVersion}`,
},
signal: AbortSignal.timeout(VERSION_CHECK_TIMEOUT_MS),
});
if (!response.ok) throw new Error(`npm registry returned HTTP ${String(response.status)}`);
const data: unknown = await response.json();
const version = isRecord(data) ? data["version"] : undefined;
if (typeof version !== "string" || version === "") throw new Error("npm registry response did not include a version");
return version;
}
function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] {
return {
update: updateCommandFor(installation),
...restartCommands,
};
}
function updateCommandFor(installation: PiWebInstallationInfo | undefined): string {
if (installation?.kind === "pi-package") {
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restartSystemd}`;
}
if (installation?.kind === "local" && installation.path !== undefined) {
return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restartSystemd}`;
}
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`;
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`;
}
function buildMessages(components: PiWebStatusResponse["components"], release: PiWebReleaseStatus, commands: PiWebStatusResponse["commands"]): PiWebStatusMessage[] {
const messages: PiWebStatusMessage[] = [];
const installedVersion = components.web.installedVersion ?? components.web.runtimeVersion;
if (release.updateAvailable && release.latestVersion !== undefined) {
messages.push({
id: "update-available",
severity: "info",
title: "Pi Web update available",
body: `Pi Web ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update Pi Web, then restart Pi Web services.`,
command: commands.update,
});
}
if (components.web.stale) {
messages.push({
id: "web-stale",
severity: "warning",
title: "Web/UI service restart needed",
body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
command: commands.restart,
});
}
if (!components.sessiond.available) {
messages.push({
id: "sessiond-unavailable",
severity: "warning",
title: "Session daemon version unavailable",
body: `Pi Web could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
command: "systemctl --user status pi-web-sessiond.service",
});
} else if (components.sessiond.stale) {
messages.push({
id: "sessiond-stale",
severity: "warning",
title: "Session daemon restart needed",
body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
command: commands.restart,
});
}
return messages;
}
function skipVersionCheck(): boolean {
return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => {
const value = process.env[key];
return value !== undefined && value !== "";
});
}
function isInstalledVersionNewer(installedVersion: string | undefined, runtimeVersion: string | undefined): boolean {
if (installedVersion === undefined || runtimeVersion === undefined) return false;
return isNewerPackageVersion(installedVersion, runtimeVersion);
}
function isNewerPackageVersion(candidateVersion: string, currentVersion: string): boolean {
const comparison = comparePackageVersions(candidateVersion, currentVersion);
if (comparison !== undefined) return comparison > 0;
return candidateVersion.trim() !== currentVersion.trim();
}
function parsePackageVersion(version: string): { major: number; minor: number; patch: number; prerelease?: string } | undefined {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+.*)?$/u.exec(version.trim());
if (match === null) return undefined;
const [, major, minor, patch, prerelease] = match;
if (major === undefined || minor === undefined || patch === undefined) return undefined;
return {
major: Number.parseInt(major, 10),
minor: Number.parseInt(minor, 10),
patch: Number.parseInt(patch, 10),
...(prerelease === undefined ? {} : { prerelease }),
};
}
function formatVersion(version: string | undefined): string {
return version ?? "unknown";
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
+7 -1
View File
@@ -11,6 +11,7 @@ import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
import { sessiondSocketPath } from "./sessiond/config.js"; import { sessiondSocketPath } from "./sessiond/config.js";
import { TerminalService } from "./terminals/terminalService.js"; import { TerminalService } from "./terminals/terminalService.js";
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js"; import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
import { getPiWebComponentStatus } from "./piWebStatus.js";
const app = Fastify({ logger: true }); const app = Fastify({ logger: true });
await app.register(fastifyWebsocket); await app.register(fastifyWebsocket);
@@ -24,7 +25,12 @@ registerAuthRoutes(app, auth);
registerSessionRoutes(app, sessions, eventHub); registerSessionRoutes(app, sessions, eventHub);
registerTerminalRoutes(app, terminals); registerTerminalRoutes(app, terminals);
app.get("/health", () => ({ ok: true, activeSessions: sessions.activeCount(), checkedAt: new Date().toISOString() })); app.get("/health", async () => ({
ok: true,
activeSessions: sessions.activeCount(),
checkedAt: new Date().toISOString(),
version: await getPiWebComponentStatus("sessiond"),
}));
let shuttingDown = false; let shuttingDown = false;
async function shutdown(signal: NodeJS.Signals): Promise<void> { async function shutdown(signal: NodeJS.Signals): Promise<void> {
+57
View File
@@ -180,6 +180,63 @@ export interface TerminalInfo {
exitCode?: number; exitCode?: number;
} }
export type PiWebServiceComponent = "web" | "sessiond";
export type PiWebStatusSeverity = "info" | "warning" | "error";
export type PiWebInstallationKind = "pi-package" | "npm-global" | "local" | "unknown";
export interface PiWebInstallationInfo {
kind: PiWebInstallationKind;
path?: string;
source?: string;
scope?: "user" | "project";
npmRoot?: string;
}
export interface PiWebComponentStatus {
component: PiWebServiceComponent;
label: string;
runtimeVersion?: string;
installedVersion?: string;
stale: boolean;
available: boolean;
installation?: PiWebInstallationInfo;
error?: string;
}
export interface PiWebReleaseStatus {
packageName: string;
latestVersion?: string;
updateAvailable: boolean;
checkedAt?: string;
skipped?: boolean;
error?: string;
}
export interface PiWebStatusMessage {
id: string;
severity: PiWebStatusSeverity;
title: string;
body: string;
command?: string;
}
export interface PiWebStatusResponse {
packageName: string;
generatedAt: string;
components: {
web: PiWebComponentStatus;
sessiond: PiWebComponentStatus;
};
release: PiWebReleaseStatus;
commands: {
update: string;
restart: string;
restartSystemd: string;
restartDev: string;
};
messages: PiWebStatusMessage[];
}
export type TerminalUiEvent = export type TerminalUiEvent =
| { type: "terminal.created"; terminal: TerminalInfo } | { type: "terminal.created"; terminal: TerminalInfo }
| { type: "terminal.exited"; terminal: TerminalInfo } | { type: "terminal.exited"; terminal: TerminalInfo }