Archived
fix: keep federated docker updates tab visible
This commit is contained in:
@@ -2,4 +2,4 @@
|
|||||||
"@jmfederico/pi-web": patch
|
"@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
@@ -212,7 +212,9 @@ After editing, check the manifest endpoint and browser-console failure cases.</c
|
|||||||
<p>
|
<p>
|
||||||
<strong>Updates</strong> adds a conditional <strong>Updates</strong> workspace tab with PI WEB update,
|
<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
|
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>
|
</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Plugin id: <code>updates</code></li>
|
<li>Plugin id: <code>updates</code></li>
|
||||||
|
|||||||
+1
-1
@@ -190,7 +190,7 @@ Built-in plugins can be managed from **Settings → Plugins** or with the top-le
|
|||||||
**Plugin id:** `updates`
|
**Plugin id:** `updates`
|
||||||
**What it does:** adds a conditional **Updates** workspace tab with PI WEB update, restart, and installed-service guidance.
|
**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
|
```json
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TemplateResult } from "lit";
|
import type { TemplateResult } from "lit";
|
||||||
import type { HtmlTemplateTag, PiWebComponentStatus, PiWebPlugin, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api";
|
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 {
|
function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void {
|
||||||
void terminal.runCommand({
|
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 {
|
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
|
||||||
const recommended = recommendedCommand(status);
|
const recommended = recommendedCommand(status);
|
||||||
const additional = additionalCommands(status, recommended);
|
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 {
|
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
|
||||||
const status = statusFor(state);
|
const status = statusFor(state) ?? fallbackDockerStatus(runtimeHint);
|
||||||
if (status === undefined) {
|
if (status === undefined) {
|
||||||
return html`
|
return html`
|
||||||
<section class="toolbar"><strong>Updates</strong></section>
|
<section class="toolbar"><strong>Updates</strong></section>
|
||||||
@@ -157,7 +168,7 @@ const plugin: PiWebPlugin = {
|
|||||||
</svg>
|
</svg>
|
||||||
`,
|
`,
|
||||||
order: 100,
|
order: 100,
|
||||||
visible: (context) => shouldShowUpdatesPanel(context.state),
|
visible: (context) => shouldShowUpdatesPanel(context.state, runtimeHint),
|
||||||
badge: (context) => {
|
badge: (context) => {
|
||||||
const count = messageCount(context.state);
|
const count = messageCount(context.state);
|
||||||
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
|
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { PiWebComponentStatus, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api";
|
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 {
|
function component(overrides: Partial<PiWebComponentStatus> = {}): PiWebComponentStatus {
|
||||||
return {
|
return {
|
||||||
@@ -181,6 +181,11 @@ describe("shouldShowUpdatesPanel", () => {
|
|||||||
expect(shouldShowUpdatesPanel(stateWith(value))).toBe(true);
|
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", () => {
|
it("hides the panel when status is unavailable", () => {
|
||||||
expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false);
|
expect(shouldShowUpdatesPanel(stateWith(undefined))).toBe(false);
|
||||||
expect(shouldShowUpdatesPanel(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", () => {
|
describe("messageCount", () => {
|
||||||
it("counts messages and tolerates missing status", () => {
|
it("counts messages and tolerates missing status", () => {
|
||||||
expect(messageCount(undefined)).toBe(0);
|
expect(messageCount(undefined)).toBe(0);
|
||||||
|
|||||||
@@ -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 {
|
export interface CommandEntry {
|
||||||
label: string;
|
label: string;
|
||||||
command: string;
|
command: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatesRuntimeHint {
|
||||||
|
dockerMode?: PiWebDockerMode;
|
||||||
|
}
|
||||||
|
|
||||||
// The single command users should run when they do not want to think: if an
|
// 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
|
// update is available, `commands.update` already chains the update and a full
|
||||||
// restart; otherwise, when anything is stale, a full restart is enough.
|
// 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";
|
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);
|
const status = statusFor(state);
|
||||||
|
if (hint.dockerMode !== undefined) return true;
|
||||||
if (messageCount(state) > 0) return true;
|
if (messageCount(state) > 0) return true;
|
||||||
if (status === undefined) return false;
|
if (status === undefined) return false;
|
||||||
return isSelfManagedInstallation(status.components.web.installation)
|
return isSelfManagedInstallation(status.components.web.installation)
|
||||||
|| isSelfManagedInstallation(status.components.sessiond.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 {
|
export function formatVersion(version: string | undefined): string {
|
||||||
return version === undefined || version === "" ? "unknown" : version;
|
return version === undefined || version === "" ? "unknown" : version;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,20 @@ import { PiWebPluginService, type PiPackageProvider } from "./piWebPluginService
|
|||||||
|
|
||||||
let tempDir: string;
|
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 () => {
|
beforeEach(async () => {
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-"));
|
tempDir = await mkdtemp(join(tmpdir(), "pi-web-plugin-service-test-"));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
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 });
|
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 }] });
|
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 () => {
|
it("discovers Pi package plugins through an injected package provider", async () => {
|
||||||
const packageDir = join(tempDir, "pkg");
|
const packageDir = join(tempDir, "pkg");
|
||||||
await writePlugin(packageDir, {
|
await writePlugin(packageDir, {
|
||||||
@@ -198,3 +221,8 @@ async function writePlugin(root: string, options: { packageJson: unknown; files:
|
|||||||
await writeFile(filePath, content);
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class PiWebPluginService {
|
|||||||
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
|
private pluginInfo(plugin: PluginRecord, config: PiWebConfig): PiWebPluginInfo {
|
||||||
return {
|
return {
|
||||||
id: plugin.id,
|
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,
|
source: plugin.source,
|
||||||
scope: plugin.scope,
|
scope: plugin.scope,
|
||||||
machineSpecific: plugin.machineSpecific,
|
machineSpecific: plugin.machineSpecific,
|
||||||
@@ -187,6 +187,35 @@ function bundledPluginRoot(packageRoot: string): string {
|
|||||||
return join(packageRoot, "dist", "pi-web-plugins");
|
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[] {
|
function sourceCheckoutPluginRoots(cwd: string): LocalPluginRoot[] {
|
||||||
const pluginsRoot = join(cwd, "plugins");
|
const pluginsRoot = join(cwd, "plugins");
|
||||||
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
|
if (!existsSync(join(cwd, "src", "server", "index.ts")) || !existsSync(pluginsRoot)) return [];
|
||||||
|
|||||||
Reference in New Issue
Block a user