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/SettingsPluginsPanel";
|
||||||
import "./settings/SettingsShortcutsPanel";
|
import "./settings/SettingsShortcutsPanel";
|
||||||
import { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
import { friendlyPiPackageErrorMessage, piPackageMutationFollowUpMessage, piPackageTargetContext, piPackageTargetLabel, shouldRefreshGatewayPluginsAfterPiPackageMutation, type PiPackageOperationState, type PiPackageTargetContext } from "./settings/piPackageSettings";
|
||||||
|
import { loadGatewaySettingsData, loadPiPackagesData } from "./settings/settingsDataLoading";
|
||||||
|
|
||||||
@customElement("settings-dialog")
|
@customElement("settings-dialog")
|
||||||
export class SettingsDialog extends LitElement {
|
export class SettingsDialog extends LitElement {
|
||||||
@@ -22,6 +23,7 @@ export class SettingsDialog extends LitElement {
|
|||||||
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
@state() private pluginsResponse: PiWebPluginsResponse | undefined;
|
||||||
@state() private packagesResponse: PiPackagesResponse | undefined;
|
@state() private packagesResponse: PiPackagesResponse | undefined;
|
||||||
@state() private loading = true;
|
@state() private loading = true;
|
||||||
|
@state() private packageLoading = true;
|
||||||
@state() private saving = false;
|
@state() private saving = false;
|
||||||
@state() private packageOperation: PiPackageOperationState | undefined;
|
@state() private packageOperation: PiPackageOperationState | undefined;
|
||||||
@state() private error = "";
|
@state() private error = "";
|
||||||
@@ -30,12 +32,13 @@ export class SettingsDialog extends LitElement {
|
|||||||
@state() private packageMessage = "";
|
@state() private packageMessage = "";
|
||||||
private savedMessageTimer: number | undefined;
|
private savedMessageTimer: number | undefined;
|
||||||
private loadRequestSeq = 0;
|
private loadRequestSeq = 0;
|
||||||
|
private packageLoadRequestSeq = 0;
|
||||||
private packageMutationSeq = 0;
|
private packageMutationSeq = 0;
|
||||||
private lastRequestedPackageTargetId: string | undefined;
|
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
void this.loadConfig();
|
void this.loadConfig();
|
||||||
|
void this.loadPackagesForTarget();
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback(): void {
|
override disconnectedCallback(): void {
|
||||||
@@ -50,7 +53,7 @@ export class SettingsDialog extends LitElement {
|
|||||||
const currentTarget = this.packageTarget();
|
const currentTarget = this.packageTarget();
|
||||||
if (previousTarget.id === currentTarget.id) return;
|
if (previousTarget.id === currentTarget.id) return;
|
||||||
this.resetPackageStateForTargetChange();
|
this.resetPackageStateForTargetChange();
|
||||||
if (this.isConnected && this.lastRequestedPackageTargetId !== currentTarget.id) void this.loadConfig();
|
if (this.isConnected) void this.loadPackagesForTarget(currentTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
override render(): TemplateResult {
|
override render(): TemplateResult {
|
||||||
@@ -115,11 +118,11 @@ export class SettingsDialog extends LitElement {
|
|||||||
<settings-packages-panel
|
<settings-packages-panel
|
||||||
.packagesResponse=${this.packagesResponse}
|
.packagesResponse=${this.packagesResponse}
|
||||||
.targetMachine=${this.packageTarget()}
|
.targetMachine=${this.packageTarget()}
|
||||||
.loading=${this.loading}
|
.loading=${this.packageLoading}
|
||||||
.operation=${this.packageOperation}
|
.operation=${this.packageOperation}
|
||||||
.error=${this.packageError}
|
.error=${this.packageError}
|
||||||
.operationMessage=${this.packageMessage}
|
.operationMessage=${this.packageMessage}
|
||||||
.onReload=${() => this.loadConfig()}
|
.onReload=${() => this.loadPackagesForTarget()}
|
||||||
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
.onInstallPackage=${(source: string) => this.installPiPackage(source)}
|
||||||
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
|
.onRemovePackage=${(source: string, scope: PiPackageScope) => this.removePiPackage(source, scope)}
|
||||||
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
|
.onUpdatePackage=${(source?: string) => this.updatePiPackage(source)}
|
||||||
@@ -184,32 +187,37 @@ export class SettingsDialog extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async loadConfig(): Promise<void> {
|
private async loadConfig(): Promise<void> {
|
||||||
const target = this.packageTarget();
|
|
||||||
const requestSeq = ++this.loadRequestSeq;
|
const requestSeq = ++this.loadRequestSeq;
|
||||||
this.lastRequestedPackageTargetId = target.id;
|
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.packageError = "";
|
|
||||||
try {
|
try {
|
||||||
const [config, plugins, packages] = await Promise.allSettled([configApi.config(), pluginsApi.plugins(), piPackagesApi.packages(target.id)]);
|
const result = await loadGatewaySettingsData({
|
||||||
if (!this.isCurrentLoad(requestSeq, target)) return;
|
loadConfig: () => configApi.config(),
|
||||||
|
loadPlugins: () => pluginsApi.plugins(),
|
||||||
|
});
|
||||||
|
if (!this.isCurrentLoad(requestSeq)) return;
|
||||||
|
|
||||||
const errors: string[] = [];
|
if (result.config !== undefined) this.configResponse = result.config;
|
||||||
if (config.status === "fulfilled") this.configResponse = config.value;
|
if (result.plugins !== undefined) this.pluginsResponse = result.plugins;
|
||||||
else errors.push(`config: ${errorMessage(config.reason)}`);
|
this.error = result.error;
|
||||||
|
|
||||||
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("; ")}`;
|
|
||||||
} finally {
|
} 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.saving = true;
|
||||||
this.error = "";
|
this.error = "";
|
||||||
this.savedMessage = "";
|
this.savedMessage = "";
|
||||||
this.packageMessage = "";
|
|
||||||
this.packageError = "";
|
|
||||||
try {
|
try {
|
||||||
const response = await configApi.saveConfig(config);
|
const response = await configApi.saveConfig(config);
|
||||||
this.configResponse = response;
|
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> {
|
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.");
|
if (this.saving) throw new Error("A settings operation is already running.");
|
||||||
const requestSeq = ++this.packageMutationSeq;
|
const requestSeq = ++this.packageMutationSeq;
|
||||||
|
this.packageLoadRequestSeq += 1;
|
||||||
|
this.packageLoading = false;
|
||||||
this.saving = true;
|
this.saving = true;
|
||||||
this.packageOperation = operation;
|
this.packageOperation = operation;
|
||||||
this.error = "";
|
|
||||||
this.packageError = "";
|
this.packageError = "";
|
||||||
this.savedMessage = "";
|
|
||||||
this.packageMessage = "";
|
this.packageMessage = "";
|
||||||
try {
|
try {
|
||||||
const response = await mutate();
|
const response = await mutate();
|
||||||
@@ -303,8 +309,12 @@ export class SettingsDialog extends LitElement {
|
|||||||
return piPackageTargetContext(this.machine);
|
return piPackageTargetContext(this.machine);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isCurrentLoad(requestSeq: number, target: PiPackageTargetContext): boolean {
|
private isCurrentLoad(requestSeq: number): boolean {
|
||||||
return requestSeq === this.loadRequestSeq && this.isCurrentPackageTarget(target);
|
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 {
|
private isCurrentPackageMutation(requestSeq: number, target: PiPackageTargetContext): boolean {
|
||||||
@@ -317,7 +327,9 @@ export class SettingsDialog extends LitElement {
|
|||||||
|
|
||||||
private resetPackageStateForTargetChange(): void {
|
private resetPackageStateForTargetChange(): void {
|
||||||
const hadPackageOperation = this.packageOperation !== undefined;
|
const hadPackageOperation = this.packageOperation !== undefined;
|
||||||
|
this.packageLoadRequestSeq += 1;
|
||||||
this.packageMutationSeq += 1;
|
this.packageMutationSeq += 1;
|
||||||
|
this.packageLoading = false;
|
||||||
this.packageOperation = undefined;
|
this.packageOperation = undefined;
|
||||||
this.packageMessage = "";
|
this.packageMessage = "";
|
||||||
this.packageError = "";
|
this.packageError = "";
|
||||||
|
|||||||
@@ -52,8 +52,11 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
|
private renderPackageList(packages: PiPackageInfo[], target: PiPackageTargetContext): TemplateResult {
|
||||||
const updateAllReason = updateAllPiPackagesDisabledReason(packages);
|
|
||||||
const targetLabel = piPackageTargetLabel(target);
|
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`
|
return html`
|
||||||
<section class="package-section" aria-label="Configured Pi packages">
|
<section class="package-section" aria-label="Configured Pi packages">
|
||||||
<div class="package-toolbar">
|
<div class="package-toolbar">
|
||||||
@@ -61,20 +64,28 @@ export class SettingsPackagesPanel extends LitElement {
|
|||||||
<h3>Configured Pi packages</h3>
|
<h3>Configured Pi packages</h3>
|
||||||
<p>This list comes from Pi's package manager settings on ${targetLabel}.</p>
|
<p>This list comes from Pi's package manager settings on ${targetLabel}.</p>
|
||||||
</div>
|
</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"}
|
${isPiPackageOperationPending(this.operation, "update-all") ? "Updating…" : "Update all"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
${updateAllReason === undefined ? null : html`<div class="action-note">${updateAllReason}</div>`}
|
${showUpdateAllReason ? html`<div class="action-note">${updateAllReason}</div>` : null}
|
||||||
${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`
|
${this.loading && packages.length > 0 ? html`<div class="action-note">Refreshing Pi packages from ${targetLabel}…</div>` : null}
|
||||||
<div class="package-list">
|
${this.renderPackageListContent(packages, targetLabel)}
|
||||||
${packages.map((packageInfo) => this.renderPackage(packageInfo))}
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</section>
|
</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 {
|
private renderPackage(packageInfo: PiPackageInfo): TemplateResult {
|
||||||
const updateReason = piPackageUpdateDisabledReason(packageInfo);
|
const updateReason = piPackageUpdateDisabledReason(packageInfo);
|
||||||
const updating = isPiPackageOperationPending(this.operation, "update", packageInfo.source);
|
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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user