diff --git a/.changeset/docker-updates-tab.md b/.changeset/docker-updates-tab.md
index 811478a..dd83d19 100644
--- a/.changeset/docker-updates-tab.md
+++ b/.changeset/docker-updates-tab.md
@@ -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.
diff --git a/docs/plugins.html b/docs/plugins.html
index 6b1f07a..1f3403e 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -212,7 +212,9 @@ After editing, check the manifest endpoint and browser-console failure cases.
Updates adds a conditional Updates 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.
- Plugin id:
updates
diff --git a/docs/plugins.md b/docs/plugins.md
index fd7f347..8b0956a 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -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
{
diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts
index 3266f08..4734749 100644
--- a/pi-web-plugins/updates/pi-web-plugin.ts
+++ b/pi-web-plugins/updates/pi-web-plugin.ts
@@ -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`
@@ -157,7 +168,7 @@ const plugin: PiWebPlugin = {
`,
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}`;
diff --git a/pi-web-plugins/updates/updatesLogic.test.ts b/pi-web-plugins/updates/updatesLogic.test.ts
index d711a18..1a3066d 100644
--- a/pi-web-plugins/updates/updatesLogic.test.ts
+++ b/pi-web-plugins/updates/updatesLogic.test.ts
@@ -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 {
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);
diff --git a/pi-web-plugins/updates/updatesLogic.ts b/pi-web-plugins/updates/updatesLogic.ts
index 459ff32..8914914 100644
--- a/pi-web-plugins/updates/updatesLogic.ts
+++ b/pi-web-plugins/updates/updatesLogic.ts
@@ -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;
}
diff --git a/src/server/piWebPluginService.test.ts b/src/server/piWebPluginService.test.ts
index 7b4ec66..754f560 100644
--- a/src/server/piWebPluginService.test.ts
+++ b/src/server/piWebPluginService.test.ts
@@ -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;
+}
diff --git a/src/server/piWebPluginService.ts b/src/server/piWebPluginService.ts
index b952d53..97061d9 100644
--- a/src/server/piWebPluginService.ts
+++ b/src/server/piWebPluginService.ts
@@ -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 [];