fix: keep machine restore snappy

This commit is contained in:
Federico Jaramillo Martinez
2026-06-09 17:06:18 +02:00
parent 65b4c76513
commit 4bc390a33a
9 changed files with 252 additions and 58 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep machine/session navigation snappy by deferring expensive Pi-Web status refreshes and caching status checks.
+28 -8
View File
@@ -64,6 +64,7 @@ import { appStyles } from "./shared";
const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000; const PI_WEB_STATUS_REFRESH_MS = 15 * 60 * 1000;
const PI_WEB_STATUS_DEFER_MS = 750;
const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const; const GLOBAL_SHORTCUT_LISTENER_OPTIONS = { capture: true } as const;
const THEME_AUTO_ON_VALUE = "auto:on"; const THEME_AUTO_ON_VALUE = "auto:on";
const THEME_AUTO_OFF_VALUE = "auto:off"; const THEME_AUTO_OFF_VALUE = "auto:off";
@@ -144,6 +145,7 @@ export class PiWebApp extends LitElement {
private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined; private readonly systemLightThemeMedia = typeof window !== "undefined" && "matchMedia" in window ? window.matchMedia("(prefers-color-scheme: light)") : undefined;
private terminalAutoStartWorkspaceId: string | undefined; private terminalAutoStartWorkspaceId: string | undefined;
private piWebStatusTimer: number | undefined; private piWebStatusTimer: number | undefined;
private piWebStatusDeferredTimer: number | undefined;
private workspaceDeletionPollTimer: number | undefined; private workspaceDeletionPollTimer: number | undefined;
private refreshingWorkspaceDeletionRuns = false; private refreshingWorkspaceDeletionRuns = false;
private readonly handledWorkspaceDeletionRunIds = new Set<string>(); private readonly handledWorkspaceDeletionRunIds = new Set<string>();
@@ -171,7 +173,7 @@ export class PiWebApp extends LitElement {
private readonly onFocus = () => { private readonly onFocus = () => {
this.appShell.repairViewportPosition(); this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession(); void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus(); this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities(); void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns(); void this.refreshWorkspaceDeletionRuns();
}; };
@@ -179,7 +181,7 @@ export class PiWebApp extends LitElement {
if (document.visibilityState === "visible") { if (document.visibilityState === "visible") {
this.appShell.repairViewportPosition(); this.appShell.repairViewportPosition();
void this.sessions.refreshSelectedSession(); void this.sessions.refreshSelectedSession();
void this.refreshPiWebStatus(); this.schedulePiWebStatusRefresh();
void this.refreshMachineActivities(); void this.refreshMachineActivities();
void this.refreshWorkspaceDeletionRuns(); void this.refreshWorkspaceDeletionRuns();
} }
@@ -213,12 +215,11 @@ export class PiWebApp extends LitElement {
this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange); this.systemLightThemeMedia?.addEventListener("change", this.onSystemLightThemeChange);
this.applyPreferredTheme(false); this.applyPreferredTheme(false);
this.connectRealtime(); this.connectRealtime();
this.piWebStatusTimer = window.setInterval(() => { void this.refreshPiWebStatus(); }, PI_WEB_STATUS_REFRESH_MS); this.piWebStatusTimer = window.setInterval(() => { this.schedulePiWebStatusRefresh(); }, PI_WEB_STATUS_REFRESH_MS);
void this.refreshPiWebStatus();
void this.refreshWorkspaceActivity(); void this.refreshWorkspaceActivity();
void this.loadClientConfig(); void this.loadClientConfig();
void this.ensureGatewayPluginsLoaded(); void this.ensureGatewayPluginsLoaded();
void this.loadProjectsAndRestoreRoute(); void this.loadProjectsAndRestoreRoute().finally(() => { this.schedulePiWebStatusRefresh(); });
} }
override disconnectedCallback(): void { override disconnectedCallback(): void {
@@ -236,6 +237,7 @@ export class PiWebApp extends LitElement {
this.git.dispose(); this.git.dispose();
if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer); if (this.piWebStatusTimer !== undefined) window.clearInterval(this.piWebStatusTimer);
this.piWebStatusTimer = undefined; this.piWebStatusTimer = undefined;
this.clearScheduledPiWebStatusRefresh();
if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer); if (this.workspaceDeletionPollTimer !== undefined) window.clearInterval(this.workspaceDeletionPollTimer);
this.workspaceDeletionPollTimer = undefined; this.workspaceDeletionPollTimer = undefined;
super.disconnectedCallback(); super.disconnectedCallback();
@@ -265,6 +267,20 @@ export class PiWebApp extends LitElement {
await this.refreshWorkspaceDeletionRuns(); await this.refreshWorkspaceDeletionRuns();
} }
private schedulePiWebStatusRefresh(delayMs = PI_WEB_STATUS_DEFER_MS): void {
this.clearScheduledPiWebStatusRefresh();
this.piWebStatusDeferredTimer = window.setTimeout(() => {
this.piWebStatusDeferredTimer = undefined;
void this.refreshPiWebStatus();
}, delayMs);
}
private clearScheduledPiWebStatusRefresh(): void {
if (this.piWebStatusDeferredTimer === undefined) return;
window.clearTimeout(this.piWebStatusDeferredTimer);
this.piWebStatusDeferredTimer = undefined;
}
private async refreshPiWebStatus(): Promise<void> { private async refreshPiWebStatus(): Promise<void> {
const machineId = selectedMachineId(this.state); const machineId = selectedMachineId(this.state);
try { try {
@@ -311,12 +327,12 @@ export class PiWebApp extends LitElement {
try { try {
await Promise.all([ await Promise.all([
this.sessions.refreshSelectedSession(), this.sessions.refreshSelectedSession(),
this.refreshPiWebStatus(),
this.refreshMachineActivities(), this.refreshMachineActivities(),
this.loadClientConfig(), this.loadClientConfig(),
this.refreshWorkspaceDeletionRuns(), this.refreshWorkspaceDeletionRuns(),
this.refreshCurrentWorkspaceSurface(), this.refreshCurrentWorkspaceSurface(),
]); ]);
this.schedulePiWebStatusRefresh();
} finally { } finally {
this.isRefreshingApp = false; this.isRefreshingApp = false;
} }
@@ -340,6 +356,7 @@ export class PiWebApp extends LitElement {
} }
private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) { private async restoreRouteFor(route: AppRoute, updateUrl: boolean, surface = this.readWorkspaceRouteSurface(route), restoredMainView?: AppState["mainView"]) {
const machineBeforeRestore = selectedMachineId(this.state);
const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface; const routeSurface = route.projectId === undefined || route.projectId === "" ? emptyWorkspaceRouteSurface() : surface;
const restoreSeq = ++this.routeRestoreSeq; const restoreSeq = ++this.routeRestoreSeq;
this.routeRestoreDepth += 1; this.routeRestoreDepth += 1;
@@ -383,6 +400,7 @@ export class PiWebApp extends LitElement {
} finally { } finally {
this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1); this.routeRestoreDepth = Math.max(0, this.routeRestoreDepth - 1);
if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined; if (this.routeRestoreDepth === 0) this.restoringRouteTerminalId = undefined;
if (selectedMachineId(this.state) !== machineBeforeRestore) this.schedulePiWebStatusRefresh();
} }
} }
@@ -728,7 +746,6 @@ export class PiWebApp extends LitElement {
this.activeTerminalIds.clear(); this.activeTerminalIds.clear();
this.setState({ piWebStatus: undefined }); this.setState({ piWebStatus: undefined });
this.git.updatePolling(); this.git.updatePolling();
void this.refreshPiWebStatus();
void this.loadPluginsForSelectedMachine(); void this.loadPluginsForSelectedMachine();
} }
@@ -1360,7 +1377,10 @@ export class PiWebApp extends LitElement {
private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> { private async submitMachineDialog(input: MachineDialogSubmit): Promise<void> {
const machine = await this.machines.addMachine(input); const machine = await this.machines.addMachine(input);
if (machine !== undefined) this.setState({ machineDialogOpen: false }); if (machine !== undefined) {
this.setState({ machineDialogOpen: false });
this.schedulePiWebStatusRefresh();
}
} }
private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> { private async removeMachine(machine: Machine | undefined = this.state.selectedMachine): Promise<void> {
-11
View File
@@ -37,17 +37,6 @@ beforeEach(async () => {
return remoteClient; return remoteClient;
}, },
now: () => new Date("2026-05-25T00:00:00.000Z"), now: () => new Date("2026-05-25T00:00:00.000Z"),
localStatus: () => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web", label: "PI WEB", stale: false, available: true },
sessiond: { component: "sessiond", label: "PI WEB Session Daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: { update: "", restart: "", restartSystemd: "", restartDev: "" },
messages: [],
}),
localRuntime: () => Promise.resolve({ localRuntime: () => Promise.resolve({
packageName: "@jmfederico/pi-web", packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z", generatedAt: "2026-05-25T00:00:00.000Z",
+5 -2
View File
@@ -17,6 +17,7 @@ import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js"; import { registerWorkspaceDeletionRoutes } from "./workspaces/workspaceDeletionRoutes.js";
import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js"; import { registerConfigRoutes, type PiWebConfigService } from "./configRoutes.js";
import { PiWebPluginService } from "./piWebPluginService.js"; import { PiWebPluginService } from "./piWebPluginService.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js"; import { getPiWebRuntime, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
import { MachineService } from "./machines/machineService.js"; import { MachineService } from "./machines/machineService.js";
import { registerMachineRoutes } from "./machines/machineRoutes.js"; import { registerMachineRoutes } from "./machines/machineRoutes.js";
@@ -92,9 +93,11 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
const workspaces = deps.workspaces ?? new WorkspaceService(); const workspaces = deps.workspaces ?? new WorkspaceService();
const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService(); const piWebPlugins = deps.piWebPlugins ?? new PiWebPluginService();
const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient(); const sessionDaemon = deps.sessionDaemon ?? new SessionDaemonClient();
const piWebStatusCache = createPiWebStatusCache(() => getPiWebStatus(sessionDaemon), {
onError: (error) => { app.log.warn({ err: error }, "failed to refresh PI WEB status cache"); },
});
const machines = deps.machines ?? new MachineService(undefined, { const machines = deps.machines ?? new MachineService(undefined, {
localRuntime: () => getPiWebRuntime(sessionDaemon), localRuntime: () => getPiWebRuntime(sessionDaemon),
localStatus: () => getPiWebStatus(sessionDaemon),
}); });
app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest()); app.get("/pi-web-plugins/manifest.json", async () => piWebPlugins.manifest());
@@ -107,7 +110,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
return reply.type(asset.contentType).send(asset.content); return reply.type(asset.contentType).send(asset.content);
}); });
app.get("/api/pi-web/status", async () => getPiWebStatus(sessionDaemon)); app.get("/api/pi-web/status", async () => piWebStatusCache.get());
app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon)); app.get("/api/pi-web/version", async () => getPiWebVersionStatus(sessionDaemon));
app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon)); app.get("/api/pi-web/runtime", async () => getPiWebRuntime(sessionDaemon));
app.get("/api/plugins", async () => piWebPlugins.plugins()); app.get("/api/plugins", async () => piWebPlugins.plugins());
+29 -1
View File
@@ -1,7 +1,7 @@
import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; import { chmod, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MachineService } from "./machineService.js"; import { MachineService } from "./machineService.js";
import { MachineStore, machineStorePath } from "./machineStore.js"; import { MachineStore, machineStorePath } from "./machineStore.js";
@@ -69,6 +69,34 @@ describe("MachineService", () => {
await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed"); await expect(service.add({ name: "Bad", baseUrl: "https://example.test", headers: { Connection: "close" } })).rejects.toThrow("not allowed");
}); });
it("uses the lightweight runtime check for local machine health", async () => {
const localRuntime = vi.fn(() => Promise.resolve({
packageName: "@jmfederico/pi-web",
generatedAt: "2026-05-25T00:00:00.000Z",
components: {
web: { component: "web" as const, label: "Web/UI", runtimeVersion: "1.0.0", available: true, capabilities: [] },
sessiond: { component: "sessiond" as const, label: "Session daemon", runtimeVersion: "1.0.0", available: true, capabilities: [] },
},
capabilities: [],
}));
const healthService = new MachineService(new MachineStore(storePath), {
localRuntime,
now: () => new Date("2026-05-25T00:00:00.000Z"),
});
const health = await healthService.health("local");
expect(localRuntime).toHaveBeenCalledTimes(1);
expect(health).toEqual({
machineId: "local",
ok: true,
checkedAt: "2026-05-25T00:00:00.000Z",
status: "online",
web: { component: "web", label: "Web/UI", runtimeVersion: "1.0.0", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", runtimeVersion: "1.0.0", stale: false, available: true },
});
});
it("does not allow local machine mutation", async () => { it("does not allow local machine mutation", async () => {
await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed"); await expect(service.update("local", { name: "Other" })).rejects.toThrow("Local machine cannot be changed");
await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted"); await expect(service.remove("local")).rejects.toThrow("Local machine cannot be deleted");
+22 -5
View File
@@ -1,6 +1,6 @@
import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js"; import type { Machine, MachineHealth, MachineRuntime, PiWebComponentStatus, PiWebRuntimeComponent, PiWebRuntimeResponse, PiWebStatusResponse } from "../../shared/apiTypes.js";
import { isPiWebCapability } from "../../shared/capabilities.js"; import { isPiWebCapability } from "../../shared/capabilities.js";
import { getPiWebRuntime, getPiWebStatus } from "../piWebStatus.js"; import { getPiWebRuntime } from "../piWebStatus.js";
import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js"; import { DEFAULT_REMOTE_HEALTH_TIMEOUT_MS, RemoteMachineClient, type MachineClient, validateConfiguredMachineHeaders } from "./machineClient.js";
import { MachineStore, type StoredMachine } from "./machineStore.js"; import { MachineStore, type StoredMachine } from "./machineStore.js";
@@ -14,7 +14,6 @@ export interface CreateMachineInput {
export type UpdateMachineInput = Partial<CreateMachineInput>; export type UpdateMachineInput = Partial<CreateMachineInput>;
export interface MachineServiceDependencies { export interface MachineServiceDependencies {
localStatus?: () => Promise<PiWebStatusResponse>;
localRuntime?: () => Promise<PiWebRuntimeResponse>; localRuntime?: () => Promise<PiWebRuntimeResponse>;
remoteClientFactory?: (machine: StoredMachine) => MachineClient; remoteClientFactory?: (machine: StoredMachine) => MachineClient;
now?: () => Date; now?: () => Date;
@@ -108,8 +107,15 @@ export class MachineService {
private async localHealth(): Promise<MachineHealth> { private async localHealth(): Promise<MachineHealth> {
const checkedAt = this.now().toISOString(); const checkedAt = this.now().toISOString();
try { try {
const status = await (this.deps.localStatus ?? getPiWebStatus)(); const runtime = await (this.deps.localRuntime ?? getPiWebRuntime)();
return { machineId: "local", ok: true, checkedAt, status: "online", web: status.components.web, sessiond: status.components.sessiond }; return {
machineId: "local",
ok: true,
checkedAt,
status: "online",
web: componentStatusFromRuntime(runtime.components.web),
sessiond: componentStatusFromRuntime(runtime.components.sessiond),
};
} catch (error) { } catch (error) {
return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) }; return { machineId: "local", ok: false, checkedAt, status: "error", error: errorMessage(error) };
} }
@@ -205,6 +211,17 @@ function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error); return error instanceof Error ? error.message : String(error);
} }
function componentStatusFromRuntime(runtime: PiWebRuntimeComponent): PiWebComponentStatus {
return {
component: runtime.component,
label: runtime.label,
...(runtime.runtimeVersion === undefined ? {} : { runtimeVersion: runtime.runtimeVersion }),
stale: false,
available: runtime.available,
...(runtime.error === undefined ? {} : { error: runtime.error }),
};
}
function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime { function machineRuntime(machineId: string, checkedAt: string, runtime: PiWebRuntimeResponse): MachineRuntime {
return { return {
machineId, machineId,
+43 -31
View File
@@ -1,6 +1,7 @@
import { spawnSync } from "node:child_process"; import { execFile } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { readFile, realpath, stat } from "node:fs/promises"; import { readFile, realpath, stat } from "node:fs/promises";
import { promisify } from "node:util";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { dirname, join, relative, resolve, sep } from "node:path"; import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -33,6 +34,8 @@ interface NativeServiceCommands {
status?: string; status?: string;
} }
const execFileAsync = promisify(execFile);
const serviceRefs: Record<ServiceId, NativeServiceRef> = { const serviceRefs: Record<ServiceId, NativeServiceRef> = {
sessiond: { sessiond: {
id: "sessiond", id: "sessiond",
@@ -126,7 +129,7 @@ export async function getPiWebStatus(daemon: PiWebStatusDaemon = new SessionDaem
const { web, sessiond } = versionStatus.components; const { web, sessiond } = versionStatus.components;
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION); const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
const components = { web, sessiond }; const components = { web, sessiond };
const commands = commandsFor(components); const commands = await commandsFor(components);
const messages = buildMessages(components, release, commands); const messages = buildMessages(components, release, commands);
return { return {
...versionStatus, ...versionStatus,
@@ -213,18 +216,21 @@ async function detectPiPackageInstallation(realRoot: string, displayPath: string
} }
async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> { async function detectNpmGlobalInstallation(realRoot: string, displayPath: string): Promise<PiWebInstallationInfo | undefined> {
const npmRoot = npmGlobalRoot(); const npmRoot = await npmGlobalRoot();
if (npmRoot === undefined) return undefined; if (npmRoot === undefined) return undefined;
const realNpmRoot = await realPathOrSelf(npmRoot); const realNpmRoot = await realPathOrSelf(npmRoot);
if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined; if (!isSameOrWithin(realNpmRoot, realRoot)) return undefined;
return { kind: "npm-global", path: displayPath, npmRoot }; return { kind: "npm-global", path: displayPath, npmRoot };
} }
function npmGlobalRoot(): string | undefined { async function npmGlobalRoot(): Promise<string | undefined> {
const result = spawnSync("npm", ["root", "-g"], { encoding: "utf8" }); try {
if (result.status !== 0) return undefined; const { stdout } = await execFileAsync("npm", ["root", "-g"], { encoding: "utf8" });
const root = result.stdout.trim(); const root = stdout.trim();
return root === "" ? undefined : root; return root === "" ? undefined : root;
} catch {
return undefined;
}
} }
function packageRootPath(): string { function packageRootPath(): string {
@@ -365,15 +371,17 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
return version; return version;
} }
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] { async function commandsFor(components: PiWebStatusResponse["components"]): Promise<PiWebStatusResponse["commands"]> {
const installation = preferredInstallation(components); const installation = preferredInstallation(components);
const serviceCommands = nativeServiceCommands(); const [serviceCommands, cliCommands] = await Promise.all([
const cliCommands = piWebCliCommands(installation); nativeServiceCommands(),
piWebCliCommands(installation),
]);
const restart = restartCommandFor(installation, serviceCommands, cliCommands); const restart = restartCommandFor(installation, serviceCommands, cliCommands);
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart; const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart; const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
const status = serviceCommands.status ?? cliCommands.status; const status = serviceCommands.status ?? cliCommands.status;
const update = updateCommandFor(installation, restart); const update = await updateCommandFor(installation, restart);
return { return {
...(update === undefined ? {} : { update }), ...(update === undefined ? {} : { update }),
@@ -391,8 +399,8 @@ function preferredInstallation(components: PiWebStatusResponse["components"]): P
return web ?? sessiond; return web ?? sessiond;
} }
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands { async function piWebCliCommands(installation: PiWebInstallationInfo | undefined): Promise<NativeServiceCommands> {
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {}; if (installation?.kind !== "npm-global" || !(await hasCommand("pi-web"))) return {};
return { restart: "pi-web restart", status: "pi-web status" }; return { restart: "pi-web restart", status: "pi-web status" };
} }
@@ -401,22 +409,22 @@ function restartCommandFor(installation: PiWebInstallationInfo | undefined, serv
return cliCommands.restart ?? serviceCommands.restart; return cliCommands.restart ?? serviceCommands.restart;
} }
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined { async function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): Promise<string | undefined> {
if (restartCommand === undefined) return undefined; if (restartCommand === undefined) return undefined;
if (installation?.kind === "pi-package") { if (installation?.kind === "pi-package") {
if (!hasCommand("pi")) return undefined; if (!(await hasCommand("pi"))) return undefined;
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`; return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
} }
if (installation?.kind === "local" && installation.path !== undefined) { if (installation?.kind === "local" && installation.path !== undefined) {
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined; if (!(await hasCommand("npm")) || !(await isGitCheckoutWithUpstream(installation.path))) return undefined;
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`; return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
} }
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined; if (installation?.kind !== "npm-global" || !(await hasCommand("npm"))) return undefined;
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`; return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
} }
function nativeServiceCommands(): NativeServiceCommands { async function nativeServiceCommands(): Promise<NativeServiceCommands> {
const backend = nativeServiceBackend(); const backend = await nativeServiceBackend();
if (backend === undefined) return {}; if (backend === undefined) return {};
const installed = installedServiceIds(backend); const installed = installedServiceIds(backend);
if (installed.size === 0) return {}; if (installed.size === 0) return {};
@@ -432,9 +440,9 @@ function nativeServiceCommands(): NativeServiceCommands {
}; };
} }
function nativeServiceBackend(): NativeServiceBackendKind | undefined { async function nativeServiceBackend(): Promise<NativeServiceBackendKind | undefined> {
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd"; if (process.platform === "linux" && await hasCommand("systemctl")) return "systemd";
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd"; if (process.platform === "darwin" && await hasCommand("launchctl")) return "launchd";
return undefined; return undefined;
} }
@@ -468,19 +476,23 @@ function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: Na
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && "); return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
} }
function isGitCheckoutWithUpstream(path: string): boolean { async function isGitCheckoutWithUpstream(path: string): Promise<boolean> {
return hasCommand("git") return await hasCommand("git")
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"]) && await commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]); && await commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
} }
function hasCommand(command: string): boolean { function hasCommand(command: string): Promise<boolean> {
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]); return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
} }
function commandSucceeds(command: string, args: string[]): boolean { async function commandSucceeds(command: string, args: string[]): Promise<boolean> {
const result = spawnSync(command, args, { encoding: "utf8" }); try {
return result.status === 0; await execFileAsync(command, args, { encoding: "utf8" });
return true;
} catch {
return false;
}
} }
function shellQuote(value: string): string { function shellQuote(value: string): string {
+74
View File
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
import { createPiWebStatusCache } from "./piWebStatusCache.js";
describe("createPiWebStatusCache", () => {
it("serves cached status while it is fresh", async () => {
const now = 1_000;
const load = vi.fn(() => Promise.resolve(status("first")));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
expect(load).toHaveBeenCalledTimes(1);
});
it("returns stale status immediately while refreshing in the background", async () => {
let now = 1_000;
const load = vi.fn()
.mockResolvedValueOnce(status("first"))
.mockResolvedValueOnce(status("second"));
const cache = createPiWebStatusCache(load, { ttlMs: 100, now: () => now });
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
now = 1_101;
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "first" });
await waitForMicrotasks();
await expect(cache.get()).resolves.toMatchObject({ generatedAt: "second" });
expect(load).toHaveBeenCalledTimes(2);
});
it("deduplicates concurrent cold loads", async () => {
const deferred = createDeferred<PiWebStatusResponse>();
const load = vi.fn(() => deferred.promise);
const cache = createPiWebStatusCache(load);
const first = cache.get();
const second = cache.get();
deferred.resolve(status("ready"));
await expect(first).resolves.toMatchObject({ generatedAt: "ready" });
await expect(second).resolves.toMatchObject({ generatedAt: "ready" });
expect(load).toHaveBeenCalledTimes(1);
});
});
function status(generatedAt: string): PiWebStatusResponse {
return {
packageName: "@jmfederico/pi-web",
generatedAt,
components: {
web: { component: "web", label: "Web/UI", stale: false, available: true },
sessiond: { component: "sessiond", label: "Session daemon", stale: false, available: true },
},
release: { packageName: "@jmfederico/pi-web", updateAvailable: false },
commands: {},
messages: [],
};
}
async function waitForMicrotasks(): Promise<void> {
await Promise.resolve();
await Promise.resolve();
}
function createDeferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve: (value: T) => void = () => undefined;
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return { promise, resolve };
}
+46
View File
@@ -0,0 +1,46 @@
import type { PiWebStatusResponse } from "../shared/apiTypes.js";
const DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS = 60_000;
export interface PiWebStatusCacheOptions {
ttlMs?: number;
now?: () => number;
onError?: (error: unknown) => void;
}
export interface PiWebStatusCache {
get(): Promise<PiWebStatusResponse>;
refresh(): Promise<PiWebStatusResponse>;
}
export function createPiWebStatusCache(load: () => Promise<PiWebStatusResponse>, options: PiWebStatusCacheOptions = {}): PiWebStatusCache {
const ttlMs = options.ttlMs ?? DEFAULT_PI_WEB_STATUS_CACHE_TTL_MS;
const now = options.now ?? Date.now;
let cached: { status: PiWebStatusResponse; expiresAt: number } | undefined;
let pending: Promise<PiWebStatusResponse> | undefined;
const refresh = (): Promise<PiWebStatusResponse> => {
pending ??= Promise.resolve()
.then(load)
.then((status) => {
cached = { status, expiresAt: now() + ttlMs };
return status;
})
.finally(() => {
pending = undefined;
});
return pending;
};
return {
async get(): Promise<PiWebStatusResponse> {
if (cached !== undefined) {
if (cached.expiresAt > now()) return cached.status;
void refresh().catch((error: unknown) => { options.onError?.(error); });
return cached.status;
}
return refresh();
},
refresh,
};
}