feat(display): persist + surface active layout

Kiosk's layout.changed events now bump displays.active_layout_id on the
server side. Display edit page and kiosk edit page render the currently-
active layout, and the "Switch Layout" dropdowns pre-select it (with
"(active)" suffix) instead of defaulting to first-in-list. Stops the
operator from accidentally re-switching to the layout already showing.

Migration is idempotent + tail-positioned so existing DBs pick up the
column without breaking PRAGMA user_version semantics.
This commit is contained in:
Mitchell R 2026-05-21 10:19:39 +02:00
parent d51e01ff0e
commit 7df048c195
No known key found for this signature in database
5 changed files with 41 additions and 2 deletions

View file

@ -511,6 +511,20 @@ function registerKioskRoutes(
forwarded_to_nodered: false, forwarded_to_nodered: false,
}); });
// Side-effect: persist active layout per display so the admin UI can
// surface "currently showing X" without having to query event_log.
if (body.topic === "layout.changed") {
const displayId = Number(body.payload?.["display_id"]);
const layoutId = Number(body.payload?.["layout_id"]);
if (Number.isInteger(displayId) && Number.isInteger(layoutId)) {
try {
repo.updateDisplay(displayId, { active_layout_id: layoutId } as any);
} catch {
// Display might not exist; layout.changed is best-effort telemetry.
}
}
}
// Best-effort forward to Node-RED. Topics that have a dedicated trigger // Best-effort forward to Node-RED. Topics that have a dedicated trigger
// node (bf-trigger-layout-changed etc.) expect a FLAT payload matching // node (bf-trigger-layout-changed etc.) expect a FLAT payload matching
// what the admin-side emit produces — splat body.payload up to the top // what the admin-side emit produces — splat body.payload up to the top

View file

@ -141,6 +141,7 @@ export function rowToDisplay(r: Row): Display {
state_check_enabled: b(r["state_check_enabled"]), state_check_enabled: b(r["state_check_enabled"]),
state_check_interval_seconds: n(r["state_check_interval_seconds"]), state_check_interval_seconds: n(r["state_check_interval_seconds"]),
is_enabled: b(r["is_enabled"]), is_enabled: b(r["is_enabled"]),
active_layout_id: nn(r["active_layout_id"]),
}; };
} }

View file

@ -867,6 +867,13 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT"); addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT");
}, },
// Kiosk reports active layout per display via layout.changed events.
// Persist on the display row so the admin UI can highlight which layout
// is currently rendering instead of defaulting to first-in-list.
(db: DatabaseSync) => {
addColumnIfNotExists(db, "displays", "active_layout_id", "INTEGER REFERENCES layouts(id) ON DELETE SET NULL");
},
// Backfill hwmon/telemetry columns. They were originally added inline to // Backfill hwmon/telemetry columns. They were originally added inline to
// an earlier migration entry; existing deploys had already passed that // an earlier migration entry; existing deploys had already passed that
// index via PRAGMA user_version, so the new columns silently never landed. // index via PRAGMA user_version, so the new columns silently never landed.

View file

@ -103,6 +103,7 @@ export interface Display {
state_check_enabled: boolean; state_check_enabled: boolean;
state_check_interval_seconds: number; state_check_interval_seconds: number;
is_enabled: boolean; is_enabled: boolean;
active_layout_id: number | null;
} }
export interface Camera { export interface Camera {

View file

@ -1596,7 +1596,10 @@ export function KioskEditPage(props: KioskEditProps) {
{layouts.length > 0 ? ( {layouts.length > 0 ? (
<select name="layout_id" class="form-input"> <select name="layout_id" class="form-input">
{layouts.map((l) => ( {layouts.map((l) => (
<option value={String(l.id)}>{l.name}</option> <option
value={String(l.id)}
selected={l.id === display.active_layout_id}
>{l.name}{l.id === display.active_layout_id ? " (active)" : ""}</option>
))} ))}
</select> </select>
) : ( ) : (
@ -2538,6 +2541,16 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
{d.kiosk_id && ( {d.kiosk_id && (
<div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div> <div>Kiosk: <a href={`/admin/kiosks/${d.kiosk_id}`}>{props.kioskName ?? `#${String(d.kiosk_id)}`}</a></div>
)} )}
{(() => {
const active = props.attachedLayouts.find((l) => l.id === d.active_layout_id);
return (
<div>
Active layout: {active
? <strong>{active.name}</strong>
: <span style="color:#999">(unknown kiosk hasn't reported)</span>}
</div>
);
})()}
</div> </div>
{props.attachedLayouts.length > 0 && d.kiosk_id ? ( {props.attachedLayouts.length > 0 && d.kiosk_id ? (
<div style="margin-bottom:1rem; padding:0.75rem; background:#f9fafb; border-radius:4px"> <div style="margin-bottom:1rem; padding:0.75rem; background:#f9fafb; border-radius:4px">
@ -2553,7 +2566,10 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
> >
<select name="layout_id" class="form-input" style="flex:1"> <select name="layout_id" class="form-input" style="flex:1">
{props.attachedLayouts.map((l) => ( {props.attachedLayouts.map((l) => (
<option value={String(l.id)}>{l.name}</option> <option
value={String(l.id)}
selected={l.id === d.active_layout_id}
>{l.name}{l.id === d.active_layout_id ? " (active)" : ""}</option>
))} ))}
</select> </select>
<button <button