feat(display): admin enable/disable toggle

is_enabled column on displays (default 1). Disabled displays are filtered
from the kiosk bundle so the kiosk never opens a window on them. Admin
edit page exposes a checkbox; list page shows a "disabled" badge.
This commit is contained in:
Mitchell R 2026-05-13 02:59:28 +02:00
parent bfb5028001
commit faaa2cef39
6 changed files with 32 additions and 2 deletions

View file

@ -973,6 +973,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
default_layout_id: validatedDefault, default_layout_id: validatedDefault,
idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10), idle_timeout_seconds: parseInt(body?.["idle_timeout_seconds"] ?? "0", 10),
sleep_timeout_seconds: parseInt(body?.["sleep_timeout_seconds"] ?? "0", 10), sleep_timeout_seconds: parseInt(body?.["sleep_timeout_seconds"] ?? "0", 10),
is_enabled: body?.["is_enabled"] === "on" || body?.["is_enabled"] === "1" ? 1 : 0,
} as any); } as any);
notifyKiosks(); notifyKiosks();
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });

View file

@ -127,6 +127,7 @@ export function rowToDisplay(r: Row): Display {
desired_power_state: s(r["desired_power_state"]) as DesiredPowerState, desired_power_state: s(r["desired_power_state"]) as DesiredPowerState,
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"]),
}; };
} }

View file

@ -652,4 +652,9 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`, ) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`, `CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`,
// ---- displays.is_enabled — admin toggle to suppress window on a display ----
(db: DatabaseSync) => {
addColumnIfNotExists(db, "displays", "is_enabled", "INTEGER NOT NULL DEFAULT 1");
},
]; ];

View file

@ -116,10 +116,12 @@ export function generateBundle(
// Find all displays for this kiosk (displays now point to kiosks via kiosk_id) // Find all displays for this kiosk (displays now point to kiosks via kiosk_id)
const kioskDisplays = repo.listDisplaysForKiosk(kioskId); const kioskDisplays = repo.listDisplaysForKiosk(kioskId);
// Fall back to legacy kiosk.display_id if no displays point to this kiosk yet // Fall back to legacy kiosk.display_id if no displays point to this kiosk yet
const displays = kioskDisplays.length > 0 const allDisplays = kioskDisplays.length > 0
? kioskDisplays ? kioskDisplays
: (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []); : (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
// Admin can disable a display — kiosk must never open a window on it.
const displays = allDisplays.filter((d) => d.is_enabled);
if (displays.length === 0) return null; if (displays.length === 0) return null;
// Collect camera IDs across ALL displays' layouts (de-duped). // Collect camera IDs across ALL displays' layouts (de-duped).

View file

@ -99,6 +99,7 @@ export interface Display {
desired_power_state: DesiredPowerState; desired_power_state: DesiredPowerState;
state_check_enabled: boolean; state_check_enabled: boolean;
state_check_interval_seconds: number; state_check_interval_seconds: number;
is_enabled: boolean;
} }
export interface Camera { export interface Camera {

View file

@ -2328,6 +2328,21 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<input id="name" name="name" type="text" class="form-input" value={d.name} required /> <input id="name" name="name" type="text" class="form-input" value={d.name} required />
</div> </div>
<div class="form-group">
<label style="display:flex; align-items:center; gap:0.5rem; cursor:pointer">
<input
type="checkbox"
name="is_enabled"
value="on"
checked={d.is_enabled}
/>
<span>Enabled</span>
</label>
<div class="form-hint">
When disabled, the kiosk will not open a window on this display. Display stays in the list so you can re-enable it later.
</div>
</div>
<div class="form-group"> <div class="form-group">
<label for="default_layout_id">Default Layout</label> <label for="default_layout_id">Default Layout</label>
<select id="default_layout_id" name="default_layout_id" class="form-input"> <select id="default_layout_id" name="default_layout_id" class="form-input">
@ -2407,7 +2422,12 @@ export function DisplaysPage(props: DisplaysPageProps) {
) : ( ) : (
props.displays.map((d) => ( props.displays.map((d) => (
<tr> <tr>
<td><a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a></td> <td>
<a href={`/admin/displays/${d.id}`}><strong>{d.name}</strong></a>
{!d.is_enabled && (
<span style="margin-left:0.5rem; padding:0.1rem 0.4rem; font-size:0.7rem; background:#fee; color:#a00; border-radius:3px">disabled</span>
)}
</td>
<td style="color:#666">{String(d.width_px)}x{String(d.height_px)} index {String(d.index)}</td> <td style="color:#666">{String(d.width_px)}x{String(d.height_px)} index {String(d.index)}</td>
</tr> </tr>
)) ))