mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 21:26:33 +00:00
feat: layout switch push + idle/sleep timer + offline bundle cache
Layout switch push:
- POST /admin/kiosks/:id/layout/:layoutId — coordinator sends
{type:"layout-switch", layout_id} via WS
- Kiosk renders specified layout from cached bundle
- KioskEditPage adds Switch Layout dropdown + button
Idle/sleep timer:
- thread_local LAST_ACTIVITY + IS_ASLEEP + CURRENT_LAYOUT_ID
- mark_activity() on render/switch/wake; wakes if asleep
- glib timeout_add_local every 1s checks elapsed:
- elapsed >= idle_timeout AND not on default + resets_idle_timer
→ switch to default layout
- elapsed >= sleep_timeout AND !asleep → cec::standby()
- Display idle/sleep timeouts from bundle.display
Offline cache:
- server::save_bundle → ~/.betterframe-kiosk/bundle.json
- server::load_cached_bundle on offline boot
- fetch_bundle no longer panics; returns Option
- 30s retry loop until server reachable
- Reload-bundle gracefully handles fetch failures
This commit is contained in:
parent
1e09582379
commit
1c0fe02fcf
6 changed files with 319 additions and 35 deletions
|
|
@ -12,6 +12,8 @@ pub enum ServerMsg {
|
|||
Wake,
|
||||
/// Some(0..=255) = manual PWM. None = restore auto.
|
||||
Fan(Option<u32>),
|
||||
/// Switch to a specific layout by ID (must be present in current bundle).
|
||||
SwitchLayout(u32),
|
||||
}
|
||||
|
||||
use gtk4::prelude::{ApplicationExt, ApplicationExtManual};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,32 @@ fn state_dir() -> PathBuf {
|
|||
|
||||
fn key_file() -> PathBuf { state_dir().join("kiosk.key") }
|
||||
fn server_file() -> PathBuf { state_dir().join("server.url") }
|
||||
fn bundle_cache_path() -> PathBuf { state_dir().join("bundle.json") }
|
||||
|
||||
/// Persist the latest bundle to disk for offline boot.
|
||||
pub fn save_bundle(bundle: &KioskBundle) {
|
||||
match serde_json::to_string(bundle) {
|
||||
Ok(text) => {
|
||||
if let Err(e) = fs::write(bundle_cache_path(), text) {
|
||||
tracing::warn!("failed to save bundle cache: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("failed to serialize bundle: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a cached bundle from disk. Returns None if file missing or invalid.
|
||||
pub fn load_cached_bundle() -> Option<KioskBundle> {
|
||||
let path = bundle_cache_path();
|
||||
let text = fs::read_to_string(&path).ok()?;
|
||||
match serde_json::from_str::<KioskBundle>(&text) {
|
||||
Ok(b) => Some(b),
|
||||
Err(e) => {
|
||||
tracing::warn!("cached bundle invalid: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the BetterFrame server.
|
||||
pub fn discover_server(override_url: Option<&str>) -> String {
|
||||
|
|
@ -132,20 +158,38 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
|
|||
}
|
||||
}
|
||||
|
||||
/// Fetch bundle from server.
|
||||
pub fn fetch_bundle(server: &str, key: &str) -> KioskBundle {
|
||||
/// Fetch bundle from server. Returns None on network/HTTP/parse failure.
|
||||
/// On success, also writes the bundle to the on-disk cache.
|
||||
pub fn fetch_bundle(server: &str, key: &str) -> Option<KioskBundle> {
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let resp = client
|
||||
let resp = match client
|
||||
.get(format!("{server}/api/kiosk/bundle"))
|
||||
.header("Authorization", format!("Bearer {key}"))
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.expect("bundle fetch failed");
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("bundle fetch failed: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
panic!("Bundle fetch returned {}", resp.status());
|
||||
tracing::warn!("bundle fetch returned {}", resp.status());
|
||||
return None;
|
||||
}
|
||||
|
||||
resp.json().expect("bad bundle JSON")
|
||||
match resp.json::<KioskBundle>() {
|
||||
Ok(b) => {
|
||||
save_bundle(&b);
|
||||
Some(b)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("bundle parse failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send heartbeat with display geometry + hwmon.
|
||||
|
|
|
|||
253
kiosk/src/ui.rs
253
kiosk/src/ui.rs
|
|
@ -1,15 +1,8 @@
|
|||
use std::cell::RefCell;
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
use url::Url;
|
||||
|
||||
thread_local! {
|
||||
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
|
||||
/// layout swaps for cameras still referenced or in preload_camera_ids.
|
||||
/// badge is 'M' / 'S' / ' ' indicating which stream is active.
|
||||
static WARM_CAMERAS: RefCell<std::collections::HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
||||
= RefCell::new(std::collections::HashMap::new());
|
||||
}
|
||||
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
|
||||
use tracing::{info, warn};
|
||||
|
|
@ -22,6 +15,32 @@ use crate::server;
|
|||
use crate::ws_client;
|
||||
use crate::ServerMsg;
|
||||
|
||||
thread_local! {
|
||||
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
|
||||
/// layout swaps for cameras still referenced or in preload_camera_ids.
|
||||
/// badge is 'M' / 'S' / ' ' indicating which stream is active.
|
||||
static WARM_CAMERAS: RefCell<std::collections::HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
|
||||
= RefCell::new(std::collections::HashMap::new());
|
||||
|
||||
/// Most recently rendered bundle. Used for layout-switch + idle revert.
|
||||
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
|
||||
|
||||
/// Server URL + kiosk key for re-rendering on layout-switch.
|
||||
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
|
||||
|
||||
/// Layout id currently on screen, if any.
|
||||
static CURRENT_LAYOUT_ID: Cell<Option<u32>> = const { Cell::new(None) };
|
||||
|
||||
/// Timestamp of the last "activity" event (render, switch, wake).
|
||||
static LAST_ACTIVITY: RefCell<Instant> = RefCell::new(Instant::now());
|
||||
|
||||
/// True after we've fired CEC standby due to sleep timeout.
|
||||
static IS_ASLEEP: Cell<bool> = const { Cell::new(false) };
|
||||
|
||||
/// Has the idle-watchdog already been installed on the main loop?
|
||||
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
|
||||
}
|
||||
|
||||
const APP_ID: &str = "dev.betterframe.kiosk";
|
||||
const BETTERFRAME_LOGO_SVG: &str = include_str!("../../server/src/web-static/betterframe-logo.svg");
|
||||
const BETTERFRAME_MARK_SVG: &str = include_str!("../../server/src/web-static/betterframe-mark.svg");
|
||||
|
|
@ -71,9 +90,27 @@ fn activate(app: &Application) {
|
|||
key
|
||||
};
|
||||
|
||||
let bundle = server::fetch_bundle(&server, &key);
|
||||
info!("bundle: {} cameras, {} layouts", bundle.cameras.len(), bundle.layouts.len());
|
||||
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
|
||||
// Try fetching live bundle. If server unreachable, fall back to
|
||||
// cached on-disk bundle and keep retrying every 30s in the background.
|
||||
let initial = match server::fetch_bundle(&server, &key) {
|
||||
Some(b) => {
|
||||
info!("bundle: {} cameras, {} layouts", b.cameras.len(), b.layouts.len());
|
||||
Some(b)
|
||||
}
|
||||
None => {
|
||||
if let Some(cached) = server::load_cached_bundle() {
|
||||
warn!("offline mode: rendering cached bundle");
|
||||
Some(cached)
|
||||
} else {
|
||||
warn!("no bundle available (server unreachable, no cache)");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(bundle) = initial {
|
||||
let _ = tx.send(WorkerMsg::RenderBundle(bundle, server.clone(), key.clone()));
|
||||
}
|
||||
|
||||
// Spawn WS client in a separate thread for live updates
|
||||
let server_ws = server.clone();
|
||||
|
|
@ -87,22 +124,55 @@ fn activate(app: &Application) {
|
|||
ws_client::run(&server_ws, &key_ws, ws_tx);
|
||||
});
|
||||
|
||||
// Background retry thread: if we couldn't fetch a live bundle on boot,
|
||||
// try again every 30s until we get one. Once fetched, send a render.
|
||||
let retry_tx = tx.clone();
|
||||
let retry_server = server.clone();
|
||||
let retry_key = key.clone();
|
||||
std::thread::spawn(move || {
|
||||
// Only loop while we have no live bundle yet — best-effort heuristic:
|
||||
// we attempt once, then sleep. If server unreachable each time we
|
||||
// keep waiting; once a fetch succeeds we push a fresh render.
|
||||
// After first success we exit; subsequent updates flow via WS.
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) {
|
||||
info!("offline-retry: fresh bundle fetched, rendering");
|
||||
let _ = retry_tx.send(WorkerMsg::RenderBundle(
|
||||
b,
|
||||
retry_server.clone(),
|
||||
retry_key.clone(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for WS messages and dispatch
|
||||
std::thread::spawn(move || {
|
||||
for msg in ws_rx {
|
||||
match msg {
|
||||
ServerMsg::ReloadBundle => {
|
||||
info!("reloading bundle");
|
||||
let bundle = server::fetch_bundle(&server_for_reload, &key_for_reload);
|
||||
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(
|
||||
bundle,
|
||||
server_for_reload.clone(),
|
||||
key_for_reload.clone(),
|
||||
));
|
||||
match server::fetch_bundle(&server_for_reload, &key_for_reload) {
|
||||
Some(bundle) => {
|
||||
let _ = tx_for_reload.send(WorkerMsg::RenderBundle(
|
||||
bundle,
|
||||
server_for_reload.clone(),
|
||||
key_for_reload.clone(),
|
||||
));
|
||||
}
|
||||
None => warn!("reload-bundle: fetch failed, keeping current render"),
|
||||
}
|
||||
}
|
||||
ServerMsg::Standby => cec::standby(),
|
||||
ServerMsg::Wake => cec::wake(),
|
||||
ServerMsg::Wake => {
|
||||
let _ = tx_for_reload.send(WorkerMsg::Wake);
|
||||
}
|
||||
ServerMsg::Fan(pwm) => { hwmon::set_fan(pwm); }
|
||||
ServerMsg::SwitchLayout(id) => {
|
||||
let _ = tx_for_reload.send(WorkerMsg::SwitchLayout(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -122,7 +192,18 @@ fn activate(app: &Application) {
|
|||
while let Ok(msg) = rx.try_recv() {
|
||||
match msg {
|
||||
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code),
|
||||
WorkerMsg::RenderBundle(bundle, server, key) => render_bundle(&window_clone, bundle, &server, &key),
|
||||
WorkerMsg::RenderBundle(bundle, server, key) => {
|
||||
render_bundle(&window_clone, bundle, &server, &key);
|
||||
install_idle_watchdog(&window_clone);
|
||||
}
|
||||
WorkerMsg::SwitchLayout(id) => {
|
||||
render_layout(&window_clone, id);
|
||||
}
|
||||
WorkerMsg::Wake => {
|
||||
cec::wake();
|
||||
IS_ASLEEP.with(|c| c.set(false));
|
||||
mark_activity();
|
||||
}
|
||||
}
|
||||
}
|
||||
gtk::glib::ControlFlow::Continue
|
||||
|
|
@ -132,6 +213,73 @@ fn activate(app: &Application) {
|
|||
enum WorkerMsg {
|
||||
ShowPairingCode(String),
|
||||
RenderBundle(KioskBundle, String, String),
|
||||
SwitchLayout(u32),
|
||||
Wake,
|
||||
}
|
||||
|
||||
/// Reset activity timer. If we were asleep, wake the display first.
|
||||
fn mark_activity() {
|
||||
LAST_ACTIVITY.with(|t| *t.borrow_mut() = Instant::now());
|
||||
if IS_ASLEEP.with(|c| c.get()) {
|
||||
info!("activity while asleep → waking display");
|
||||
cec::wake();
|
||||
IS_ASLEEP.with(|c| c.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the once-per-second watchdog that enforces idle/sleep timeouts.
|
||||
/// Safe to call multiple times — installs at most once.
|
||||
fn install_idle_watchdog(window: &ApplicationWindow) {
|
||||
if WATCHDOG_INSTALLED.with(|c| c.get()) { return; }
|
||||
WATCHDOG_INSTALLED.with(|c| c.set(true));
|
||||
let window = window.clone();
|
||||
gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
|
||||
let elapsed = LAST_ACTIVITY.with(|t| t.borrow().elapsed());
|
||||
|
||||
// Need the bundle to read display timeouts + default layout.
|
||||
let (idle_to, sleep_to, default_id) = CURRENT_BUNDLE.with(|b| {
|
||||
match b.borrow().as_ref() {
|
||||
Some(bundle) => (
|
||||
bundle.display.idle_timeout_seconds as u64,
|
||||
bundle.display.sleep_timeout_seconds as u64,
|
||||
bundle.display.default_layout_id,
|
||||
),
|
||||
None => (0, 0, None),
|
||||
}
|
||||
});
|
||||
|
||||
// Idle revert: if elapsed >= idle timeout AND current layout is not
|
||||
// default AND current layout doesn't itself reset the idle timer.
|
||||
if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
|
||||
let cur = CURRENT_LAYOUT_ID.with(|c| c.get());
|
||||
let cur_resets_idle = CURRENT_BUNDLE.with(|b| {
|
||||
let bundle = b.borrow();
|
||||
let Some(bundle) = bundle.as_ref() else { return false };
|
||||
let Some(cur_id) = cur else { return false };
|
||||
bundle.layouts.iter().find(|l| l.id == cur_id)
|
||||
.map(|l| l.resets_idle_timer)
|
||||
.unwrap_or(false)
|
||||
});
|
||||
if let (Some(cur_id), Some(def_id)) = (cur, default_id) {
|
||||
if cur_id != def_id && cur_resets_idle {
|
||||
info!("idle timeout reached → reverting to default layout");
|
||||
render_layout(&window, def_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep: fire CEC standby once, mark asleep.
|
||||
if sleep_to > 0
|
||||
&& elapsed >= Duration::from_secs(sleep_to)
|
||||
&& !IS_ASLEEP.with(|c| c.get())
|
||||
{
|
||||
info!("sleep timeout reached → CEC standby");
|
||||
cec::standby();
|
||||
IS_ASLEEP.with(|c| c.set(true));
|
||||
}
|
||||
|
||||
gtk::glib::ControlFlow::Continue
|
||||
});
|
||||
}
|
||||
|
||||
/// Query connected HDMI displays from sysfs. Returns (name, width, height).
|
||||
|
|
@ -184,16 +332,57 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
|
|||
}
|
||||
|
||||
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) {
|
||||
let layout = match bundle.display.default_layout_id {
|
||||
Some(default_layout_id) => bundle.layouts.iter()
|
||||
.find(|l| l.id == default_layout_id)
|
||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default)),
|
||||
None => None,
|
||||
};
|
||||
// Cache the bundle + auth so layout-switch and idle-revert can re-render
|
||||
// without needing a full reload.
|
||||
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone()));
|
||||
CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
|
||||
mark_activity();
|
||||
|
||||
let Some(layout) = layout else {
|
||||
let target_layout_id = bundle.display.default_layout_id
|
||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id));
|
||||
|
||||
let Some(target_layout_id) = target_layout_id else {
|
||||
warn!("display has no default layout");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
||||
show_logo(window);
|
||||
return;
|
||||
};
|
||||
|
||||
render_layout(window, target_layout_id);
|
||||
}
|
||||
|
||||
/// Render a specific layout id from the cached bundle. If not found, fall back
|
||||
/// to the display's default layout. If neither exists, show the logo.
|
||||
fn render_layout(window: &ApplicationWindow, layout_id: u32) {
|
||||
mark_activity();
|
||||
|
||||
// Snapshot what we need out of the cached bundle.
|
||||
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
|
||||
let bundle = b.borrow();
|
||||
let bundle = bundle.as_ref()?.clone();
|
||||
let auth = CURRENT_AUTH.with(|a| a.borrow().clone());
|
||||
let (server_url, kiosk_key) = auth?;
|
||||
Some((bundle, server_url, kiosk_key))
|
||||
});
|
||||
let Some((bundle, server_url, kiosk_key)) = snapshot else {
|
||||
warn!("render_layout: no cached bundle yet");
|
||||
show_logo(window);
|
||||
return;
|
||||
};
|
||||
|
||||
let layout = bundle.layouts.iter().find(|l| l.id == layout_id)
|
||||
.or_else(|| {
|
||||
warn!("render_layout: layout {layout_id} not found, falling back to default");
|
||||
bundle.display.default_layout_id
|
||||
.and_then(|did| bundle.layouts.iter().find(|l| l.id == did))
|
||||
.or_else(|| bundle.layouts.iter().find(|l| l.is_default))
|
||||
});
|
||||
|
||||
let Some(layout) = layout else {
|
||||
warn!("render_layout: no usable layout");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(None));
|
||||
show_logo(window);
|
||||
return;
|
||||
};
|
||||
|
|
@ -201,12 +390,15 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
|
|||
if layout.cells.is_empty() {
|
||||
warn!("layout has no cells");
|
||||
clear_warm_cameras();
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||
show_logo(window);
|
||||
return;
|
||||
}
|
||||
|
||||
info!("rendering layout '{}' with {}x{} grid, {} cells",
|
||||
layout.name, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
||||
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id)));
|
||||
|
||||
info!("rendering layout '{}' (id {}) with {}x{} grid, {} cells",
|
||||
layout.name, layout.id, layout.grid_cols, layout.grid_rows, layout.cells.len());
|
||||
|
||||
// Compute which cameras are needed: cells with content_type=camera + preload_camera_ids
|
||||
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||
|
|
@ -229,6 +421,9 @@ fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &s
|
|||
}
|
||||
});
|
||||
|
||||
let server_url = server_url.as_str();
|
||||
let kiosk_key = kiosk_key.as_str();
|
||||
|
||||
let grid = Grid::new();
|
||||
grid.set_row_homogeneous(true);
|
||||
grid.set_column_homogeneous(true);
|
||||
|
|
|
|||
|
|
@ -46,6 +46,16 @@ pub fn run(server_url: &str, kiosk_key: &str, tx: Sender<ServerMsg>) {
|
|||
} else if text.contains("\"type\":\"wake\"") {
|
||||
info!("ws: wake received");
|
||||
let _ = tx.send(ServerMsg::Wake);
|
||||
} else if text.contains("\"type\":\"layout-switch\"") {
|
||||
info!("ws: layout-switch received: {text}");
|
||||
let layout_id: Option<u32> = text.split("\"layout_id\":").nth(1)
|
||||
.and_then(|s| s.split(|c: char| !c.is_ascii_digit()).next())
|
||||
.and_then(|s| s.parse::<u32>().ok());
|
||||
if let Some(id) = layout_id {
|
||||
let _ = tx.send(ServerMsg::SwitchLayout(id));
|
||||
} else {
|
||||
warn!("ws: layout-switch missing layout_id");
|
||||
}
|
||||
} else if text.contains("\"type\":\"fan\"") {
|
||||
info!("ws: fan received: {text}");
|
||||
let pwm: Option<u32> = if text.contains("\"mode\":\"auto\"") {
|
||||
|
|
|
|||
|
|
@ -1052,12 +1052,15 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
role: kl.role,
|
||||
}));
|
||||
const displays = deps.repo.listDisplaysForKiosk(id);
|
||||
const firstDisplay = displays[0];
|
||||
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
|
||||
return htmlPage(KioskEditPage({
|
||||
user: user.username,
|
||||
kiosk,
|
||||
labels: kioskLabels,
|
||||
allLabels: deps.repo.listLabels(),
|
||||
displays,
|
||||
switchableLayouts,
|
||||
}));
|
||||
});
|
||||
|
||||
|
|
@ -1102,6 +1105,16 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
return new Response(null, { status: 302, headers: { location: "/admin/kiosks" } });
|
||||
});
|
||||
|
||||
// ---- Layout switch ----------------------------------------------------
|
||||
app.post("/admin/kiosks/:id/layout/:layoutId", (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
const layoutId = Number(getRouterParam(event, "layoutId"));
|
||||
if (Number.isFinite(id) && Number.isFinite(layoutId)) {
|
||||
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId });
|
||||
}
|
||||
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
|
||||
});
|
||||
|
||||
// ---- CEC power commands -----------------------------------------------
|
||||
app.post("/admin/kiosks/:id/power/standby", (event) => {
|
||||
const id = Number(getRouterParam(event, "id"));
|
||||
|
|
|
|||
|
|
@ -1164,6 +1164,7 @@ interface KioskEditProps {
|
|||
labels: Array<{ label_id: number; name: string; role: string }>;
|
||||
allLabels: Label[];
|
||||
displays?: Display[];
|
||||
switchableLayouts?: LayoutType[];
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
|
@ -1213,6 +1214,25 @@ export function KioskEditPage(props: KioskEditProps) {
|
|||
</form>
|
||||
</div>
|
||||
|
||||
{props.switchableLayouts && props.switchableLayouts.length > 0 ? (
|
||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout</div>
|
||||
<form
|
||||
method="post"
|
||||
action={`/admin/kiosks/${k.id}/layout/0`}
|
||||
style="display:flex; gap:0.5rem; align-items:center"
|
||||
{...{ "onsubmit": "this.action = this.action.replace(/\\/layout\\/.*/, '/layout/' + this.layout_id.value); return true;" }}
|
||||
>
|
||||
<select name="layout_id" class="form-input" style="flex:1">
|
||||
{props.switchableLayouts.map((l) => (
|
||||
<option value={String(l.id)}>{l.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm">Switch</button>
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style="margin-top:1rem; padding-top:1rem; border-top:1px solid #eee">
|
||||
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Hardware</div>
|
||||
<div style="display:flex; gap:1.5rem; flex-wrap:wrap; font-size:0.85rem; color:#666; margin-bottom:0.75rem">
|
||||
|
|
|
|||
Loading…
Reference in a new issue