From ed3806f9984ab16f2f3a0dad827b1593e6f5154c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 17 Aug 2026 13:24:27 +0000 Subject: [PATCH] installing a plugin resets its saved layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a layout stores appType STRINGS. rename a panel key between versions and every saved layout still names the old one; normalizeLayout swaps each unknown appType for allowed[0] — whichever panel the plugin lists first — and WorkspaceView persists the repair. so renaming headscale-* to offscale-* rewrote the owner's layout into three copies of the server picker and saved it. ServersView never mounted, there was no register form on the page at all, and it survived a server restart, a sidecar restart and a plugin reinstall because nothing on disk was wrong. found by counting elements in a real browser: 3 links, 0 buttons. install is the only place that knows a plugin's panels may have just changed shape, so that is where the stale row dies — for every user, since the damage is not per-user and install is owner-only. nothing is lost: a plugin screen is locked, so the layout is the author's design rather than someone's arrangement, and useDashboardState re-seeds from defaultLayout on the next load. does NOT fix the underlying trap: renaming a panel key is still a silent data migration for any screen that is not a plugin. see TODO. --- .../officer_db/src/dashboards/index.ts | 1 + .../officer_db/src/dashboards/queries.ts | 23 +++++++++++++++++++ src/servers/plugins/install.ts | 18 +++++++++++++++ 3 files changed, 42 insertions(+) diff --git a/src/databases/officer_db/src/dashboards/index.ts b/src/databases/officer_db/src/dashboards/index.ts index 03931df6..8c2c8c5b 100644 --- a/src/databases/officer_db/src/dashboards/index.ts +++ b/src/databases/officer_db/src/dashboards/index.ts @@ -6,6 +6,7 @@ export { setDashboardPanelState, upsertScreen, deleteScreen, + deleteScreenForAllUsers, upsertDefaults, setDefaultsPanelState, } from './queries'; diff --git a/src/databases/officer_db/src/dashboards/queries.ts b/src/databases/officer_db/src/dashboards/queries.ts index e5ed65b4..90d0af0a 100644 --- a/src/databases/officer_db/src/dashboards/queries.ts +++ b/src/databases/officer_db/src/dashboards/queries.ts @@ -210,6 +210,29 @@ export async function deleteScreen(userId: number, name: string): Promise await db.delete(screens).where(and(eq(screens.userId, userId), eq(screens.name, name))); } +/** + * Forget one screen's saved layout for EVERY user. Returns how many rows went. + * + * For plugin installs, and the reason is a bug that cost an evening. A layout stores `appType` STRINGS and + * the registry resolves them at render; rename a panel key and every saved layout still names the old one. + * `normalizeLayout` then replaces each unknown appType with the caller's fallback — `allowed[0]`, whichever + * panel the plugin happens to list first — and `WorkspaceView` writes the repair back. So renaming + * `headscale-*` to `offscale-*` silently rewrote the owner's layout into three copies of the first panel + * and saved it. Restarting, reinstalling and rebuilding all changed nothing, because the code was right and + * the data was wrong. + * + * Deleting is safe rather than merely convenient: a plugin screen is `locked`, so the layout is the + * author's design and not a workspace anyone arranged. There is no user intent in the row to lose, and + * `useDashboardState` re-seeds from the plugin's own `defaultLayout` on the next load. + * + * Not scoped to a user, because the damage is not either — install is owner-only and the rows belong to + * everyone who ever opened the screen. + */ +export async function deleteScreenForAllUsers(name: string): Promise { + const rows = await db.delete(screens).where(eq(screens.name, name)).returning({ id: screens.id }); + return rows.length; +} + // ── Defaults ── type UpsertDefaultsData = { diff --git a/src/servers/plugins/install.ts b/src/servers/plugins/install.ts index 8f1384ff..93fd2f61 100644 --- a/src/servers/plugins/install.ts +++ b/src/servers/plugins/install.ts @@ -1,4 +1,5 @@ import { + deleteScreenForAllUsers, getDockPaths, getPluginInstall, recordPluginInstall, @@ -272,6 +273,23 @@ export async function installPlugin( await recordPluginInstall(appName, plugin.manifest.version); await step(steps, onStep, `recorded at ${plugin.manifest.version}`); + /* + Throw away every saved layout for this plugin's screen, for every user. + + A layout holds `appType` STRINGS. Rename a panel key between two versions and every saved layout still + names the old one; `normalizeLayout` then swaps each unknown appType for `allowed[0]` — whichever panel + the plugin lists first — and `WorkspaceView` persists the repair. Renaming `headscale-*` to `offscale-*` + therefore rewrote the owner's layout into three copies of the picker and saved it, which survived a + server restart, a sidecar restart and a reinstall, because nothing on disk was wrong. + + Install is the only place that knows a plugin's panels may have just changed shape, so it is where the + stale layout dies. Nothing is lost: a plugin screen is `locked`, so the layout is the author's design + rather than an arrangement someone made, and `useDashboardState` re-seeds from `defaultLayout` on the + next load. + */ + const clearedLayouts = await deleteScreenForAllUsers(`plugin/${appName}`); + if (clearedLayouts) await step(steps, onStep, `layout: reset for ${clearedLayouts} user(s)`); + // The dock tile's artwork, copied where the browser can fetch it. Reuses the app store's publisher — // `/assets/` → `public/plugins//` — because it already solves the parts that are not // obvious: the `/plugins/*` route is dynamic precisely so a first install does not show a broken