installing a plugin resets its saved layout

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.
This commit is contained in:
2026-08-17 13:24:27 +00:00
parent 2c11e42717
commit ed3806f998
3 changed files with 42 additions and 0 deletions
@@ -6,6 +6,7 @@ export {
setDashboardPanelState,
upsertScreen,
deleteScreen,
deleteScreenForAllUsers,
upsertDefaults,
setDefaultsPanelState,
} from './queries';
@@ -210,6 +210,29 @@ export async function deleteScreen(userId: number, name: string): Promise<void>
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<number> {
const rows = await db.delete(screens).where(eq(screens.name, name)).returning({ id: screens.id });
return rows.length;
}
// ── Defaults ──
type UpsertDefaultsData = {