fix: keep federated docker updates tab visible

This commit is contained in:
Pi Web Agent
2026-06-29 16:12:40 +00:00
parent 4885aa0dd5
commit 01c75298f9
8 changed files with 136 additions and 10 deletions
+1 -1
View File
@@ -2,4 +2,4 @@
"@jmfederico/pi-web": patch
---
Expose Docker-aware PI WEB status, update, and restart commands in the Updates panel through the canonical `pi-web-docker` command, including explicit `pi-web-docker --dev ...` commands for Docker development runtimes, and harden production and development Docker workflows around generated Compose assets, Compose project-name isolation, clearer checkout/runtime guidance, root-safety checks, UID/GID preservation, and detached helper execution.
Expose Docker-aware PI WEB status, update, and restart commands in the Updates panel through the canonical `pi-web-docker` command, keep the Updates tab visible across federated Docker runtimes, including Docker development runtimes with explicit `pi-web-docker --dev ...` commands, and harden production and development Docker workflows around generated Compose assets, Compose project-name isolation, clearer checkout/runtime guidance, root-safety checks, UID/GID preservation, and detached helper execution.
+3 -1
View File
@@ -212,7 +212,9 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
<p>
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
restart, and installed-service guidance. It is built into PI WEB, enabled by default, and uses the
selected machine's plugin copy when machine federation is active.
selected machine's plugin copy when machine federation is active. Docker runtimes also publish a small
manifest hint so federated gateways can keep the remote Updates tab visible and expose Docker commands
while gateway status parsing catches up.
</p>
<ul>
<li>Plugin id: <code>updates</code></li>
+1 -1
View File
@@ -190,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
**Plugin id:** `updates`
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. To hide it, disable `updates` in **Settings → Plugins** or set:
Updates is enabled by default. It declares `machineSpecific: true` so the gateway Updates tab only appears for the local machine; while a remote machine is selected, that remote machine's Updates plugin is used if available. Docker runtimes add a small manifest hint so federated gateways can keep the remote Updates tab visible and expose Docker commands while gateway status parsing catches up. To hide it, disable `updates` in **Settings → Plugins** or set:
```json
{
+14 -3
View File
@@ -1,6 +1,6 @@
import type { TemplateResult } from "lit";
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor } from "./updatesLogic.js";
import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel, statusFor, type UpdatesRuntimeHint } from "./updatesLogic.js";
function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void {
void terminal.runCommand({
@@ -48,6 +48,17 @@ function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal |
`;
}
function updatesRuntimeHintFromModuleUrl(moduleUrl: string): UpdatesRuntimeHint {
try {
const dockerMode = new URL(moduleUrl).searchParams.get("piWebDockerMode");
return dockerMode === "runtime" || dockerMode === "dev" ? { dockerMode } : {};
} catch {
return {};
}
}
const runtimeHint = updatesRuntimeHintFromModuleUrl(import.meta.url);
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
const recommended = recommendedCommand(status);
const additional = additionalCommands(status, recommended);
@@ -71,7 +82,7 @@ function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal
}
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
const status = statusFor(state);
const status = statusFor(state) ?? fallbackDockerStatus(runtimeHint);
if (status === undefined) {
return html`
<section class="toolbar"><strong>Updates</strong></section>
@@ -157,7 +168,7 @@ const plugin: PiWebPlugin = {
</svg>
`,
order: 100,
visible: (context) => shouldShowUpdatesPanel(context.state),
visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
badge: (context) => {
const count = messageCount(context.state);
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
+24 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { PiWebComponentStatus, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
import { additionalCommands, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
import { additionalCommands, fallbackDockerStatus, formatVersion, installationLabel, messageCount, recommendedCommand, shouldShowUpdatesPanel } from "./updatesLogic";
function component(overrides: Partial<PiWebComponentStatus> = {}): PiWebComponentStatus {
return {
@@ -181,6 +181,11 @@ describe("shouldShowUpdatesPanel", () => {
expect(shouldShowUpdatesPanel(stateWith(value))).toBe(true);
});
it("shows the panel when a federated Docker runtime hint is available before status is parsed", () => {
expect(shouldShowUpdatesPanel(undefined, { dockerMode: "dev" })).toBe(true);
expect(shouldShowUpdatesPanel(undefined, { dockerMode: "runtime" })).toBe(true);
});
it("hides the panel when status is unavailable", () => {
expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false);
expect(shouldShowUpdatesPanel(undefined)).toBe(false);
@@ -223,6 +228,24 @@ describe("shouldShowUpdatesPanel", () => {
});
});
describe("fallbackDockerStatus", () => {
it("creates Docker development commands from a federated runtime hint", () => {
const fallback = fallbackDockerStatus({ dockerMode: "dev" }, "generated");
expect(fallback?.generatedAt).toBe("generated");
expect(fallback?.components.web.installation).toEqual({ kind: "docker", dockerMode: "dev" });
expect(fallback?.commands).toMatchObject({
update: "pi-web-docker --dev update",
restart: "pi-web-docker --dev restart",
status: "pi-web-docker --dev status",
});
expect(fallback?.messages[0]?.id).toBe("docker-status-compatibility");
});
it("does not create a fallback without a Docker runtime hint", () => {
expect(fallbackDockerStatus({})).toBeUndefined();
});
});
describe("messageCount", () => {
it("counts messages and tolerates missing status", () => {
expect(messageCount(undefined)).toBe(0);
+35 -2
View File
@@ -1,10 +1,14 @@
import type { PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
import type { PiWebDockerMode, PiWebInstallationInfo, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
export interface CommandEntry {
label: string;
command: string;
}
export interface UpdatesRuntimeHint {
dockerMode?: PiWebDockerMode;
}
// The single command users should run when they do not want to think: if an
// update is available, `commands.update` already chains the update and a full
// restart; otherwise, when anything is stale, a full restart is enough.
@@ -49,14 +53,43 @@ export function isSelfManagedInstallation(installation: PiWebInstallationInfo |
return installation === undefined || installation.kind === "local" || installation.kind === "docker" || installation.kind === "unknown";
}
export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
export function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined, hint: UpdatesRuntimeHint = {}): boolean {
const status = statusFor(state);
if (hint.dockerMode !== undefined) return true;
if (messageCount(state) > 0) return true;
if (status === undefined) return false;
return isSelfManagedInstallation(status.components.web.installation)
|| isSelfManagedInstallation(status.components.sessiond.installation);
}
export function fallbackDockerStatus(hint: UpdatesRuntimeHint, generatedAt = "federated status unavailable"): PiWebStatusResponse | undefined {
if (hint.dockerMode === undefined) return undefined;
const commandPrefix = hint.dockerMode === "dev" ? "pi-web-docker --dev" : "pi-web-docker";
const installation: PiWebInstallationInfo = { kind: "docker", dockerMode: hint.dockerMode };
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true, installation },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true, installation },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false, skipped: true },
commands: {
update: `${commandPrefix} update`,
restart: `${commandPrefix} restart`,
restartWeb: `${commandPrefix} restart-web`,
restartSessiond: `${commandPrefix} restart-sessiond`,
status: `${commandPrefix} status`,
},
messages: [{
id: "docker-status-compatibility",
severity: "info",
title: "Docker update commands available",
body: "This Updates plugin was loaded from a Docker PI WEB runtime, but the gateway has not provided Docker-aware status details yet. The Docker maintenance commands below are still available.",
}],
};
}
export function formatVersion(version: string | undefined): string {
return version === undefined || version === "" ? "unknown" : version;
}
+28
View File
@@ -6,11 +6,20 @@ import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService
let tempDir: string;
const originalDockerRuntime = process.env["PI_WEB_DOCKER_RUNTIME"];
const originalDockerMode = process.env["PI_WEB_DOCKER_MODE"];
const originalDockerDevRepoRoot = process.env["PI_WEB_DOCKER_DEV_REPO_ROOT"];
const originalDockerInstallDir = process.env["PI_WEB_DOCKER_INSTALL_DIR"];
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-"));
});
afterEach(async () => {
restoreEnv("PI_WEB_DOCKER_RUNTIME", originalDockerRuntime);
restoreEnv("PI_WEB_DOCKER_MODE", originalDockerMode);
restoreEnv("PI_WEB_DOCKER_DEV_REPO_ROOT", originalDockerDevRepoRoot);
restoreEnv("PI_WEB_DOCKER_INSTALL_DIR", originalDockerInstallDir);
await rm(tempDir, { recursive: true, force: true });
});
@@ -47,6 +56,20 @@ describe("PiWebPluginService", () => {
await expect(service.plugins()).resolves.toMatchObject({ plugins: [{ id: "updates", machineSpecific: true, enabled: true }] });
});
it("adds Docker runtime hints to the Updates plugin module URL", async () => {
process.env["PI_WEB_DOCKER_RUNTIME"] = "1";
process.env["PI_WEB_DOCKER_MODE"] = "dev";
await writePlugin(join(tempDir, "plugins", "updates"), {
packageJson: { piWeb: { plugins: [{ id: "updates", module: "pi-web-plugin.js", machineSpecific: true }] } },
files: { "pi-web-plugin.js": "export default {};" },
});
const service = new PiWebPluginService({ roots: [{ path: join(tempDir, "plugins"), source: "test", scope: "local" }], packageProvider: false });
const manifest = await service.manifest();
expect(manifest.plugins[0]?.module).toMatch(/^\/pi-web-plugins\/updates\/pi-web-plugin\.js\?v=\d+&piWebDockerMode=dev$/u);
});
it("discovers Pi package plugins through an injected package provider", async () => {
const packageDir = join(tempDir, "pkg");
await writePlugin(packageDir, {
@@ -198,3 +221,8 @@ async function writePlugin(root: string, options: { packageJson: unknown; files:
await writeFile(filePath, content);
}
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) Reflect.deleteProperty(process.env, key);
else process.env[key] = value;
}
+30 -1
View File
@@ -135,7 +135,7 @@ export class PiWebPluginService {
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
return {
id: plugin.id,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?v=${encodeURIComponent(plugin.version)}`,
module: `/pi-web-plugins/${encodeURIComponent(plugin.id)}/${plugin.entryFile}?${pluginModuleQuery(plugin)}`,
source: plugin.source,
scope: plugin.scope,
machineSpecific: plugin.machineSpecific,
@@ -187,6 +187,35 @@ function bundledPluginRoot(packageRoot: string): string {
return join(packageRoot, "dist", "pi-web-plugins");
}
function pluginModuleQuery(plugin: PluginRecord): string {
const params = new URLSearchParams({ v: plugin.version });
const dockerMode = plugin.id === "updates" ? dockerModeFromEnv() : undefined;
if (dockerMode !== undefined) params.set("piWebDockerMode", dockerMode);
return params.toString();
}
function dockerModeFromEnv(): "runtime" | "dev" | undefined {
if (!isTruthyEnv("PI_WEB_DOCKER_RUNTIME")) return undefined;
const mode = process.env["PI_WEB_DOCKER_MODE"];
if (mode === "runtime" || mode === "dev") return mode;
if (firstNonEmptyEnv("PI_WEB_DOCKER_DEV_REPO_ROOT") !== undefined) return "dev";
if (firstNonEmptyEnv("PI_WEB_DOCKER_INSTALL_DIR") !== undefined) return "runtime";
return undefined;
}
function firstNonEmptyEnv(...keys: string[]): string | undefined {
for (const key of keys) {
const value = process.env[key];
if (value !== undefined && value !== "") return value;
}
return undefined;
}
function isTruthyEnv(key: string): boolean {
const value = process.env[key];
return value !== undefined && value !== "" && value !== "0" && value.toLowerCase() !== "false";
}
function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
const pluginsRoot = join(cwd, "plugins");
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];