From 3c6b4a4869301e08b4d7abb08501f600975447dc Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 13 Jun 2026 12:58:09 +0200 Subject: [PATCH] feat: detached systemd restart + runnable Updates panel commands Run Linux restart commands inside a detached transient systemd user service (systemd-run --user) so the restart survives the launching terminal being killed and its logs can be inspected via journalctl. Make the Updates panel actionable: every command has Copy and Run, a single recommended all-in-one command is shown at the top, and the rest are grouped as optional additional commands. --- .changeset/restart-via-systemd-run.md | 5 ++ .changeset/updates-panel-run-commands.md | 5 ++ pi-web-plugins/updates/pi-web-plugin.ts | 109 ++++++++++++++++++----- src/server/piWebStatus.test.ts | 8 +- src/server/piWebStatus.ts | 20 +++-- 5 files changed, 117 insertions(+), 30 deletions(-) create mode 100644 .changeset/restart-via-systemd-run.md create mode 100644 .changeset/updates-panel-run-commands.md diff --git a/.changeset/restart-via-systemd-run.md b/.changeset/restart-via-systemd-run.md new file mode 100644 index 0000000..310def2 --- /dev/null +++ b/.changeset/restart-via-systemd-run.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Run the suggested Linux restart commands inside a detached transient systemd user service (`systemd-run --user`) instead of directly. The restart now completes even when the launching PI WEB terminal is killed by restarting the session daemon, and its output can be inspected with `journalctl --user -u pi-web-restart`. diff --git a/.changeset/updates-panel-run-commands.md b/.changeset/updates-panel-run-commands.md new file mode 100644 index 0000000..21ad939 --- /dev/null +++ b/.changeset/updates-panel-run-commands.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Make the Updates panel actionable: every suggested command now has both a Copy and a Run button (Run executes it in a workspace terminal), a single recommended all-in-one command is shown at the top so users do not have to choose, and the remaining commands are grouped as clearly optional additional commands. diff --git a/pi-web-plugins/updates/pi-web-plugin.ts b/pi-web-plugins/updates/pi-web-plugin.ts index 4a11efc..95b8792 100644 --- a/pi-web-plugins/updates/pi-web-plugin.ts +++ b/pi-web-plugins/updates/pi-web-plugin.ts @@ -1,5 +1,49 @@ import type { TemplateResult } from "lit"; -import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState } from "@jmfederico/pi-web/plugin-api"; +import type { HtmlTemplateTag, PiWebComponentStatus, PiWebInstallationInfo, PiWebPlugin, PiWebStatusMessage, PiWebStatusResponse, PluginRuntimeState, WorkspacePanelTerminal } from "@jmfederico/pi-web/plugin-api"; + +interface CommandEntry { + label: string; + command: string; +} + +function runCommandInTerminal(terminal: WorkspacePanelTerminal, label: string, command: string): void { + void terminal.runCommand({ + title: label, + command, + open: true, + metadata: { "pi.plugin": "updates" }, + }).catch((error: unknown) => { + console.error(`Updates plugin failed to run "${label}"`, error); + }); +} + +// 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. +function recommendedCommand(status: PiWebStatusResponse): CommandEntry | undefined { + const { commands, release, components } = status; + if (release.updateAvailable && typeof commands.update === "string" && commands.update !== "") { + return { label: "Update & restart everything", command: commands.update }; + } + const restartNeeded = components.web.stale || components.sessiond.stale || !components.sessiond.available; + if (restartNeeded && typeof commands.restart === "string" && commands.restart !== "") { + return { label: "Restart everything", command: commands.restart }; + } + return undefined; +} + +function additionalCommands(status: PiWebStatusResponse, recommended: CommandEntry | undefined): CommandEntry[] { + return [ + ["Update", status.commands.update], + ["Restart all", status.commands.restart], + ["Restart Web/UI", status.commands.restartWeb], + ["Restart session daemon", status.commands.restartSessiond], + ["Status", status.commands.status], + ] + .filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== "") + .filter(([, command]) => command !== recommended?.command) + .map(([label, command]) => ({ label, command })); +} function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] { return state?.piWebStatus?.messages ?? []; @@ -57,35 +101,48 @@ function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus) `; } -function renderCommand(html: HtmlTemplateTag, label: string, command: string): TemplateResult { +function renderCommandActions(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, label: string, command: string): TemplateResult { + return html` + + + ${terminal === undefined ? null : html``} + + `; +} + +function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, label: string, command: string): TemplateResult { return html`
${label} ${command} - + ${renderCommandActions(html, terminal, label, command)}
`; } -function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): TemplateResult | undefined { - const commands = [ - ["Update", status.commands.update], - ["Restart all", status.commands.restart], - ["Restart Web/UI", status.commands.restartWeb], - ["Restart session daemon", status.commands.restartSessiond], - ["Status", status.commands.status], - ].filter((entry): entry is [string, string] => typeof entry[1] === "string" && entry[1] !== ""); - - if (commands.length === 0) return undefined; +function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined { + const recommended = recommendedCommand(status); + const additional = additionalCommands(status, recommended); + if (recommended === undefined && additional.length === 0) return undefined; return html` -
- Suggested commands - ${commands.map(([label, command]) => renderCommand(html, label, command))} -
+ ${recommended === undefined ? null : html` + + `} + ${additional.length === 0 ? null : html` +
+ ${recommended === undefined ? "Suggested commands" : "Additional commands (optional)"} + ${recommended === undefined ? null : html`

Only needed for finer control, such as restarting a single service.

`} + ${additional.map((entry) => renderCommand(html, terminal, entry.label, entry.command))} +
+ `} `; } -function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | undefined): TemplateResult { +function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult { const status = statusFor(state); if (status === undefined) { return html` @@ -108,6 +165,11 @@ function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | u .updates-version-row small { grid-column: 1 / -1; color: var(--pi-muted); } .updates-command { min-width: 0; display: grid; grid-template-columns: minmax(90px, auto) minmax(0, 1fr) auto; gap: 8px; align-items: center; } .updates-command code { overflow: auto; border: 1px solid var(--pi-border-muted); border-radius: 6px; background: var(--pi-bg); padding: 5px 7px; white-space: nowrap; } + .updates-command-inline { grid-template-columns: minmax(0, 1fr) auto; } + .updates-command-actions { display: inline-flex; gap: 6px; } + .updates-command-actions button.primary { border-color: var(--pi-accent-border); color: var(--pi-text-bright); } + .updates-recommended { border: 1px solid var(--pi-accent-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); } + .updates-recommended > strong { color: var(--pi-text-bright); } .updates-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; } @media (max-width: 520px) { .updates-command { grid-template-columns: minmax(0, 1fr) auto; } @@ -121,7 +183,12 @@ function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | u
${message.title}${message.severity}

${message.body}

- ${message.command === undefined ? null : html`${message.command}`} + ${message.command === undefined ? null : html` +
+ ${message.command} + ${renderCommandActions(html, terminal, message.title, message.command)} +
+ `}
`)} @@ -132,7 +199,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | u ${renderComponent(html, status.components.sessiond)} - ${renderCommands(html, status)} + ${renderCommands(html, terminal, status)}
Generated ${status.generatedAt} @@ -167,7 +234,7 @@ const plugin: PiWebPlugin = { const count = messageCount(context.state); return html`beta${count > 0 ? html` ยท ${String(count)}` : null}`; }, - render: (context) => renderUpdatesPanel(html, context.state), + render: (context) => renderUpdatesPanel(html, context.terminal, context.state), }, ], }, diff --git a/src/server/piWebStatus.test.ts b/src/server/piWebStatus.test.ts index 1060652..344395f 100644 --- a/src/server/piWebStatus.test.ts +++ b/src/server/piWebStatus.test.ts @@ -71,10 +71,10 @@ describe("PI WEB status", () => { const status = await getPiWebStatus(daemon); - expect(status.commands.restart).toBe("systemctl --user restart pi-web-ui-dev.service pi-web-sessiond.service"); - expect(status.commands.restartWeb).toBe("systemctl --user restart pi-web-ui-dev.service"); - expect(status.commands.restartSessiond).toBe("systemctl --user restart pi-web-sessiond.service"); - expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemctl --user restart pi-web-sessiond.service"); + expect(status.commands.restart).toBe("systemd-run --user --collect --unit=pi-web-restart -- systemctl --user restart pi-web-ui-dev.service pi-web-sessiond.service"); + expect(status.commands.restartWeb).toBe("systemd-run --user --collect --unit=pi-web-restart-web -- systemctl --user restart pi-web-ui-dev.service"); + expect(status.commands.restartSessiond).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service"); + expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemd-run --user --collect --unit=pi-web-restart-sessiond -- systemctl --user restart pi-web-sessiond.service"); } finally { await rm(home, { recursive: true, force: true }); } diff --git a/src/server/piWebStatus.ts b/src/server/piWebStatus.ts index 85d1203..a7d4dd4 100644 --- a/src/server/piWebStatus.ts +++ b/src/server/piWebStatus.ts @@ -437,9 +437,9 @@ async function nativeServiceCommands(): Promise { const restartable = web.length === 0 ? [] : installedServiceRefs(installed, restartServiceOrder, restartServiceOrder); const status = installedServiceRefs(installed); return { - ...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }), - ...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }), - ...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }), + ...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable, "pi-web-restart") }), + ...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web, "pi-web-restart-web") }), + ...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond, "pi-web-restart-sessiond") }), ...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }), }; } @@ -470,8 +470,18 @@ function launchdServiceDir(): string { return join(homedir(), "Library", "LaunchAgents"); } -function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string { - if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`; +function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[], systemdUnit: string): string { + // On systemd, run the restart inside a transient, detached `.service` unit + // (the default `systemd-run` mode, not `--scope`). A scope would stay a child + // of the calling shell and die with the terminal; a transient service is + // reparented to the user service manager, so the restart finishes even when + // restarting the session daemon kills the pi-web terminal that launched it. + // `--collect` cleans the unit up afterwards, and the fixed `--unit` name makes + // logs easy to find with `journalctl --user -u `. + if (backend === "systemd") { + const names = refs.map((ref) => ref.systemdName).join(" "); + return `systemd-run --user --collect --unit=${systemdUnit} -- systemctl --user restart ${names}`; + } return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && "); }