Archived
fix: decouple settings package loading
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@jmfederico/pi-web": patch
|
||||
---
|
||||
|
||||
Keep gateway Settings panels responsive while selected-machine Pi packages load or fail separately.
|
||||
@@ -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 (result.config !== undefined) this.configResponse = result.config;
|
||||
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
|
||||
this.error = result.error;
|
||||
} finally {
|
||||
if (this.isCurrentLoad(requestSeq)) this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) this.error = `Failed to load settings: ${errors.join("; ")}`;
|
||||
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.isCurrentLoad(requestSeq, target)) this.loading = false;
|
||||
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,17 +64,25 @@ 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`
|
||||
${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>
|
||||
`}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user