fix: decouple settings package loading

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 22:05:48 +02:00
parent 3df4041bd7
commit 0a1b7f9699
5 changed files with 166 additions and 38 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately.
+42 -30
View File
@@ -9,6 +9,7 @@ import "./settings/SettingsPackagesPanel";
import "./settings/SettingsPluginsPanel";
import "./settings/SettingsShortcutsPanel";
import { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
@customElement("settings-dialog")
export class SettingsDialog extends LitElement {
@@ -22,6 +23,7 @@ export class SettingsDialog extends LitElement {
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
@state() private packagesResponse: PiPackagesResponse | undefined;
@state() private loading = true;
@state() private packageLoading = true;
@state() private saving = false;
@state() private packageOperation: PiPackageOperationState | undefined;
@state() private error = "";
@@ -30,12 +32,13 @@ export class SettingsDialog extends LitElement {
@state() private packageMessage = "";
private savedMessageTimer: number | undefined;
private loadRequestSeq = 0;
private packageLoadRequestSeq = 0;
private packageMutationSeq = 0;
private lastRequestedPackageTargetId: string | undefined;
override connectedCallback(): void {
super.connectedCallback();
void this.loadConfig();
void this.loadPackagesForTarget();
}
override disconnectedCallback(): void {
@@ -50,7 +53,7 @@ export class SettingsDialog extends LitElement {
const currentTarget = this.packageTarget();
if (previousTarget.id === currentTarget.id) return;
this.resetPackageStateForTargetChange();
if (this.isConnected && this.lastRequestedPackageTargetId !== currentTarget.id) void this.loadConfig();
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
}
override render(): TemplateResult {
@@ -115,11 +118,11 @@ export class SettingsDialog extends LitElement {
<settings-packages-panel
.packagesResponse=${this.packagesResponse}
.targetMachine=${this.packageTarget()}
.loading=${this.loading}
.loading=${this.packageLoading}
.operation=${this.packageOperation}
.error=${this.packageError}
.operationMessage=${this.packageMessage}
.onReload=${() => this.loadConfig()}
.onReload=${() => this.loadPackagesForTarget()}
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
@@ -184,32 +187,37 @@ export class SettingsDialog extends LitElement {
}
private async loadConfig(): Promise<void> {
const target = this.packageTarget();
const requestSeq = ++this.loadRequestSeq;
this.lastRequestedPackageTargetId = target.id;
this.loading = true;
this.error = "";
this.packageError = "";
try {
const [config, plugins, packages] = await Promise.allSettled([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages(target.id)]);
if (!this.isCurrentLoad(requestSeq, target)) return;
const result = await loadGatewaySettingsData({
loadConfig: () => configApi.config(),
loadPlugins: () => pluginsApi.plugins(),
});
if (!this.isCurrentLoad(requestSeq)) return;
const errors: string[] = [];
if (config.status === "fulfilled") this.configResponse = config.value;
else errors.push(`config: ${errorMessage(config.reason)}`);
if (plugins.status === "fulfilled") this.pluginsResponse = plugins.value;
else errors.push(`PI WEB plugins: ${errorMessage(plugins.reason)}`);
if (packages.status === "fulfilled") this.packagesResponse = packages.value;
else {
this.packagesResponse = undefined;
this.packageError = `Failed to load Pi packages from ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(packages.reason), target)}`;
}
if (errors.length > 0) this.error = `Failed to load settings: ${errors.join("; ")}`;
if (result.config !== undefined) this.configResponse = result.config;
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
this.error = result.error;
} finally {
if (this.isCurrentLoad(requestSeq, target)) this.loading = false;
if (this.isCurrentLoad(requestSeq)) this.loading = false;
}
}
private async loadPackagesForTarget(target = this.packageTarget()): Promise<void> {
const requestSeq = ++this.packageLoadRequestSeq;
this.packageLoading = true;
this.packageError = "";
this.packageMessage = "";
try {
const result = await loadPiPackagesData(target, (targetId) => piPackagesApi.packages(targetId));
if (!this.isCurrentPackageLoad(requestSeq, target)) return;
this.packagesResponse = result.packagesResponse;
this.packageError = result.error;
} finally {
if (this.isCurrentPackageLoad(requestSeq, target)) this.packageLoading = false;
}
}
@@ -233,8 +241,6 @@ export class SettingsDialog extends LitElement {
this.saving = true;
this.error = "";
this.savedMessage = "";
this.packageMessage = "";
this.packageError = "";
try {
const response = await configApi.saveConfig(config);
this.configResponse = response;
@@ -265,11 +271,11 @@ export class SettingsDialog extends LitElement {
private async runPiPackageMutation(operation: PiPackageOperationState, label: string, target: PiPackageTargetContext, mutate: () => Promise<PiPackageMutationResponse>): Promise<void> {
if (this.saving) throw new Error("A settings operation is already running.");
const requestSeq = ++this.packageMutationSeq;
this.packageLoadRequestSeq += 1;
this.packageLoading = false;
this.saving = true;
this.packageOperation = operation;
this.error = "";
this.packageError = "";
this.savedMessage = "";
this.packageMessage = "";
try {
const response = await mutate();
@@ -303,8 +309,12 @@ export class SettingsDialog extends LitElement {
return piPackageTargetContext(this.machine);
}
private isCurrentLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.loadRequestSeq && this.isCurrentPackageTarget(target);
private isCurrentLoad(requestSeq: number): boolean {
return requestSeq === this.loadRequestSeq;
}
private isCurrentPackageLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
return requestSeq === this.packageLoadRequestSeq && this.isCurrentPackageTarget(target);
}
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
@@ -317,7 +327,9 @@ export class SettingsDialog extends LitElement {
private resetPackageStateForTargetChange(): void {
const hadPackageOperation = this.packageOperation !== undefined;
this.packageLoadRequestSeq += 1;
this.packageMutationSeq += 1;
this.packageLoading = false;
this.packageOperation = undefined;
this.packageMessage = "";
this.packageError = "";
@@ -52,8 +52,11 @@ export class SettingsPackagesPanel extends LitElement {
}
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
const targetLabel = piPackageTargetLabel(target);
const packageListUnavailable = this.error !== "" && packages.length === 0;
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
const showUpdateAllReason = updateAllReason !== undefined && (packages.length > 0 || (!this.loading && !packageListUnavailable));
const updateAllTitle = packageListUnavailable ? `Pi package list unavailable for ${targetLabel}` : updateAllReason ?? "Update all user-scope Pi packages";
return html`
<section class="package-section" aria-label="Configured Pi packages">
<div class="package-toolbar">
@@ -61,20 +64,28 @@ export class SettingsPackagesPanel extends LitElement {
<h3>Configured Pi packages</h3>
<p>This list comes from Pi's package manager settings on ${targetLabel}.</p>
</div>
<button class="secondary" title=${updateAllReason ?? "Update all user-scope Pi packages"} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
<button class="secondary" title=${updateAllTitle} ?disabled=${this.isOperating || updateAllReason !== undefined} @click=${() => { void this.updatePackage(); }}>
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
</button>
</div>
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</div>`}
${this.loading && packages.length === 0 ? html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>` : packages.length === 0 ? html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>` : html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
`}
${showUpdateAllReason ? html`<div class="action-note">${updateAllReason}</div>` : null}
${this.loading && packages.length > 0 ? html`<div class="action-note">Refreshing Pi packages from ${targetLabel}…</div>` : null}
${this.renderPackageListContent(packages, targetLabel)}
</section>
`;
}
private renderPackageListContent(packages: PiPackageInfo[], targetLabel: string): TemplateResult {
if (this.loading && packages.length === 0) return html`<div class="loading-card">Loading Pi packages from ${targetLabel}…</div>`;
if (this.error !== "" && packages.length === 0) return html`<div class="loading-card">Pi package list unavailable for ${targetLabel}. Use Reload to try again.</div>`;
if (packages.length === 0) return html`<div class="loading-card">No Pi packages configured in Pi settings on ${targetLabel} yet.</div>`;
return html`
<div class="package-list">
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
</div>
`;
}
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
const updateReason = piPackageUpdateDisabledReason(packageInfo);
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { loadGatewaySettingsData, loadPiPackagesData } from "./settingsDataLoading";
const configResponse: PiWebConfigResponse = {
path: "/home/test/.config/pi-web/config.json",
exists: true,
config: { host: "127.0.0.1" },
effectiveConfig: { host: "127.0.0.1" },
envOverrides: { host: false, port: false, allowedHosts: false, spawnSessions: false, subsessions: false },
};
const pluginsResponse: PiWebPluginsResponse = { plugins: [] };
const packagesResponse: PiPackagesResponse = { packages: [{ source: "npm:@acme/tools", scope: "user", filtered: false }] };
const remoteTarget = { id: "remote-a", name: "Lab Mac", kind: "remote" } as const;
describe("settings data loading helpers", () => {
it("loads gateway settings without depending on Pi package data", async () => {
const result = await loadGatewaySettingsData({
loadConfig: () => Promise.resolve(configResponse),
loadPlugins: () => Promise.resolve(pluginsResponse),
});
expect(result).toEqual({ config: configResponse, plugins: pluginsResponse, error: "" });
});
it("keeps gateway settings errors scoped to gateway config and plugins", async () => {
const result = await loadGatewaySettingsData({
loadConfig: () => Promise.resolve(configResponse),
loadPlugins: () => Promise.reject(new Error("plugin scan failed")),
});
expect(result.config).toBe(configResponse);
expect(result.plugins).toBeUndefined();
expect(result.error).toBe("Failed to load settings: PI WEB plugins: plugin scan failed");
});
it("loads Pi packages for the selected target with package-scoped errors", async () => {
const requestedTargets: string[] = [];
const success = await loadPiPackagesData(remoteTarget, (targetId) => {
requestedTargets.push(targetId);
return Promise.resolve(packagesResponse);
});
const failure = await loadPiPackagesData(remoteTarget, (targetId) => {
requestedTargets.push(targetId);
return Promise.reject(new Error("Remote machine unavailable"));
});
expect(requestedTargets).toEqual(["remote-a", "remote-a"]);
expect(success).toEqual({ packagesResponse, error: "" });
expect(failure.packagesResponse).toBeUndefined();
expect(failure.error).toBe("Failed to load Pi packages from Lab Mac (remote machine): Could not reach Lab Mac for Pi package management. Check the machine connection and try again.");
});
});
@@ -0,0 +1,45 @@
import type { PiPackagesResponse, PiWebConfigResponse, PiWebPluginsResponse } from "../../api";
import { friendlyPiPackageErrorMessage, piPackageTargetLabel, type PiPackageTargetContext } from "./piPackageSettings";
export interface GatewaySettingsLoaders {
loadConfig: () => Promise<PiWebConfigResponse>;
loadPlugins: () => Promise<PiWebPluginsResponse>;
}
export interface GatewaySettingsLoadResult {
config?: PiWebConfigResponse;
plugins?: PiWebPluginsResponse;
error: string;
}
export interface PiPackagesLoadResult {
packagesResponse?: PiPackagesResponse;
error: string;
}
export async function loadGatewaySettingsData(loaders: GatewaySettingsLoaders): Promise<GatewaySettingsLoadResult> {
const [config, plugins] = await Promise.allSettled([loaders.loadConfig(), loaders.loadPlugins()]);
const result: GatewaySettingsLoadResult = { error: "" };
const errors: string[] = [];
if (config.status === "fulfilled") result.config = config.value;
else errors.push(`config: ${errorMessage(config.reason)}`);
if (plugins.status === "fulfilled") result.plugins = plugins.value;
else errors.push(`PI WEB plugins: ${errorMessage(plugins.reason)}`);
if (errors.length > 0) result.error = `Failed to load settings: ${errors.join("; ")}`;
return result;
}
export async function loadPiPackagesData(target: PiPackageTargetContext, loadPackages: (targetId: string) => Promise<PiPackagesResponse>): Promise<PiPackagesLoadResult> {
try {
return { packagesResponse: await loadPackages(target.id), error: "" };
} catch (error) {
return { error: `Failed to load Pi packages from ${piPackageTargetLabel(target)}: ${friendlyPiPackageErrorMessage(errorMessage(error), target)}` };
}
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}