Archived
feat: add Pi Web status panel
This commit is contained in:
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "@pi-web/status-plugin",
|
||||||
|
"private": true,
|
||||||
|
"piWeb": {
|
||||||
|
"plugins": [
|
||||||
|
{ "id": "pi-web", "module": "pi-web-plugin.js" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
Reference in New Issue
Block a user