Archived
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.
245 lines
11 KiB
TypeScript
245 lines
11 KiB
TypeScript
import type { TemplateResult } from "lit";
|
|
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 ?? [];
|
|
}
|
|
|
|
function statusFor(state: PluginRuntimeState | undefined): PiWebStatusResponse | undefined {
|
|
return state?.piWebStatus;
|
|
}
|
|
|
|
function messageCount(state: PluginRuntimeState | undefined): number {
|
|
return messagesFor(state).length;
|
|
}
|
|
|
|
function isLocalOrUnknownInstallation(installation: PiWebInstallationInfo | undefined): boolean {
|
|
return installation === undefined || installation.kind === "local" || installation.kind === "unknown";
|
|
}
|
|
|
|
function shouldShowUpdatesPanel(state: PluginRuntimeState | undefined): boolean {
|
|
const status = statusFor(state);
|
|
if (messageCount(state) > 0) return true;
|
|
if (status === undefined) return false;
|
|
return isLocalOrUnknownInstallation(status.components.web.installation)
|
|
|| isLocalOrUnknownInstallation(status.components.sessiond.installation);
|
|
}
|
|
|
|
function formatVersion(version: string | undefined): string {
|
|
return version === undefined || version === "" ? "unknown" : version;
|
|
}
|
|
|
|
function installationLabel(installation: PiWebInstallationInfo | undefined): string {
|
|
if (installation === undefined) return "installation unknown";
|
|
if (installation.kind === "pi-package") {
|
|
const scope = installation.scope === undefined ? "" : ` · ${installation.scope}`;
|
|
const source = installation.source ?? "Pi package";
|
|
return `${source}${scope}`;
|
|
}
|
|
if (installation.kind === "npm-global") return "global npm package";
|
|
if (installation.kind === "local") return "local checkout";
|
|
return "installation unknown";
|
|
}
|
|
|
|
function renderComponent(html: HtmlTemplateTag, component: PiWebComponentStatus): TemplateResult {
|
|
const status = !component.available
|
|
? "unavailable"
|
|
: component.stale
|
|
? "restart needed"
|
|
: "current";
|
|
return html`
|
|
<div class="updates-version-row">
|
|
<strong>${component.label}</strong>
|
|
<span>${status}</span>
|
|
<small>running ${formatVersion(component.runtimeVersion)} · installed ${formatVersion(component.installedVersion)}</small>
|
|
<small>${installationLabel(component.installation)}${component.installation?.path === undefined ? "" : ` · ${component.installation.path}`}</small>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
<div class="updates-command">
|
|
<span>${label}</span>
|
|
<code>${command}</code>
|
|
${renderCommandActions(html, terminal, label, command)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
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`
|
|
${recommended === undefined ? null : html`
|
|
<section class="updates-recommended">
|
|
<strong>Recommended</strong>
|
|
<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, terminal: WorkspacePanelTerminal | undefined, state: PluginRuntimeState | undefined): TemplateResult {
|
|
const status = statusFor(state);
|
|
if (status === undefined) {
|
|
return html`
|
|
<section class="toolbar"><strong>Updates</strong></section>
|
|
<section class="viewer"><p class="muted">Checking PI WEB update status…</p></section>
|
|
`;
|
|
}
|
|
|
|
const messages = status.messages;
|
|
return html`
|
|
<style>
|
|
.viewer.updates-status { flex: 1 1 auto; min-height: 0; box-sizing: border-box; display: flex; flex-direction: column; gap: 14px; padding: 12px; overflow-y: auto; overflow-x: hidden; }
|
|
.viewer.updates-status section { flex: 0 0 auto; min-width: 0; display: grid; gap: 8px; }
|
|
.updates-message { display: grid; gap: 5px; border: 1px solid var(--pi-border); border-radius: 8px; padding: 10px; background: var(--pi-surface); }
|
|
.updates-message.warning { border-color: var(--pi-warning-border); background: var(--pi-warning-surface); }
|
|
.updates-message.error { border-color: var(--pi-danger); }
|
|
.updates-message-title { display: flex; gap: 8px; align-items: baseline; }
|
|
.updates-message-title span { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
|
.updates-version-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 3px 10px; border-bottom: 1px solid var(--pi-border-muted); padding: 6px 0; }
|
|
.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; }
|
|
.updates-command > span { grid-column: 1 / -1; }
|
|
}
|
|
</style>
|
|
<section class="toolbar"><strong>Updates</strong><span class="stale">beta</span>${messages.length > 0 ? html`<span class="stale">${String(messages.length)}</span>` : null}</section>
|
|
<section class="viewer updates-status">
|
|
<section>
|
|
${messages.length === 0 ? html`<p class="muted">No PI WEB update or restart messages.</p>` : messages.map((message) => html`
|
|
<article class=${`updates-message ${message.severity}`}>
|
|
<div class="updates-message-title"><strong>${message.title}</strong><span>${message.severity}</span></div>
|
|
<p>${message.body}</p>
|
|
${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>
|
|
`)}
|
|
</section>
|
|
|
|
<section>
|
|
<strong>Installed services</strong>
|
|
${renderComponent(html, status.components.web)}
|
|
${renderComponent(html, status.components.sessiond)}
|
|
</section>
|
|
|
|
${renderCommands(html, terminal, status)}
|
|
|
|
<section class="updates-meta">
|
|
<span>Generated ${status.generatedAt}</span>
|
|
${status.release.latestVersion === undefined ? null : html`<span>Latest npm release ${status.release.latestVersion}</span>`}
|
|
${status.release.skipped === true ? html`<span>Remote version check skipped.</span>` : null}
|
|
${status.release.error === undefined ? null : html`<span>Remote version check failed: ${status.release.error}</span>`}
|
|
</section>
|
|
</section>
|
|
`;
|
|
}
|
|
|
|
const plugin: PiWebPlugin = {
|
|
apiVersion: 1,
|
|
name: "Updates",
|
|
activate: ({ html, svg }) => ({
|
|
contributions: {
|
|
workspacePanels: [
|
|
{
|
|
id: "workspace.updates",
|
|
title: "Updates",
|
|
icon: svg`
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M20 6v5h-5"></path>
|
|
<path d="M4 18v-5h5"></path>
|
|
<path d="M18.4 9A7 7 0 0 0 6.1 6.7L4 8.8"></path>
|
|
<path d="M5.6 15A7 7 0 0 0 17.9 17.3L20 15.2"></path>
|
|
</svg>
|
|
`,
|
|
order: 100,
|
|
visible: (context) => shouldShowUpdatesPanel(context.state),
|
|
badge: (context) => {
|
|
const count = messageCount(context.state);
|
|
return html`beta${count > 0 ? html` · ${String(count)}` : null}`;
|
|
},
|
|
render: (context) => renderUpdatesPanel(html, context.terminal, context.state),
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
};
|
|
|
|
export default plugin;
|