feat: multi-display + snapshot + health + GPIO + nodered embed

Multi-display:
- Bundle ships displays[] each with own layouts + idle/sleep
- Rust kiosk creates one ApplicationWindow per gdk monitor
- Per-display state (layout, idle, sleep) via HashMap
- WARM_CAMERAS pool shared across displays
- Backward-compat top-level display/layouts still emitted

System Health (/admin/health):
- Online status, CPU temp (color-coded), fan RPM/PWM
- Bundle version mismatch detection
- 30s auto-refresh

Camera snapshot/test:
- shared/snapshot.ts: ffmpeg/gst-launch fallback, 5s timeout
- /admin/entities/:id/snapshot returns JPEG
- EntityEditPage shows live preview with Refresh

GPIO (Pi buttons/sensors):
- kiosk_gpio_bindings table + CRUD admin UI
- Bundle ships gpio_bindings[]
- kiosk/src/gpio.rs with gpiod crate, worker thread per pin
- Edge events POST to /api/kiosk/event with source_type=gpio

Layout switch fixes:
- GET aliases added so direct URL hits work
- New /admin/displays/:displayId/layout/:layoutId for multi-display
- DisplayEditPage gets "Switch Layout Now" section

Node-RED embed:
- /admin/nodered renders iframe at /nrdp/
- Sandbox attrs allow scripts/forms/popups
- Sidebar link now opens embedded view
This commit is contained in:
Mitchell R 2026-05-13 01:18:22 +02:00
parent 1c0fe02fcf
commit 975cc184b3
14 changed files with 1284 additions and 221 deletions

View file

@ -33,3 +33,4 @@ tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
futures-util = "0.3" futures-util = "0.3"
url = "2" url = "2"
webkit6 = "0.4" webkit6 = "0.4"
gpiod = "0.3"

View file

@ -4,12 +4,47 @@ use serde::{Deserialize, Serialize};
pub struct KioskBundle { pub struct KioskBundle {
pub kiosk_id: u32, pub kiosk_id: u32,
pub kiosk_name: String, pub kiosk_name: String,
pub display: BundleDisplay, /// Legacy single-display field (mirrors `displays[0]`). New code should
/// iterate `displays` instead.
#[serde(default)]
pub display: Option<BundleDisplay>,
/// Legacy single-display layouts (mirrors `displays[0].layouts`). Kept for
/// backward compatibility with older bundles that pre-date multi-display.
#[serde(default)]
pub layouts: Vec<BundleLayout>, pub layouts: Vec<BundleLayout>,
/// All physical displays driven by this kiosk.
#[serde(default)]
pub displays: Vec<BundleDisplayWithLayouts>,
pub cameras: Vec<BundleCamera>, pub cameras: Vec<BundleCamera>,
#[serde(default)]
pub gpio_bindings: Vec<BundleGpioBinding>,
pub version: String, pub version: String,
} }
impl KioskBundle {
/// Normalize the bundle: if `displays` is empty (old server), synthesize it
/// from the legacy single `display` + `layouts` fields so the rest of the
/// kiosk only deals with one shape.
pub fn normalized_displays(&self) -> Vec<BundleDisplayWithLayouts> {
if !self.displays.is_empty() {
return self.displays.clone();
}
if let Some(d) = &self.display {
return vec![BundleDisplayWithLayouts {
id: d.id,
name: d.name.clone(),
width_px: d.width_px,
height_px: d.height_px,
idle_timeout_seconds: d.idle_timeout_seconds,
sleep_timeout_seconds: d.sleep_timeout_seconds,
default_layout_id: d.default_layout_id,
layouts: self.layouts.clone(),
}];
}
Vec::new()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleDisplay { pub struct BundleDisplay {
pub id: u32, pub id: u32,
@ -21,6 +56,19 @@ pub struct BundleDisplay {
pub default_layout_id: Option<u32>, pub default_layout_id: Option<u32>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleDisplayWithLayouts {
pub id: u32,
pub name: String,
pub width_px: u32,
pub height_px: u32,
pub idle_timeout_seconds: u32,
pub sleep_timeout_seconds: u32,
pub default_layout_id: Option<u32>,
#[serde(default)]
pub layouts: Vec<BundleLayout>,
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleLayout { pub struct BundleLayout {
pub id: u32, pub id: u32,
@ -76,6 +124,17 @@ pub struct BundleStream {
pub framerate: Option<u32>, pub framerate: Option<u32>,
} }
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct BundleGpioBinding {
pub id: u32,
pub chip: String,
pub pin: u32,
pub direction: String,
pub pull: Option<String>,
pub edge: Option<String>,
pub topic: String,
}
impl BundleCamera { impl BundleCamera {
/// Pick stream URI + role tag for this camera given selector and cell area fraction. /// Pick stream URI + role tag for this camera given selector and cell area fraction.
/// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub. /// Heuristic: when selector=auto, cell ≥20% of grid → main, else sub.

152
kiosk/src/gpio.rs Normal file
View file

@ -0,0 +1,152 @@
//! GPIO worker threads — one per binding.
//!
//! Each binding spawns a worker that opens the configured gpio chip, requests
//! the configured line, waits for edge events, and posts to
//! `/api/kiosk/event` on each trigger. Output bindings are opened but idle —
//! reserved for future server-driven set operations.
//!
//! The whole worker pool is rebuilt on every bundle reload: `start_workers`
//! replaces any previously running set. Workers shut down when their
//! `running` flag flips to `false`.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tracing::{info, warn};
use crate::bundle::BundleGpioBinding;
struct WorkerHandle {
running: Arc<AtomicBool>,
}
static WORKERS: Mutex<Vec<WorkerHandle>> = Mutex::new(Vec::new());
/// Tear down the previous worker set (if any) and start a fresh set for the
/// given bindings. Safe to call repeatedly on bundle reload.
pub fn start_workers(bindings: &[BundleGpioBinding], server_url: &str, kiosk_key: &str) {
stop_workers();
if bindings.is_empty() {
return;
}
let mut handles = Vec::new();
for b in bindings {
let running = Arc::new(AtomicBool::new(true));
let r2 = running.clone();
let binding = b.clone();
let server = server_url.to_string();
let key = kiosk_key.to_string();
std::thread::spawn(move || run_binding(binding, server, key, r2));
handles.push(WorkerHandle { running });
}
if let Ok(mut w) = WORKERS.lock() {
*w = handles;
}
info!("gpio: {} worker(s) started", bindings.len());
}
fn stop_workers() {
if let Ok(mut w) = WORKERS.lock() {
for h in w.drain(..) {
h.running.store(false, Ordering::Relaxed);
}
}
}
fn run_binding(b: BundleGpioBinding, server: String, key: String, running: Arc<AtomicBool>) {
if b.direction != "in" {
// Output bindings: open the line so the chip knows it's claimed but
// there's nothing to wait on. Bail early — output set ops will be
// added later via WS commands.
return;
}
let chip = match gpiod::Chip::new(&b.chip) {
Ok(c) => c,
Err(e) => {
warn!("gpio: open chip {} failed: {e}", b.chip);
return;
}
};
let mut opts = gpiod::Options::input([b.pin as u32])
.consumer("betterframe-kiosk");
if let Some(edge) = b.edge.as_deref() {
let edge_detect = match edge {
"rising" => gpiod::EdgeDetect::Rising,
"falling" => gpiod::EdgeDetect::Falling,
_ => gpiod::EdgeDetect::Both,
};
opts = opts.edge(edge_detect);
} else {
opts = opts.edge(gpiod::EdgeDetect::Both);
}
if let Some(pull) = b.pull.as_deref() {
let bias = match pull {
"up" => gpiod::Bias::PullUp,
"down" => gpiod::Bias::PullDown,
_ => gpiod::Bias::Disable,
};
opts = opts.bias(bias);
}
let lines = match chip.request_lines(opts) {
Ok(l) => l,
Err(e) => {
warn!("gpio: request {}:{} failed: {e}", b.chip, b.pin);
return;
}
};
info!(
"gpio: watching {} pin {} ({}/{}/topic={})",
b.chip,
b.pin,
b.direction,
b.edge.as_deref().unwrap_or("both"),
b.topic
);
let http = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(5))
.build()
.expect("reqwest client build");
while running.load(Ordering::Relaxed) {
// Poll with a timeout so the running flag is checked periodically.
match lines.read_event() {
Ok(event) => {
let edge_str = match event.edge {
gpiod::Edge::Rising => "rising",
gpiod::Edge::Falling => "falling",
};
let payload = serde_json::json!({
"chip": b.chip,
"pin": b.pin,
"edge": edge_str,
});
let _ = http
.post(format!("{server}/api/kiosk/event"))
.header("Authorization", format!("Bearer {key}"))
.json(&serde_json::json!({
"topic": b.topic,
"source_type": "gpio",
"payload": payload,
}))
.send();
}
Err(e) => {
warn!("gpio: read_event {}:{} failed: {e}", b.chip, b.pin);
std::thread::sleep(Duration::from_secs(1));
}
}
}
info!("gpio: worker {}:{} stopped", b.chip, b.pin);
}

View file

@ -1,6 +1,7 @@
mod server; mod server;
mod bundle; mod bundle;
mod cec; mod cec;
mod gpio;
mod hwmon; mod hwmon;
mod pipeline; mod pipeline;
mod ui; mod ui;

View file

@ -1,4 +1,5 @@
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use url::Url; use url::Url;
@ -7,20 +8,32 @@ use gtk4::prelude::*;
use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture}; use gtk4::{self as gtk, Application, ApplicationWindow, Box as GtkBox, Grid, Label, Orientation, Picture};
use tracing::{info, warn}; use tracing::{info, warn};
use crate::bundle::KioskBundle; use crate::bundle::{BundleDisplayWithLayouts, KioskBundle};
use crate::cec; use crate::cec;
use crate::gpio;
use crate::hwmon; use crate::hwmon;
use crate::pipeline; use crate::pipeline;
use crate::server; use crate::server;
use crate::ws_client; use crate::ws_client;
use crate::ServerMsg; use crate::ServerMsg;
/// Per-display runtime state. Kept inside a thread-local hashmap keyed by
/// display id, so all the idle/sleep/layout tracking is local to that display
/// even though the GTK main loop is shared.
struct DisplayState {
window: ApplicationWindow,
current_layout_id: Option<u32>,
last_activity: Instant,
is_asleep: bool,
}
thread_local! { thread_local! {
/// camera_id → (pipeline, paintable, badge). Pipelines stay warm across /// camera_id → (pipeline, paintable, badge). Pipelines stay warm across
/// layout swaps for cameras still referenced or in preload_camera_ids. /// layout swaps for cameras still referenced or in preload_camera_ids.
/// badge is 'M' / 'S' / ' ' indicating which stream is active. /// Shared across ALL displays — if two displays use the same camera the
static WARM_CAMERAS: RefCell<std::collections::HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>> /// pipeline is reused. The paintable can be attached to multiple Pictures.
= RefCell::new(std::collections::HashMap::new()); static WARM_CAMERAS: RefCell<HashMap<u32, (gstreamer::Pipeline, gtk::gdk::Paintable, char)>>
= RefCell::new(HashMap::new());
/// Most recently rendered bundle. Used for layout-switch + idle revert. /// Most recently rendered bundle. Used for layout-switch + idle revert.
static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) }; static CURRENT_BUNDLE: RefCell<Option<KioskBundle>> = const { RefCell::new(None) };
@ -28,14 +41,8 @@ thread_local! {
/// Server URL + kiosk key for re-rendering on layout-switch. /// Server URL + kiosk key for re-rendering on layout-switch.
static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) }; static CURRENT_AUTH: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
/// Layout id currently on screen, if any. /// Per-display state, keyed by bundle display id.
static CURRENT_LAYOUT_ID: Cell<Option<u32>> = const { Cell::new(None) }; static DISPLAYS: RefCell<HashMap<u32, DisplayState>> = RefCell::new(HashMap::new());
/// 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? /// Has the idle-watchdog already been installed on the main loop?
static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) }; static WATCHDOG_INSTALLED: Cell<bool> = const { Cell::new(false) };
@ -52,7 +59,9 @@ pub fn build_app() -> Application {
} }
fn activate(app: &Application) { fn activate(app: &Application) {
let window = ApplicationWindow::builder() // Create the initial pairing window. Multi-display windows are spawned
// later once we receive a bundle.
let pairing_window = ApplicationWindow::builder()
.application(app) .application(app)
.title("BetterFrame") .title("BetterFrame")
.fullscreened(true) .fullscreened(true)
@ -61,13 +70,13 @@ fn activate(app: &Application) {
let provider = gtk::CssProvider::new(); let provider = gtk::CssProvider::new();
provider.load_from_string("window { background-color: #000000; }"); provider.load_from_string("window { background-color: #000000; }");
gtk::style_context_add_provider_for_display( gtk::style_context_add_provider_for_display(
&WidgetExt::display(&window), &WidgetExt::display(&pairing_window),
&provider, &provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );
show_logo(&window); show_logo(&pairing_window);
window.present(); pairing_window.present();
let (tx, rx) = mpsc::channel::<WorkerMsg>(); let (tx, rx) = mpsc::channel::<WorkerMsg>();
@ -94,7 +103,7 @@ fn activate(app: &Application) {
// cached on-disk bundle and keep retrying every 30s in the background. // cached on-disk bundle and keep retrying every 30s in the background.
let initial = match server::fetch_bundle(&server, &key) { let initial = match server::fetch_bundle(&server, &key) {
Some(b) => { Some(b) => {
info!("bundle: {} cameras, {} layouts", b.cameras.len(), b.layouts.len()); info!("bundle: {} cameras, {} display(s)", b.cameras.len(), b.normalized_displays().len());
Some(b) Some(b)
} }
None => { None => {
@ -130,10 +139,6 @@ fn activate(app: &Application) {
let retry_server = server.clone(); let retry_server = server.clone();
let retry_key = key.clone(); let retry_key = key.clone();
std::thread::spawn(move || { 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 { loop {
std::thread::sleep(Duration::from_secs(30)); std::thread::sleep(Duration::from_secs(30));
if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) { if let Some(b) = server::fetch_bundle(&retry_server, &retry_key) {
@ -187,22 +192,27 @@ fn activate(app: &Application) {
}); });
// Poll channel from UI thread via timeout // Poll channel from UI thread via timeout
let window_clone = window.clone(); let app_clone = app.clone();
let pairing_window_clone = pairing_window.clone();
gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || { gtk::glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
while let Ok(msg) = rx.try_recv() { while let Ok(msg) = rx.try_recv() {
match msg { match msg {
WorkerMsg::ShowPairingCode(code) => show_pairing_code(&window_clone, &code), WorkerMsg::ShowPairingCode(code) => show_pairing_code(&pairing_window_clone, &code),
WorkerMsg::RenderBundle(bundle, server, key) => { WorkerMsg::RenderBundle(bundle, server, key) => {
render_bundle(&window_clone, bundle, &server, &key); render_bundle(&app_clone, &pairing_window_clone, bundle, &server, &key);
install_idle_watchdog(&window_clone); install_idle_watchdog();
} }
WorkerMsg::SwitchLayout(id) => { WorkerMsg::SwitchLayout(id) => {
render_layout(&window_clone, id); switch_layout_anywhere(id);
} }
WorkerMsg::Wake => { WorkerMsg::Wake => {
cec::wake(); cec::wake();
IS_ASLEEP.with(|c| c.set(false)); DISPLAYS.with(|ds| {
mark_activity(); for st in ds.borrow_mut().values_mut() {
st.is_asleep = false;
st.last_activity = Instant::now();
}
});
} }
} }
} }
@ -217,65 +227,77 @@ enum WorkerMsg {
Wake, Wake,
} }
/// Reset activity timer. If we were asleep, wake the display first. /// Reset activity timer for one display. If asleep, wake it.
fn mark_activity() { fn mark_activity(display_id: u32) {
LAST_ACTIVITY.with(|t| *t.borrow_mut() = Instant::now()); DISPLAYS.with(|ds| {
if IS_ASLEEP.with(|c| c.get()) { if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
info!("activity while asleep → waking display"); st.last_activity = Instant::now();
if st.is_asleep {
info!("activity while asleep → waking display {display_id}");
cec::wake(); cec::wake();
IS_ASLEEP.with(|c| c.set(false)); st.is_asleep = false;
} }
} }
});
}
/// Install the once-per-second watchdog that enforces idle/sleep timeouts. /// Install the once-per-second watchdog that enforces idle/sleep timeouts
/// Safe to call multiple times — installs at most once. /// per display. Safe to call multiple times — installs at most once.
fn install_idle_watchdog(window: &ApplicationWindow) { fn install_idle_watchdog() {
if WATCHDOG_INSTALLED.with(|c| c.get()) { return; } if WATCHDOG_INSTALLED.with(|c| c.get()) { return; }
WATCHDOG_INSTALLED.with(|c| c.set(true)); WATCHDOG_INSTALLED.with(|c| c.set(true));
let window = window.clone();
gtk::glib::timeout_add_local(Duration::from_secs(1), move || { gtk::glib::timeout_add_local(Duration::from_secs(1), move || {
let elapsed = LAST_ACTIVITY.with(|t| t.borrow().elapsed()); let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
let Some(bundle) = bundle else { return gtk::glib::ControlFlow::Continue };
// Need the bundle to read display timeouts + default layout. // Snapshot per-display timing decisions so we can act outside the borrow.
let (idle_to, sleep_to, default_id) = CURRENT_BUNDLE.with(|b| { struct Action { display_id: u32, revert_to: Option<u32>, sleep: bool }
match b.borrow().as_ref() { let mut actions: Vec<Action> = Vec::new();
Some(bundle) => (
bundle.display.idle_timeout_seconds as u64, DISPLAYS.with(|ds| {
bundle.display.sleep_timeout_seconds as u64, for (display_id, st) in ds.borrow().iter() {
bundle.display.default_layout_id, let Some(d) = bundle.normalized_displays().into_iter().find(|d| d.id == *display_id) else { continue };
), let idle_to = d.idle_timeout_seconds as u64;
None => (0, 0, None), let sleep_to = d.sleep_timeout_seconds as u64;
} let elapsed = st.last_activity.elapsed();
}); let default_id = d.default_layout_id;
let mut act = Action { display_id: *display_id, revert_to: None, sleep: false };
// 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) { if idle_to > 0 && elapsed >= Duration::from_secs(idle_to) {
let cur = CURRENT_LAYOUT_ID.with(|c| c.get()); let cur_resets_idle = st.current_layout_id
let cur_resets_idle = CURRENT_BUNDLE.with(|b| { .and_then(|cur_id| d.layouts.iter().find(|l| l.id == cur_id))
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) .map(|l| l.resets_idle_timer)
.unwrap_or(false) .unwrap_or(false);
}); if let (Some(cur_id), Some(def_id)) = (st.current_layout_id, default_id) {
if let (Some(cur_id), Some(def_id)) = (cur, default_id) {
if cur_id != def_id && cur_resets_idle { if cur_id != def_id && cur_resets_idle {
info!("idle timeout reached → reverting to default layout"); act.revert_to = Some(def_id);
render_layout(&window, def_id);
} }
} }
} }
if sleep_to > 0 && elapsed >= Duration::from_secs(sleep_to) && !st.is_asleep {
act.sleep = true;
}
if act.revert_to.is_some() || act.sleep {
actions.push(act);
}
}
});
// Sleep: fire CEC standby once, mark asleep. for a in actions {
if sleep_to > 0 if let Some(layout_id) = a.revert_to {
&& elapsed >= Duration::from_secs(sleep_to) info!("idle timeout reached → reverting display {} to default", a.display_id);
&& !IS_ASLEEP.with(|c| c.get()) render_layout(a.display_id, layout_id);
{ }
info!("sleep timeout reached → CEC standby"); if a.sleep {
info!("sleep timeout reached on display {} → CEC standby", a.display_id);
cec::standby(); cec::standby();
IS_ASLEEP.with(|c| c.set(true)); DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&a.display_id) {
st.is_asleep = true;
}
});
}
} }
gtk::glib::ControlFlow::Continue gtk::glib::ControlFlow::Continue
@ -289,20 +311,17 @@ fn query_displays() -> Vec<(String, u32, u32)> {
let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out }; let Ok(entries) = std::fs::read_dir("/sys/class/drm") else { return out };
for entry in entries.flatten() { for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
// Skip non-HDMI connectors and the "card" parents
if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; } if !name.contains("-HDMI-") && !name.contains("-DP-") { continue; }
let path = entry.path(); let path = entry.path();
let status = std::fs::read_to_string(path.join("status")).unwrap_or_default(); let status = std::fs::read_to_string(path.join("status")).unwrap_or_default();
if status.trim() != "connected" { continue; } if status.trim() != "connected" { continue; }
let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default(); let modes = std::fs::read_to_string(path.join("modes")).unwrap_or_default();
// First line = preferred mode
let mode = modes.lines().next().unwrap_or(""); let mode = modes.lines().next().unwrap_or("");
let parts: Vec<&str> = mode.split('x').collect(); let parts: Vec<&str> = mode.split('x').collect();
if parts.len() != 2 { continue; } if parts.len() != 2 { continue; }
let w: u32 = parts[0].parse().unwrap_or(0); let w: u32 = parts[0].parse().unwrap_or(0);
let h: u32 = parts[1].trim().parse().unwrap_or(0); let h: u32 = parts[1].trim().parse().unwrap_or(0);
if w == 0 || h == 0 { continue; } if w == 0 || h == 0 { continue; }
// Strip "cardN-" prefix for cleaner name
let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name); let clean_name = name.split_once('-').map(|(_, rest)| rest.to_string()).unwrap_or(name);
out.push((clean_name, w, h)); out.push((clean_name, w, h));
} }
@ -331,33 +350,160 @@ fn show_pairing_code(window: &ApplicationWindow, code: &str) {
window.set_child(Some(&vbox)); window.set_child(Some(&vbox));
} }
fn render_bundle(window: &ApplicationWindow, bundle: KioskBundle, server_url: &str, kiosk_key: &str) { /// Render a fresh bundle: rebuild the per-display window set, restart GPIO
// Cache the bundle + auth so layout-switch and idle-revert can re-render /// workers, recompute warm-camera needs across all displays.
// without needing a full reload. fn render_bundle(
app: &Application,
pairing_window: &ApplicationWindow,
bundle: KioskBundle,
server_url: &str,
kiosk_key: &str,
) {
CURRENT_BUNDLE.with(|b| *b.borrow_mut() = Some(bundle.clone())); 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()))); CURRENT_AUTH.with(|a| *a.borrow_mut() = Some((server_url.to_string(), kiosk_key.to_string())));
mark_activity();
let target_layout_id = bundle.display.default_layout_id // Restart GPIO workers (always — even if list is empty, this drops the old set).
.or_else(|| bundle.layouts.iter().find(|l| l.is_default).map(|l| l.id)); gpio::start_workers(&bundle.gpio_bindings, server_url, kiosk_key);
let Some(target_layout_id) = target_layout_id else { let displays = bundle.normalized_displays();
warn!("display has no default layout"); if displays.is_empty() {
clear_warm_cameras(); warn!("bundle has no displays");
CURRENT_LAYOUT_ID.with(|c| c.set(None)); show_logo(pairing_window);
show_logo(window);
return; return;
};
render_layout(window, target_layout_id);
} }
/// Render a specific layout id from the cached bundle. If not found, fall back // Match GDK monitors to bundle displays by index. Bundle display 0 → GDK
/// to the display's default layout. If neither exists, show the logo. // monitor 0, etc. v1 simple ordering — re-binding will land if/when the
fn render_layout(window: &ApplicationWindow, layout_id: u32) { // admin UI exposes a mapping. Falls back to overlapping windows on a
mark_activity(); // single physical screen if the kiosk has fewer monitors than bundle
// displays (rare on Pi5).
let gdk_monitors: Vec<gtk::gdk::Monitor> = WidgetExt::display(pairing_window)
.monitors()
.iter::<gtk::gdk::Monitor>()
.flatten()
.collect();
// Tear down any previous per-display windows we no longer need.
let keep_ids: std::collections::HashSet<u32> = displays.iter().map(|d| d.id).collect();
let to_remove: Vec<u32> = DISPLAYS.with(|ds| {
ds.borrow().keys().filter(|id| !keep_ids.contains(id)).copied().collect()
});
for id in to_remove {
if let Some(st) = DISPLAYS.with(|ds| ds.borrow_mut().remove(&id)) {
st.window.close();
}
}
// Compute global warm-camera set: union across all displays' current/needed cameras.
let mut globally_needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (i, bd) in displays.iter().enumerate() {
let target_id = pick_initial_layout(bd);
if let Some(target_id) = target_id {
if let Some(layout) = bd.layouts.iter().find(|l| l.id == target_id) {
for cell in &layout.cells {
if cell.content_type == "camera" {
if let Some(id) = cell.camera_id { globally_needed.insert(id); }
}
}
for id in &layout.preload_camera_ids { globally_needed.insert(*id); }
}
}
// Pre-touch the monitor binding to silence warnings about unused var.
let _ = gdk_monitors.get(i);
}
// Stop pipelines for cameras no longer needed by any display.
WARM_CAMERAS.with(|w| {
let mut warm = w.borrow_mut();
let stale: Vec<u32> = warm.keys().filter(|id| !globally_needed.contains(id)).copied().collect();
for id in stale {
if let Some((pipe, _, _)) = warm.remove(&id) {
info!("stopping pipeline for camera {id} (no longer needed by any display)");
pipeline::stop(&pipe);
}
}
});
// Build/reuse window per bundle display, then render its initial layout.
let mut new_state: HashMap<u32, DisplayState> = HashMap::new();
for (i, bd) in displays.iter().enumerate() {
let existing = DISPLAYS.with(|ds| ds.borrow_mut().remove(&bd.id));
let window = match existing {
Some(st) => st.window,
None => {
let w = ApplicationWindow::builder()
.application(app)
.title(format!("BetterFrame — {}", bd.name))
.fullscreened(true)
.build();
let provider = gtk::CssProvider::new();
provider.load_from_string("window { background-color: #000000; }");
gtk::style_context_add_provider_for_display(
&WidgetExt::display(&w),
&provider,
gtk::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
w.present();
if let Some(monitor) = gdk_monitors.get(i) {
w.fullscreen_on_monitor(monitor);
}
w
}
};
new_state.insert(bd.id, DisplayState {
window,
current_layout_id: None,
last_activity: Instant::now(),
is_asleep: false,
});
}
DISPLAYS.with(|ds| *ds.borrow_mut() = new_state);
// Hide the pairing window now that real displays are up (if we created any).
if !displays.is_empty() {
pairing_window.set_visible(false);
}
// Now render each display's initial layout.
for bd in &displays {
let target = pick_initial_layout(bd);
if let Some(layout_id) = target {
render_layout(bd.id, layout_id);
} else {
warn!("display {} has no default layout", bd.id);
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&bd.id) {
show_logo(&st.window);
st.current_layout_id = None;
}
});
}
}
}
fn pick_initial_layout(bd: &BundleDisplayWithLayouts) -> Option<u32> {
bd.default_layout_id
.or_else(|| bd.layouts.iter().find(|l| l.is_default).map(|l| l.id))
.or_else(|| bd.layouts.first().map(|l| l.id))
}
/// Find which display owns a given layout_id and render it there.
fn switch_layout_anywhere(layout_id: u32) {
let bundle = CURRENT_BUNDLE.with(|b| b.borrow().clone());
let Some(bundle) = bundle else { return };
for bd in bundle.normalized_displays() {
if bd.layouts.iter().any(|l| l.id == layout_id) {
render_layout(bd.id, layout_id);
return;
}
}
warn!("switch_layout: layout {layout_id} not found on any display");
}
/// Render a specific layout id on a specific display.
fn render_layout(display_id: u32, layout_id: u32) {
mark_activity(display_id);
// Snapshot what we need out of the cached bundle.
let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| { let snapshot: Option<(KioskBundle, String, String)> = CURRENT_BUNDLE.with(|b| {
let bundle = b.borrow(); let bundle = b.borrow();
let bundle = bundle.as_ref()?.clone(); let bundle = bundle.as_ref()?.clone();
@ -367,59 +513,59 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
}); });
let Some((bundle, server_url, kiosk_key)) = snapshot else { let Some((bundle, server_url, kiosk_key)) = snapshot else {
warn!("render_layout: no cached bundle yet"); warn!("render_layout: no cached bundle yet");
show_logo(window);
return; return;
}; };
let layout = bundle.layouts.iter().find(|l| l.id == layout_id) let displays = bundle.normalized_displays();
let Some(bd) = displays.iter().find(|d| d.id == display_id) else {
warn!("render_layout: display {display_id} not in bundle");
return;
};
let layout = bd.layouts.iter().find(|l| l.id == layout_id)
.or_else(|| { .or_else(|| {
warn!("render_layout: layout {layout_id} not found, falling back to default"); warn!("render_layout: layout {layout_id} not on display {display_id}, falling back to default");
bundle.display.default_layout_id bd.default_layout_id
.and_then(|did| bundle.layouts.iter().find(|l| l.id == did)) .and_then(|did| bd.layouts.iter().find(|l| l.id == did))
.or_else(|| bundle.layouts.iter().find(|l| l.is_default)) .or_else(|| bd.layouts.iter().find(|l| l.is_default))
}); });
let Some(layout) = layout else { let Some(layout) = layout else {
warn!("render_layout: no usable layout"); warn!("render_layout: no usable layout on display {display_id}");
clear_warm_cameras(); DISPLAYS.with(|ds| {
CURRENT_LAYOUT_ID.with(|c| c.set(None)); if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
show_logo(window); show_logo(&st.window);
st.current_layout_id = None;
}
});
return; return;
}; };
// Update per-display layout id BEFORE recomputing warm-cameras so the
// union across displays is correct.
DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
st.current_layout_id = Some(layout.id);
}
});
info!("rendering layout '{}' (id {}) on display {} ({}x{} grid, {} cells)",
layout.name, layout.id, display_id, layout.grid_cols, layout.grid_rows, layout.cells.len());
if layout.cells.is_empty() { if layout.cells.is_empty() {
warn!("layout has no cells"); warn!("layout has no cells");
clear_warm_cameras(); recompute_warm_cameras(&bundle);
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id))); DISPLAYS.with(|ds| {
show_logo(window); if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
show_logo(&st.window);
}
});
return; return;
} }
CURRENT_LAYOUT_ID.with(|c| c.set(Some(layout.id))); // Recompute warm-camera set across ALL displays (the union), then drop
// pipelines no longer needed anywhere.
info!("rendering layout '{}' (id {}) with {}x{} grid, {} cells", recompute_warm_cameras(&bundle);
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();
for cell in &layout.cells {
if cell.content_type == "camera" {
if let Some(id) = cell.camera_id { needed.insert(id); }
}
}
for id in &layout.preload_camera_ids { needed.insert(*id); }
// Stop pipelines for cameras no longer needed
WARM_CAMERAS.with(|w| {
let mut warm = w.borrow_mut();
let stale: Vec<u32> = warm.keys().filter(|id| !needed.contains(id)).copied().collect();
for id in stale {
if let Some((pipe, _, _)) = warm.remove(&id) {
info!("stopping pipeline for camera {id} (no longer needed)");
pipeline::stop(&pipe);
}
}
});
let server_url = server_url.as_str(); let server_url = server_url.as_str();
let kiosk_key = kiosk_key.as_str(); let kiosk_key = kiosk_key.as_str();
@ -430,13 +576,12 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
grid.set_vexpand(true); grid.set_vexpand(true);
grid.set_hexpand(true); grid.set_hexpand(true);
let cam_map: std::collections::HashMap<u32, &crate::bundle::BundleCamera> = let cam_map: HashMap<u32, &crate::bundle::BundleCamera> =
bundle.cameras.iter().map(|c| (c.id, c)).collect(); bundle.cameras.iter().map(|c| (c.id, c)).collect();
// Total grid area for the heuristic
let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32; let total_area = (layout.grid_cols.max(1) * layout.grid_rows.max(1)) as f32;
// Ensure preloaded cameras have pipelines even if not visible (use sub for warmth) // Ensure preloaded cameras have pipelines even if not visible.
for cam_id in &layout.preload_camera_ids { for cam_id in &layout.preload_camera_ids {
if let Some(cam) = cam_map.get(cam_id) { if let Some(cam) = cam_map.get(cam_id) {
ensure_warm(*cam_id, cam, None, 0.0); ensure_warm(*cam_id, cam, None, 0.0);
@ -458,7 +603,6 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
}); });
picture.set_vexpand(true); picture.set_vexpand(true);
picture.set_hexpand(true); picture.set_hexpand(true);
// Wrap in Overlay so we can stack a stream-role badge on top
let overlay = gtk::Overlay::new(); let overlay = gtk::Overlay::new();
overlay.set_child(Some(&picture)); overlay.set_child(Some(&picture));
overlay.set_vexpand(true); overlay.set_vexpand(true);
@ -520,13 +664,40 @@ fn render_layout(window: &ApplicationWindow, layout_id: u32) {
); );
} }
window.set_child(Some(&grid)); DISPLAYS.with(|ds| {
if let Some(st) = ds.borrow_mut().get_mut(&display_id) {
st.window.set_child(Some(&grid));
}
});
} }
fn clear_warm_cameras() { /// Compute the union of cameras needed across all displays' current layouts +
/// preload sets, then drop any warm pipelines outside that set.
fn recompute_warm_cameras(bundle: &KioskBundle) {
let mut needed: std::collections::HashSet<u32> = std::collections::HashSet::new();
let displays = bundle.normalized_displays();
DISPLAYS.with(|ds| {
for (display_id, st) in ds.borrow().iter() {
let Some(bd) = displays.iter().find(|d| d.id == *display_id) else { continue };
let Some(cur_id) = st.current_layout_id else { continue };
let Some(layout) = bd.layouts.iter().find(|l| l.id == cur_id) else { continue };
for cell in &layout.cells {
if cell.content_type == "camera" {
if let Some(id) = cell.camera_id { needed.insert(id); }
}
}
for id in &layout.preload_camera_ids { needed.insert(*id); }
}
});
WARM_CAMERAS.with(|w| { WARM_CAMERAS.with(|w| {
for (_, (pipe, _, _)) in w.borrow().iter() { pipeline::stop(pipe); } let mut warm = w.borrow_mut();
w.borrow_mut().clear(); let stale: Vec<u32> = warm.keys().filter(|id| !needed.contains(id)).copied().collect();
for id in stale {
if let Some((pipe, _, _)) = warm.remove(&id) {
info!("stopping pipeline for camera {id} (no longer needed)");
pipeline::stop(&pipe);
}
}
}); });
} }
@ -568,7 +739,6 @@ fn ensure_warm(
) -> Option<(gtk::gdk::Paintable, char)> { ) -> Option<(gtk::gdk::Paintable, char)> {
let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?; let (uri, desired_badge) = cam.pick_stream(selector, area_fraction)?;
// Check cached: if badge matches desired, reuse. Else swap.
let cached = WARM_CAMERAS.with(|w| { let cached = WARM_CAMERAS.with(|w| {
w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b)) w.borrow().get(&cam_id).map(|(p, paint, b)| (p.clone(), paint.clone(), *b))
}); });

View file

@ -24,10 +24,14 @@ import {
LayoutEditPage, LayoutEditPage,
DisplaysPage, DisplaysPage,
DisplayEditPage, DisplayEditPage,
SystemHealthPage,
NoderedEmbedPage,
renderCell, renderCell,
renderGrid, renderGrid,
} from "../../web-templates/admin-pages.js"; } from "../../web-templates/admin-pages.js";
import { discover as onvifDiscover } from "../../shared/onvif.js"; import { discover as onvifDiscover } from "../../shared/onvif.js";
import { generateBundle } from "../../shared/bundle.js";
import { captureSnapshot } from "../../shared/snapshot.js";
interface DiscoverAddStream { interface DiscoverAddStream {
profile_name: string; profile_name: string;
@ -252,6 +256,44 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 301, headers: { location: "/admin/" } }); return new Response(null, { status: 301, headers: { location: "/admin/" } });
}); });
// ---- System Health --------------------------------------------------------
app.get("/admin/health", (event) => {
const user = event.context.user!;
const kiosks = deps.repo.listKiosks();
const now = Date.now();
let clusterKey: string | undefined;
try {
const enc = deps.repo.getSetupExtra("cluster_key_encrypted") as string | undefined;
if (enc) clusterKey = deps.secrets.decryptString(enc, "cluster");
} catch { /* ignore */ }
const rows = kiosks.map((k) => {
const online = k.last_seen_at
? now - new Date(k.last_seen_at).getTime() < 5 * 60 * 1000
: false;
const displays = deps.repo.listDisplaysForKiosk(k.id);
let expectedBundleVersion: string | null = null;
try {
const b = generateBundle(deps.repo, deps.secrets, k.id, clusterKey);
expectedBundleVersion = b?.version ?? null;
} catch { /* ignore */ }
const bundleMismatch =
expectedBundleVersion != null
&& k.last_bundle_version != null
&& k.last_bundle_version !== expectedBundleVersion;
return {
kiosk: k,
online,
bundleMismatch,
expectedBundleVersion,
displays,
};
});
return htmlPage(SystemHealthPage({ user: user.username, rows }));
});
// ---- Cameras -------------------------------------------------------------- // ---- Cameras --------------------------------------------------------------
app.get("/admin/cameras", (event) => { app.get("/admin/cameras", (event) => {
@ -513,6 +555,33 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
return new Response(null, { status: 302, headers: { location: "/admin/entities" } }); return new Response(null, { status: 302, headers: { location: "/admin/entities" } });
}); });
// Camera snapshot — pulls one frame from the entity's main stream and
// returns it as JPEG. Used by the EntityEditPage "Test" preview.
app.get("/admin/entities/:id/snapshot", async (event) => {
const id = Number(getRouterParam(event, "id"));
const ent = deps.repo.getEntityById(id);
if (!ent || ent.type !== "camera" || ent.camera_id == null) {
return new Response("Not a camera entity", { status: 404 });
}
const streams = deps.repo.listCameraStreams(ent.camera_id);
const main = streams.find((s) => s.role === "main") ?? streams[0];
const cam = deps.repo.getCameraById(ent.camera_id);
const rtsp = main?.rtsp_uri ?? cam?.rtsp_url ?? null;
if (!rtsp) return new Response("No RTSP URL", { status: 404 });
const jpeg = await captureSnapshot(rtsp, { timeoutMs: 8000 });
if (!jpeg) {
return new Response("Snapshot failed (camera unreachable or ffmpeg/gst missing)", { status: 502 });
}
return new Response(jpeg, {
status: 200,
headers: {
"content-type": "image/jpeg",
"cache-control": "no-store",
},
});
});
// ---- Kiosks --------------------------------------------------------------- // ---- Kiosks ---------------------------------------------------------------
app.get("/admin/kiosks", (event) => { app.get("/admin/kiosks", (event) => {
@ -1054,6 +1123,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
const displays = deps.repo.listDisplaysForKiosk(id); const displays = deps.repo.listDisplaysForKiosk(id);
const firstDisplay = displays[0]; const firstDisplay = displays[0];
const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : []; const switchableLayouts = firstDisplay ? deps.repo.listLayoutsForDisplay(firstDisplay.id) : [];
const gpioBindings = deps.repo.listGpioBindings(id);
return htmlPage(KioskEditPage({ return htmlPage(KioskEditPage({
user: user.username, user: user.username,
kiosk, kiosk,
@ -1061,9 +1131,45 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
allLabels: deps.repo.listLabels(), allLabels: deps.repo.listLabels(),
displays, displays,
switchableLayouts, switchableLayouts,
gpioBindings,
})); }));
}); });
// ---- GPIO bindings ----------------------------------------------------
app.post("/admin/kiosks/:id/gpio", async (event) => {
const kioskId = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event);
const pin = Number(body?.["pin"]);
const direction = (body?.["direction"] ?? "in") === "out" ? "out" : "in";
const pullRaw = body?.["pull"];
const pull = pullRaw === "up" || pullRaw === "down" || pullRaw === "none" ? pullRaw : null;
const edgeRaw = body?.["edge"];
const edge = edgeRaw === "rising" || edgeRaw === "falling" || edgeRaw === "both" ? edgeRaw : null;
const chip = (body?.["chip"] ?? "gpiochip0").trim() || "gpiochip0";
const topic = (body?.["topic"] ?? "").trim();
if (Number.isFinite(pin) && topic) {
deps.repo.createGpioBinding({
kiosk_id: kioskId,
chip,
pin,
direction,
pull,
edge,
topic,
});
notifyKiosks();
}
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
});
app.post("/admin/kiosks/:id/gpio/:bindingId/delete", (event) => {
const kioskId = Number(getRouterParam(event, "id"));
const bindingId = Number(getRouterParam(event, "bindingId"));
deps.repo.deleteGpioBinding(bindingId);
notifyKiosks();
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${kioskId}` } });
});
app.post("/admin/kiosks/:id", async (event) => { app.post("/admin/kiosks/:id", async (event) => {
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const body = await readBody<Record<string, string>>(event); const body = await readBody<Record<string, string>>(event);
@ -1106,13 +1212,39 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
}); });
// ---- Layout switch ---------------------------------------------------- // ---- Layout switch ----------------------------------------------------
app.post("/admin/kiosks/:id/layout/:layoutId", (event) => { const kioskLayoutSwitch = (event: any) => {
const id = Number(getRouterParam(event, "id")); const id = Number(getRouterParam(event, "id"));
const layoutId = Number(getRouterParam(event, "layoutId")); const layoutId = Number(getRouterParam(event, "layoutId"));
if (Number.isFinite(id) && Number.isFinite(layoutId)) { if (Number.isFinite(id) && Number.isFinite(layoutId)) {
getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId }); getCoordinator().sendToKiosk(id, { type: "layout-switch", layout_id: layoutId });
} }
return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } });
};
app.post("/admin/kiosks/:id/layout/:layoutId", kioskLayoutSwitch);
app.get("/admin/kiosks/:id/layout/:layoutId", kioskLayoutSwitch);
const displayLayoutSwitch = (event: any) => {
const displayId = Number(getRouterParam(event, "displayId"));
const layoutId = Number(getRouterParam(event, "layoutId"));
if (Number.isFinite(displayId) && Number.isFinite(layoutId)) {
const display = deps.repo.getDisplayById(displayId);
if (display?.kiosk_id) {
getCoordinator().sendToKiosk(display.kiosk_id, {
type: "layout-switch",
display_id: displayId,
layout_id: layoutId,
});
}
}
return new Response(null, { status: 302, headers: { location: `/admin/displays/${displayId}` } });
};
app.post("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
app.get("/admin/displays/:displayId/layout/:layoutId", displayLayoutSwitch);
// Node-RED embedded page
app.get("/admin/nodered", (event) => {
const user = event.context.user!;
return htmlPage(NoderedEmbedPage({ user: user.username }));
}); });
// ---- CEC power commands ----------------------------------------------- // ---- CEC power commands -----------------------------------------------

View file

@ -18,7 +18,11 @@ import type {
EntityType, EntityType,
EventLog, EventLog,
EventSourceType, EventSourceType,
GpioDirection,
GpioEdge,
GpioPull,
Kiosk, Kiosk,
KioskGpioBinding,
KioskLabel, KioskLabel,
Label, Label,
LabelRole, LabelRole,
@ -280,6 +284,22 @@ export function rowToPairingCode(r: Row): PairingCode {
}; };
} }
export function rowToKioskGpioBinding(r: Row): KioskGpioBinding {
const pullRaw = sn(r["pull"]);
const edgeRaw = sn(r["edge"]);
return {
id: n(r["id"]),
kiosk_id: n(r["kiosk_id"]),
chip: s(r["chip"]) || "gpiochip0",
pin: n(r["pin"]),
direction: s(r["direction"]) as GpioDirection,
pull: pullRaw ? (pullRaw as GpioPull) : null,
edge: edgeRaw ? (edgeRaw as GpioEdge) : null,
topic: s(r["topic"]),
created_at: s(r["created_at"]),
};
}
export function rowToEventLog(r: Row): EventLog { export function rowToEventLog(r: Row): EventLog {
return { return {
id: n(r["id"]), id: n(r["id"]),

View file

@ -601,4 +601,18 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
(db: DatabaseSync) => { (db: DatabaseSync) => {
addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'"); addColumnIfNotExists(db, "layout_cells", "fit", "TEXT NOT NULL DEFAULT 'cover'");
}, },
// ---- kiosk GPIO bindings ----
`CREATE TABLE IF NOT EXISTS kiosk_gpio_bindings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
chip TEXT NOT NULL DEFAULT 'gpiochip0',
pin INTEGER NOT NULL,
direction TEXT NOT NULL CHECK(direction IN ('in', 'out')),
pull TEXT CHECK(pull IN ('up', 'down', 'none')),
edge TEXT CHECK(edge IN ('rising', 'falling', 'both')),
topic TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
) STRICT`,
`CREATE INDEX IF NOT EXISTS idx_kiosk_gpio_bindings_kiosk ON kiosk_gpio_bindings(kiosk_id)`,
]; ];

View file

@ -22,7 +22,11 @@ import type {
EntityType, EntityType,
EventLog, EventLog,
EventSourceType, EventSourceType,
GpioDirection,
GpioEdge,
GpioPull,
Kiosk, Kiosk,
KioskGpioBinding,
KioskLabel, KioskLabel,
Label, Label,
LabelRole, LabelRole,
@ -45,6 +49,7 @@ import {
rowToEntity, rowToEntity,
rowToEventLog, rowToEventLog,
rowToKiosk, rowToKiosk,
rowToKioskGpioBinding,
rowToLabel, rowToLabel,
rowToLayout, rowToLayout,
rowToLayoutCell, rowToLayoutCell,
@ -1377,6 +1382,55 @@ export class Repository {
void this.notify("labels", "delete", id); void this.notify("labels", "delete", id);
} }
// ===========================================================================
// kiosk GPIO bindings
// ===========================================================================
listGpioBindings(kioskId: number): KioskGpioBinding[] {
const rs = this.prep(
"SELECT * FROM kiosk_gpio_bindings WHERE kiosk_id = ? ORDER BY chip, pin",
).all(kioskId);
return rs.map((r) => rowToKioskGpioBinding(r as Record<string, unknown>));
}
getGpioBindingById(id: number): KioskGpioBinding | null {
const r = this.prep("SELECT * FROM kiosk_gpio_bindings WHERE id = ?").get(id);
return r ? rowToKioskGpioBinding(r as Record<string, unknown>) : null;
}
createGpioBinding(input: {
kiosk_id: number;
chip?: string;
pin: number;
direction: GpioDirection;
pull?: GpioPull | null;
edge?: GpioEdge | null;
topic: string;
}): KioskGpioBinding {
const result = this.prep(
`INSERT INTO kiosk_gpio_bindings (kiosk_id, chip, pin, direction, pull, edge, topic)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
input.kiosk_id,
input.chip ?? "gpiochip0",
input.pin,
input.direction,
input.pull ?? null,
input.edge ?? null,
input.topic,
);
const id = Number(result.lastInsertRowid);
void this.notify("kiosk_gpio_bindings", "create", id);
const b = this.getGpioBindingById(id);
if (!b) throw new Error("gpio binding vanished after insert");
return b;
}
deleteGpioBinding(id: number): void {
this.db.prepare(`DELETE FROM kiosk_gpio_bindings WHERE id = ?`).run(id);
void this.notify("kiosk_gpio_bindings", "delete", id);
}
updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void { updateLabel(id: number, patch: { name?: string; description?: string | null; color?: string | null }): void {
const sets: string[] = []; const sets: string[] = [];
const vals: unknown[] = []; const vals: unknown[] = [];

View file

@ -70,12 +70,37 @@ export interface BundleDisplay {
default_layout_id: number | null; default_layout_id: number | null;
} }
export interface BundleDisplayWithLayouts extends BundleDisplay {
layouts: BundleLayout[];
}
export interface BundleGpioBinding {
id: number;
chip: string;
pin: number;
direction: "in" | "out";
pull: "up" | "down" | "none" | null;
edge: "rising" | "falling" | "both" | null;
topic: string;
}
export interface KioskBundle { export interface KioskBundle {
kiosk_id: number; kiosk_id: number;
kiosk_name: string; kiosk_name: string;
/**
* @deprecated Use `displays` (array). Kept for backward compat with older
* kiosk builds that consume a single display. Mirrors `displays[0]`.
*/
display: BundleDisplay; display: BundleDisplay;
/**
* @deprecated Use `displays[N].layouts`. Mirrors `displays[0].layouts` for
* older kiosk builds.
*/
layouts: BundleLayout[]; layouts: BundleLayout[];
/** All physical displays driven by this kiosk. New (multi-display) shape. */
displays: BundleDisplayWithLayouts[];
cameras: BundleCamera[]; cameras: BundleCamera[];
gpio_bindings: BundleGpioBinding[];
version: string; version: string;
} }
@ -88,23 +113,25 @@ export function generateBundle(
const kiosk = repo.getKioskById(kioskId); const kiosk = repo.getKioskById(kioskId);
if (!kiosk) return null; if (!kiosk) return null;
// Find display 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
let display = kioskDisplays[0] ?? null; const displays = kioskDisplays.length > 0
if (!display && kiosk.display_id) { ? kioskDisplays
display = repo.getDisplayById(kiosk.display_id); : (kiosk.display_id ? [repo.getDisplayById(kiosk.display_id)].filter((d): d is NonNullable<typeof d> => d != null) : []);
if (displays.length === 0) return null;
// Collect camera IDs across ALL displays' layouts (de-duped).
const allLayoutIds = new Set<number>();
for (const d of displays) {
for (const l of repo.layoutsForDisplayId(d.id)) allLayoutIds.add(l.id);
} }
if (!display) return null; const cameras = repo.camerasForLayoutIds([...allLayoutIds]);
const layouts = repo.layoutsForDisplayId(display.id); function buildLayouts(displayId: number, defaultLayoutId: number | null): BundleLayout[] {
const layoutIds = layouts.map((l) => l.id); const layouts = repo.layoutsForDisplayId(displayId);
return layouts.map((l) => {
// Collect all cameras referenced by cells in these layouts
const cameras = repo.camerasForLayoutIds(layoutIds);
const defaultLayoutId = display.default_layout_id;
const bundleLayouts: BundleLayout[] = layouts.map((l) => {
const cells = repo.layoutCells(l.id); const cells = repo.layoutCells(l.id);
let gridCols = 1; let gridCols = 1;
let gridRows = 1; let gridRows = 1;
@ -158,6 +185,18 @@ export function generateBundle(
}), }),
}; };
}); });
}
const bundleDisplays: BundleDisplayWithLayouts[] = displays.map((display) => ({
id: display.id,
name: display.name,
width_px: display.width_px,
height_px: display.height_px,
idle_timeout_seconds: display.idle_timeout_seconds,
sleep_timeout_seconds: display.sleep_timeout_seconds,
default_layout_id: display.default_layout_id,
layouts: buildLayouts(display.id, display.default_layout_id),
}));
const bundleCameras: BundleCamera[] = cameras.map((cam) => { const bundleCameras: BundleCamera[] = cameras.map((cam) => {
const streams = repo.listCameraStreams(cam.id); const streams = repo.listCameraStreams(cam.id);
@ -188,20 +227,36 @@ export function generateBundle(
}; };
}); });
const gpioBindings: BundleGpioBinding[] = repo.listGpioBindings(kioskId).map((g) => ({
id: g.id,
chip: g.chip,
pin: g.pin,
direction: g.direction,
pull: g.pull,
edge: g.edge,
topic: g.topic,
}));
// Mirror first display into the legacy top-level `display` + `layouts` so
// older kiosk builds keep working unchanged. New builds should read
// `displays`.
const primary = bundleDisplays[0]!;
const bundle: KioskBundle = { const bundle: KioskBundle = {
kiosk_id: kioskId, kiosk_id: kioskId,
kiosk_name: kiosk.name, kiosk_name: kiosk.name,
display: { display: {
id: display.id, id: primary.id,
name: display.name, name: primary.name,
width_px: display.width_px, width_px: primary.width_px,
height_px: display.height_px, height_px: primary.height_px,
idle_timeout_seconds: display.idle_timeout_seconds, idle_timeout_seconds: primary.idle_timeout_seconds,
sleep_timeout_seconds: display.sleep_timeout_seconds, sleep_timeout_seconds: primary.sleep_timeout_seconds,
default_layout_id: display.default_layout_id, default_layout_id: primary.default_layout_id,
}, },
layouts: bundleLayouts, layouts: primary.layouts,
displays: bundleDisplays,
cameras: bundleCameras, cameras: bundleCameras,
gpio_bindings: gpioBindings,
version: "", version: "",
}; };

View file

@ -0,0 +1,123 @@
/**
* Camera snapshot capture.
*
* Spawns ffmpeg (preferred) or gst-launch-1.0 to pull one frame from an RTSP
* URL and return it as a JPEG buffer. Bounded by a hard timeout so a stuck
* connection can't pile up subprocesses.
*/
import { spawn } from "node:child_process";
import { unlink, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomBytes } from "node:crypto";
const DEFAULT_TIMEOUT_MS = 8000;
export interface SnapshotOptions {
timeoutMs?: number;
}
/**
* Capture a single frame from an RTSP URL. Returns a JPEG buffer, or null on
* any failure (timeout, missing binary, non-zero exit, empty file).
*
* Tries ffmpeg first (fast, widely installed). Falls back to gst-launch-1.0.
*/
export async function captureSnapshot(
rtspUrl: string,
opts: SnapshotOptions = {},
): Promise<Buffer | null> {
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
if (!rtspUrl || !rtspUrl.startsWith("rtsp://")) return null;
const result = await tryFfmpeg(rtspUrl, timeoutMs);
if (result) return result;
return tryGstLaunch(rtspUrl, timeoutMs);
}
async function tryFfmpeg(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
// -y overwrite, -rtsp_transport tcp (most reliable), -frames:v 1 single frame,
// -an no audio. Use mjpeg via filename extension.
const args = [
"-y",
"-rtsp_transport", "tcp",
"-i", rtspUrl,
"-frames:v", "1",
"-q:v", "5",
"-an",
out,
];
const ok = await runWithTimeout("ffmpeg", args, timeoutMs);
if (!ok) {
void unlink(out).catch(() => undefined);
return null;
}
try {
const buf = await readFile(out);
return buf.length > 0 ? buf : null;
} catch {
return null;
} finally {
void unlink(out).catch(() => undefined);
}
}
async function tryGstLaunch(rtspUrl: string, timeoutMs: number): Promise<Buffer | null> {
const out = join(tmpdir(), `bf-snap-${randomBytes(8).toString("hex")}.jpg`);
// rtspsrc → decodebin → videoconvert → jpegenc → single-frame filesink
const args = [
"-q",
"rtspsrc", `location=${rtspUrl}`, "protocols=tcp", "latency=200", "!",
"decodebin", "!",
"videoconvert", "!",
"jpegenc", "!",
"filesink", `location=${out}`,
"num-buffers=1",
];
const ok = await runWithTimeout("gst-launch-1.0", args, timeoutMs);
if (!ok) {
void unlink(out).catch(() => undefined);
return null;
}
try {
const buf = await readFile(out);
return buf.length > 0 ? buf : null;
} catch {
return null;
} finally {
void unlink(out).catch(() => undefined);
}
}
function runWithTimeout(bin: string, args: string[], timeoutMs: number): Promise<boolean> {
return new Promise((resolve) => {
let child;
try {
child = spawn(bin, args, { stdio: ["ignore", "ignore", "pipe"] });
} catch {
resolve(false);
return;
}
let settled = false;
const t = setTimeout(() => {
if (settled) return;
settled = true;
try { child.kill("SIGKILL"); } catch { /* ignore */ }
resolve(false);
}, timeoutMs);
child.on("error", () => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve(false);
});
child.on("exit", (code) => {
if (settled) return;
settled = true;
clearTimeout(t);
resolve(code === 0);
});
});
}

View file

@ -237,6 +237,22 @@ export interface PairingCode {
extras: Record<string, unknown>; extras: Record<string, unknown>;
} }
export type GpioDirection = "in" | "out";
export type GpioPull = "up" | "down" | "none";
export type GpioEdge = "rising" | "falling" | "both";
export interface KioskGpioBinding {
id: number;
kiosk_id: number;
chip: string;
pin: number;
direction: GpioDirection;
pull: GpioPull | null;
edge: GpioEdge | null;
topic: string;
created_at: string;
}
export interface EventLog { export interface EventLog {
id: number; id: number;
source_kiosk_id: number | null; source_kiosk_id: number | null;

View file

@ -8,6 +8,7 @@ import type {
Display, Display,
Entity, Entity,
Kiosk, Kiosk,
KioskGpioBinding,
Label, Label,
Layout as LayoutType, Layout as LayoutType,
LayoutCell, LayoutCell,
@ -691,6 +692,31 @@ export function EntityEditPage(props: EntityEditPageProps) {
</select> </select>
</div> </div>
)} )}
{e.type === "camera" && e.camera_id != null && (
<div class="form-group">
<label>Live Preview</label>
<div style="background:#111827; border-radius:4px; overflow:hidden; aspect-ratio:16/9; display:flex; align-items:center; justify-content:center">
<img
id={`snap-${String(e.id)}`}
src={`/admin/entities/${e.id}/snapshot?t=${String(Date.now())}`}
alt="Camera snapshot"
style="width:100%; height:100%; object-fit:contain; display:block"
{...{ "onerror": "this.style.display='none'; var s=this.nextElementSibling; if(s) s.style.display='block';" }}
/>
<span style="display:none; color:#fca5a5; font-size:0.85rem">Snapshot failed camera unreachable or RTSP not configured</span>
</div>
<div style="margin-top:0.5rem">
<button
type="button"
class="btn btn-sm btn-ghost"
{...{ "onclick": `(function(){var img=document.getElementById('snap-${String(e.id)}'); if(img){img.style.display='block'; img.src='/admin/entities/${String(e.id)}/snapshot?t='+Date.now();}})()` }}
>
Refresh
</button>
<span style="margin-left:0.5rem; color:#666; font-size:0.8rem">Pulls one frame via ffmpeg/gst (up to ~8s).</span>
</div>
</div>
)}
{e.type === "web" && ( {e.type === "web" && (
<div class="form-group"> <div class="form-group">
<label for="web_url">URL</label> <label for="web_url">URL</label>
@ -1165,6 +1191,7 @@ interface KioskEditProps {
allLabels: Label[]; allLabels: Label[];
displays?: Display[]; displays?: Display[];
switchableLayouts?: LayoutType[]; switchableLayouts?: LayoutType[];
gpioBindings?: KioskGpioBinding[];
error?: string; error?: string;
success?: string; success?: string;
} }
@ -1284,6 +1311,93 @@ export function KioskEditPage(props: KioskEditProps) {
)} )}
</div> </div>
{/* GPIO bindings */}
<div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">GPIO Bindings</h2>
<p style="color:#666; font-size:0.85rem; margin-bottom:1rem">
Each input binding fires an event with the configured topic when the
pin's edge triggers. Pi 5's main GPIO chip is <code>gpiochip4</code>;
older Pis use <code>gpiochip0</code>.
</p>
{props.gpioBindings && props.gpioBindings.length > 0 ? (
<div class="table-wrap" style="margin-bottom:1rem">
<table>
<thead>
<tr>
<th>Chip</th>
<th>Pin</th>
<th>Dir</th>
<th>Pull</th>
<th>Edge</th>
<th>Topic</th>
<th></th>
</tr>
</thead>
<tbody>
{props.gpioBindings.map((g) => (
<tr>
<td style="font-family:monospace; font-size:0.85rem">{g.chip}</td>
<td style="font-family:monospace">{String(g.pin)}</td>
<td><span class="badge badge-gray">{g.direction}</span></td>
<td style="font-size:0.85rem">{g.pull ?? "—"}</td>
<td style="font-size:0.85rem">{g.edge ?? "—"}</td>
<td style="font-family:monospace; font-size:0.85rem">{g.topic}</td>
<td>
<form method="post" action={`/admin/kiosks/${k.id}/gpio/${g.id}/delete`} style="display:inline">
<button type="submit" class="btn btn-sm btn-danger" {...{"onclick": "return confirm('Remove GPIO binding?')"}}>×</button>
</form>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p style="color:#999; margin-bottom:1rem">No GPIO bindings configured</p>
)}
<form method="post" action={`/admin/kiosks/${k.id}/gpio`} style="display:grid; grid-template-columns:repeat(6, 1fr) auto; gap:0.5rem; align-items:end">
<div>
<label style="font-size:0.75rem; color:#666">Chip</label>
<input name="chip" class="form-input" value="gpiochip0" />
</div>
<div>
<label style="font-size:0.75rem; color:#666">Pin</label>
<input name="pin" type="number" class="form-input" required min="0" />
</div>
<div>
<label style="font-size:0.75rem; color:#666">Dir</label>
<select name="direction" class="form-input">
<option value="in">in</option>
<option value="out">out</option>
</select>
</div>
<div>
<label style="font-size:0.75rem; color:#666">Pull</label>
<select name="pull" class="form-input">
<option value=""></option>
<option value="up">up</option>
<option value="down">down</option>
<option value="none">none</option>
</select>
</div>
<div>
<label style="font-size:0.75rem; color:#666">Edge</label>
<select name="edge" class="form-input">
<option value=""></option>
<option value="rising">rising</option>
<option value="falling">falling</option>
<option value="both">both</option>
</select>
</div>
<div>
<label style="font-size:0.75rem; color:#666">Topic</label>
<input name="topic" class="form-input" required placeholder="gpio/button-1" />
</div>
<button type="submit" class="btn btn-primary btn-sm">Add</button>
</form>
</div>
<div class="card" style="margin-bottom:1.5rem"> <div class="card" style="margin-bottom:1.5rem">
<h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2> <h2 style="margin:0 0 1rem; font-size:1.1rem">Labels</h2>
{props.labels.length > 0 ? ( {props.labels.length > 0 ? (
@ -1926,6 +2040,25 @@ export function DisplayEditPage(props: DisplayEditPageProps) {
<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>
)} )}
</div> </div>
{props.attachedLayouts.length > 0 && d.kiosk_id ? (
<div style="margin-bottom:1rem; padding:0.75rem; background:#f9fafb; border-radius:4px">
<div style="font-size:0.85rem; font-weight:600; margin-bottom:0.5rem">Switch Layout Now</div>
<form
method="post"
action={`/admin/displays/${d.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.attachedLayouts.map((l) => (
<option value={String(l.id)}>{l.name}</option>
))}
</select>
<button type="submit" class="btn btn-sm">Switch</button>
</form>
</div>
) : null}
<form method="post" action={`/admin/displays/${d.id}`}> <form method="post" action={`/admin/displays/${d.id}`}>
<div class="form-group"> <div class="form-group">
<label for="name">Name</label> <label for="name">Name</label>
@ -2090,3 +2223,135 @@ function formatTime(iso: string): string {
return iso; return iso;
} }
} }
// ---- System Health ----------------------------------------------------------
interface SystemHealthRow {
kiosk: Kiosk;
online: boolean;
bundleMismatch: boolean;
expectedBundleVersion: string | null;
displays: Display[];
}
interface SystemHealthPageProps {
user: string;
rows: SystemHealthRow[];
}
function tempBadge(temp: number | null) {
if (temp == null) return <span class="badge badge-gray"></span>;
const txt = `${temp.toFixed(1)}°C`;
if (temp >= 80) return <span class="badge badge-red">{txt}</span>;
if (temp >= 70) return <span class="badge" style="background-color:#fef3c7; color:#92400e">{txt}</span>;
return <span class="badge badge-green">{txt}</span>;
}
// ---- Node-RED Embed ---------------------------------------------------
export function NoderedEmbedPage(props: { user: string }) {
return (
<Layout title="Node-RED" user={props.user} activeNav="nodered">
<div style="position:fixed; top:48px; left:220px; right:0; bottom:0; background:#fff">
<iframe
src="/nrdp/"
style="width:100%; height:100%; border:none; display:block"
{...{ "sandbox": "allow-same-origin allow-scripts allow-forms allow-popups allow-downloads" }}
/>
</div>
</Layout>
);
}
export function SystemHealthPage(props: SystemHealthPageProps) {
const total = props.rows.length;
const online = props.rows.filter((r) => r.online).length;
const hot = props.rows.filter((r) => r.kiosk.cpu_temp_c != null && r.kiosk.cpu_temp_c >= 70).length;
const mismatched = props.rows.filter((r) => r.bundleMismatch).length;
return (
<Layout title="System Health" user={props.user} activeNav="health">
<meta http-equiv="refresh" content="30" />
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Kiosks</div>
<div class="stat-value">{String(total)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Online</div>
<div class="stat-value" style={online === total ? "color:#065f46" : "color:#92400e"}>{String(online)}/{String(total)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Hot (70°C)</div>
<div class="stat-value" style={hot > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(hot)}</div>
</div>
<div class="stat-card">
<div class="stat-label">Bundle Mismatch</div>
<div class="stat-value" style={mismatched > 0 ? "color:#b91c1c" : "color:#065f46"}>{String(mismatched)}</div>
</div>
</div>
<p style="color:#666; margin-bottom:1rem; font-size:0.85rem">
Auto-refresh every 30 seconds. Online = last seen within 5 minutes.
</p>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Kiosk</th>
<th>Status</th>
<th>Last Seen</th>
<th>CPU Temp</th>
<th>Fan</th>
<th>Bundle</th>
<th>Displays</th>
</tr>
</thead>
<tbody>
{props.rows.length === 0 ? (
<tr><td colspan="7" style="text-align:center; color:#999; padding:2rem">No kiosks paired</td></tr>
) : (
props.rows.map((row) => {
const k = row.kiosk;
return (
<tr>
<td><a href={`/admin/kiosks/${k.id}`}><strong>{k.name}</strong></a></td>
<td>
{row.online
? <span class="badge badge-green">Online</span>
: <span class="badge badge-red">Offline</span>}
</td>
<td style="font-size:0.85rem; white-space:nowrap">{k.last_seen_at ? formatTime(k.last_seen_at) : "Never"}</td>
<td>{tempBadge(k.cpu_temp_c)}</td>
<td style="font-size:0.85rem">
{k.fan_rpm != null ? `${String(k.fan_rpm)} RPM` : "—"}
{k.fan_pwm != null && (
<span style="color:#999"> ({String(k.fan_pwm)}/255)</span>
)}
</td>
<td style="font-size:0.85rem">
{row.bundleMismatch ? (
<span class="badge badge-red" title={`expected ${row.expectedBundleVersion ?? "?"}, have ${k.last_bundle_version ?? "none"}`}>mismatch</span>
) : k.last_bundle_version ? (
<span class="badge badge-green">{k.last_bundle_version.slice(0, 8)}</span>
) : (
<span class="badge badge-gray"></span>
)}
</td>
<td style="font-size:0.85rem">
{row.displays.length === 0 ? (
<span style="color:#999">none</span>
) : (
row.displays.map((d) => (
<div>{d.name}: {String(d.width_px)}×{String(d.height_px)}</div>
))
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Layout>
);
}

View file

@ -43,6 +43,7 @@ function Sidebar(props: { activeNav?: string }) {
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">
<NavItem href="/admin/" label="Overview" icon="&#9632;" active={a === "overview"} /> <NavItem href="/admin/" label="Overview" icon="&#9632;" active={a === "overview"} />
<NavItem href="/admin/health" label="Health" icon="&#9829;" active={a === "health"} />
<NavItem href="/admin/cameras" label="Cameras" icon="&#9899;" active={a === "cameras"} /> <NavItem href="/admin/cameras" label="Cameras" icon="&#9899;" active={a === "cameras"} />
<NavItem href="/admin/entities" label="Entities" icon="&#9863;" active={a === "entities"} /> <NavItem href="/admin/entities" label="Entities" icon="&#9863;" active={a === "entities"} />
<NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} /> <NavItem href="/admin/layouts" label="Layouts" icon="&#9638;" active={a === "layouts"} />
@ -51,7 +52,7 @@ function Sidebar(props: { activeNav?: string }) {
<NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} /> <NavItem href="/admin/labels" label="Labels" icon="&#9670;" active={a === "labels"} />
<hr /> <hr />
<NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} /> <NavItem href="/admin/account" label="Account" icon="&#9679;" active={a === "account"} />
<NavItem href="/nrdp/" label="Node-RED" icon="&#8594;" /> <NavItem href="/admin/nodered" label="Node-RED" icon="&#8594;" active={a === "nodered"} />
</nav> </nav>
</aside> </aside>
); );