Archived
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.
This commit is contained in:
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -1,5 +1,49 @@
|
|||||||
import type { TemplateResult } from "lit";
|
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[] {
|
function messagesFor(state: PluginRuntimeState | undefined): PiWebStatusMessage[] {
|
||||||
return state?.piWebStatus?.messages ?? [];
|
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`
|
||||||
|
<span class="updates-command-actions">
|
||||||
|
<button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
|
||||||
|
${terminal === undefined ? null : html`<button class="primary" @click=${() => { runCommandInTerminal(terminal, label, command); }}>Run</button>`}
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommand(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, label: string, command: string): TemplateResult {
|
||||||
return html`
|
return html`
|
||||||
<div class="updates-command">
|
<div class="updates-command">
|
||||||
<span>${label}</span>
|
<span>${label}</span>
|
||||||
<code>${command}</code>
|
<code>${command}</code>
|
||||||
<button @click=${() => { void navigator.clipboard.writeText(command); }}>Copy</button>
|
${renderCommandActions(html, terminal, label, command)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCommands(html: HtmlTemplateTag, status: PiWebStatusResponse): TemplateResult | undefined {
|
function renderCommands(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, status: PiWebStatusResponse): TemplateResult | undefined {
|
||||||
const commands = [
|
const recommended = recommendedCommand(status);
|
||||||
["Update", status.commands.update],
|
const additional = additionalCommands(status, recommended);
|
||||||
["Restart all", status.commands.restart],
|
if (recommended === undefined && additional.length === 0) return undefined;
|
||||||
["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;
|
|
||||||
return html`
|
return html`
|
||||||
<section>
|
${recommended === undefined ? null : html`
|
||||||
<strong>Suggested commands</strong>
|
<section class="updates-recommended">
|
||||||
${commands.map(([label, command]) => renderCommand(html, label, command))}
|
<strong>Recommended</strong>
|
||||||
</section>
|
<p class="muted">Run this one command to bring this installation fully up to date. Nothing else is required.</p>
|
||||||
|
${renderCommand(html, terminal, recommended.label, recommended.command)}
|
||||||
|
</section>
|
||||||
|
`}
|
||||||
|
${additional.length === 0 ? null : html`
|
||||||
|
<section>
|
||||||
|
<strong>${recommended === undefined ? "Suggested commands" : "Additional commands (optional)"}</strong>
|
||||||
|
${recommended === undefined ? null : html`<p class="muted">Only needed for finer control, such as restarting a single service.</p>`}
|
||||||
|
${additional.map((entry) => renderCommand(html, terminal, entry.label, entry.command))}
|
||||||
|
</section>
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | undefined): TemplateResult {
|
function renderUpdatesPanel(html: HtmlTemplateTag, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
|
||||||
const status = statusFor(state);
|
const status = statusFor(state);
|
||||||
if (status === undefined) {
|
if (status === undefined) {
|
||||||
return html`
|
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-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 { 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 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; }
|
.updates-meta { display: grid; gap: 2px; color: var(--pi-muted); font-size: 12px; }
|
||||||
@media (max-width: 520px) {
|
@media (max-width: 520px) {
|
||||||
.updates-command { grid-template-columns: minmax(0, 1fr) auto; }
|
.updates-command { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
@@ -121,7 +183,12 @@ function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | u
|
|||||||
<article class=${`updates-message ${message.severity}`}>
|
<article class=${`updates-message ${message.severity}`}>
|
||||||
<div class="updates-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
|
<div class="updates-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
|
||||||
<p>${message.body}</p>
|
<p>${message.body}</p>
|
||||||
${message.command === undefined ? null : html`<code>${message.command}</code>`}
|
${message.command === undefined ? null : html`
|
||||||
|
<div class="updates-command updates-command-inline">
|
||||||
|
<code>${message.command}</code>
|
||||||
|
${renderCommandActions(html, terminal, message.title, message.command)}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
</article>
|
</article>
|
||||||
`)}
|
`)}
|
||||||
</section>
|
</section>
|
||||||
@@ -132,7 +199,7 @@ function renderUpdatesPanel(html: HtmlTemplateTag, state: PluginRuntimeState | u
|
|||||||
${renderComponent(html, status.components.sessiond)}
|
${renderComponent(html, status.components.sessiond)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
${renderCommands(html, status)}
|
${renderCommands(html, terminal, status)}
|
||||||
|
|
||||||
<section class="updates-meta">
|
<section class="updates-meta">
|
||||||
<span>Generated ${status.generatedAt}</span>
|
<span>Generated ${status.generatedAt}</span>
|
||||||
@@ -167,7 +234,7 @@ const plugin: PiWebPlugin = {
|
|||||||
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}`;
|
||||||
},
|
},
|
||||||
render: (context) => renderUpdatesPanel(html, context.state),
|
render: (context) => renderUpdatesPanel(html, context.terminal, context.state),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -71,10 +71,10 @@ describe("PI WEB status", () => {
|
|||||||
|
|
||||||
const status = await getPiWebStatus(daemon);
|
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.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("systemctl --user restart pi-web-ui-dev.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("systemctl --user restart pi-web-sessiond.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("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 {
|
} finally {
|
||||||
await rm(home, { recursive: true, force: true });
|
await rm(home, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -437,9 +437,9 @@ async function nativeServiceCommands(): Promise<NativeServiceCommands> {
|
|||||||
const restartable = web.length === 0 ? [] : installedServiceRefs(installed, restartServiceOrder, restartServiceOrder);
|
const restartable = web.length === 0 ? [] : installedServiceRefs(installed, restartServiceOrder, restartServiceOrder);
|
||||||
const status = installedServiceRefs(installed);
|
const status = installedServiceRefs(installed);
|
||||||
return {
|
return {
|
||||||
...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }),
|
...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable, "pi-web-restart") }),
|
||||||
...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }),
|
...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web, "pi-web-restart-web") }),
|
||||||
...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }),
|
...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond, "pi-web-restart-sessiond") }),
|
||||||
...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }),
|
...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -470,8 +470,18 @@ function launchdServiceDir(): string {
|
|||||||
return join(homedir(), "Library", "LaunchAgents");
|
return join(homedir(), "Library", "LaunchAgents");
|
||||||
}
|
}
|
||||||
|
|
||||||
function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
|
function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[], systemdUnit: string): string {
|
||||||
if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`;
|
// 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 <unit>`.
|
||||||
|
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(" && ");
|
return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user