From 281c0adf44ba9fa20bd4ce06679ac759a81bb4c8 Mon Sep 17 00:00:00 2001 From: Mitchell R Date: Thu, 21 May 2026 09:23:50 +0200 Subject: [PATCH] fix(bundle): synthesize stream for any camera with rtsp_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ONVIF-imported cameras with rtsp_url but no camera_streams rows showed "(no stream)" in the kiosk because the bundle fallback was gated to type=rtsp only. Drop the type check + backfill existing rows so old imports get a main stream row created. feat(kiosk-mgmt): report hostname + all network interfaces Behind Docker/Angie the server only saw the proxy bridge IP (172.31.0.2). Kiosk now shells `ip -j addr show`, reports every non-loopback IPv4/v6 with CIDR, MAC, and operstate. Plus `hostname` for verifying that managed-config applies landed. Admin UI renders interface list with LAN IPs preferred for the copy-paste local-LAN endpoint. feat(managed-config): auto-sync hostname from kiosk name When admin renames a managed-image kiosk, slugify the name → DNS-safe hostname and bump managed_config_version so the kiosk applies it on next heartbeat. Empty form hostname now falls back to slug too, so DHCP shows the friendly name. feat(events): forward firmware + OS update outcomes as kiosk.log Kiosk POSTs `/api/kiosk/event` with topic=kiosk.log on firmware-apply attempts. Server-side firmware/os-update endpoints also insert into event_log so admins can audit upgrades without correlating per-source. Wire schema heartbeat gains reported_hostname + network_interfaces for Rust import parity. --- kiosk/src/server.rs | 89 +++++++++++++++++++ kiosk/src/ui.rs | 23 +++++ .../service-admin-http/routes-admin.ts | 25 +++++- server/src/plugins/service-api-http/index.ts | 34 +++++++ server/src/plugins/service-store/mappers.ts | 2 + .../src/plugins/service-store/migrations.ts | 17 ++++ .../src/plugins/service-store/repository.ts | 8 +- server/src/schemas/wire/events.ts | 11 +++ server/src/shared/bundle.ts | 2 +- server/src/shared/types.ts | 2 + server/src/web-templates/admin-pages.tsx | 64 ++++++++++++- 11 files changed, 272 insertions(+), 5 deletions(-) diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index c5acc39..a4957c2 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -1,8 +1,10 @@ use std::fs; use std::path::PathBuf; +use std::process::Command; use std::time::Duration; use serde::Deserialize; +use serde_json::Value; use tracing::info; use crate::bundle::KioskBundle; @@ -19,6 +21,71 @@ fn kiosk_app_version() -> &'static str { option_env!("BF_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")) } +fn reported_hostname() -> Option { + hostname::get() + .ok() + .map(|h| h.to_string_lossy().trim().to_string()) + .filter(|h| !h.is_empty()) +} + +fn read_network_interfaces() -> Vec { + let out = match Command::new("ip").args(["-j", "addr", "show"]).output() { + Ok(out) if out.status.success() => out, + Ok(out) => { + tracing::warn!("ip -j addr show exited with {}", out.status); + return Vec::new(); + } + Err(err) => { + tracing::warn!("ip -j addr show failed: {err}"); + return Vec::new(); + } + }; + + let parsed: Value = match serde_json::from_slice(&out.stdout) { + Ok(v) => v, + Err(err) => { + tracing::warn!("ip -j addr show parse failed: {err}"); + return Vec::new(); + } + }; + + let Some(items) = parsed.as_array() else { + return Vec::new(); + }; + + items + .iter() + .filter_map(|item| { + let name = item.get("ifname")?.as_str()?; + let addr_info = item.get("addr_info")?.as_array()?; + let ips: Vec = addr_info + .iter() + .filter_map(|addr| { + let family = addr.get("family")?.as_str()?; + if family != "inet" && family != "inet6" { + return None; + } + let local = addr.get("local")?.as_str()?; + let prefix = addr.get("prefixlen").and_then(|v| v.as_u64()); + Some(match prefix { + Some(prefix) => Value::String(format!("{local}/{prefix}")), + None => Value::String(local.to_string()), + }) + }) + .collect(); + if ips.is_empty() { + return None; + } + Some(serde_json::json!({ + "name": name, + "mac": item.get("address").and_then(|v| v.as_str()), + "operstate": item.get("operstate").and_then(|v| v.as_str()), + "ips": ips, + })) + }) + .collect() +} + fn state_dir() -> PathBuf { let home = dirs::home_dir().expect("no home directory"); let dir = home.join(".betterframe-kiosk"); @@ -263,6 +330,24 @@ pub fn report_layout_change( .send(); } +pub fn report_kiosk_log(server: &str, key: &str, level: &str, message: &str, payload: Value) { + let client = reqwest::blocking::Client::new(); + let _ = client + .post(format!("{server}/api/kiosk/event")) + .header("Authorization", format!("Bearer {key}")) + .json(&serde_json::json!({ + "topic": "kiosk.log", + "source_type": "system", + "payload": { + "level": level, + "message": message, + "context": payload, + }, + })) + .timeout(Duration::from_secs(5)) + .send(); +} + pub fn heartbeat( server: &str, key: &str, @@ -286,6 +371,8 @@ pub fn heartbeat( .ok() .and_then(|s| s.parse().ok()) .unwrap_or(18090); + let hostname = reported_hostname(); + let network_interfaces = read_network_interfaces(); client .post(format!("{server}/api/kiosk/heartbeat")) .header("Authorization", format!("Bearer {key}")) @@ -303,6 +390,8 @@ pub fn heartbeat( "disk_used_percent": hw.disk_used_percent, "local_key": local_key, "local_port": local_port, + "reported_hostname": hostname, + "network_interfaces": network_interfaces, })) .timeout(Duration::from_secs(5)) .send() diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 269e1ae..537963b 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -454,8 +454,31 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { return; }; info!("firmware: update {} → {} available", current, info.version); + server::report_kiosk_log( + server_url, + kiosk_key, + "info", + "firmware update available", + serde_json::json!({ + "current_version": current, + "target_version": &info.version, + "channel": &info.channel, + "release_id": &info.release_id, + }), + ); if let Err(err) = firmware::apply(server_url, kiosk_key, &info) { warn!("firmware: apply failed: {err}"); + server::report_kiosk_log( + server_url, + kiosk_key, + "error", + "firmware update failed", + serde_json::json!({ + "target_version": &info.version, + "release_id": &info.release_id, + "error": &err, + }), + ); let _ = reqwest::blocking::Client::new() .post(format!("{server_url}/api/kiosk/firmware/applied")) .header("Authorization", format!("Bearer {kiosk_key}")) diff --git a/server/src/plugins/service-admin-http/routes-admin.ts b/server/src/plugins/service-admin-http/routes-admin.ts index c161fa7..019c4f6 100644 --- a/server/src/plugins/service-admin-http/routes-admin.ts +++ b/server/src/plugins/service-admin-http/routes-admin.ts @@ -70,6 +70,17 @@ function jsonResponse(value: unknown, status: number = 200): Response { }); } +function hostnameFromName(name: string): string { + const slug = name + .toLowerCase() + .replace(/[^a-z0-9-]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-") + .slice(0, 63) + .replace(/^-+|-+$/g, ""); + return slug || "betterframe-kiosk"; +} + function isHtmxRequest(event: Parameters[0]): boolean { return getRequestHeader(event, "hx-request") === "true"; } @@ -1358,10 +1369,22 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { app.post("/admin/kiosks/:id", async (event) => { const id = Number(getRouterParam(event, "id")); const body = await readBody>(event); + const kiosk = deps.repo.getKioskById(id); deps.repo.updateKiosk(id, { name: body?.["name"], enabled: body?.["enabled"] === "1", } as any); + if (kiosk?.managed_image && body?.["name"]) { + const cfg = kiosk.managed_config_json ? JSON.parse(kiosk.managed_config_json) : {}; + const hostname = hostnameFromName(body["name"]); + if (cfg?.hostname !== hostname) { + deps.repo.updateKiosk(id, { + managed_config_json: JSON.stringify({ ...cfg, hostname }), + managed_config_version: kiosk.managed_config_version + 1, + managed_config_error: null, + } as any); + } + } return new Response(null, { status: 302, headers: { location: `/admin/kiosks/${id}` } }); }); @@ -1380,7 +1403,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void { const trim = (v: string | undefined) => (v ?? "").trim(); const cfg: Record = {}; - const hostname = trim(body?.["hostname"]); + const hostname = trim(body?.["hostname"]) || hostnameFromName(kiosk.name); if (hostname) cfg["hostname"] = hostname; const timezone = trim(body?.["timezone"]); if (timezone) cfg["timezone"] = timezone; diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index fb32749..1f2301f 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -320,6 +320,8 @@ function registerKioskRoutes( disk_used_percent?: number | null; local_key?: string | null; local_port?: number | null; + reported_hostname?: string | null; + network_interfaces?: Array>; // Managed-image kiosk echoes back the version it last applied, and the // last apply error (if any). Server uses these to decide whether to // include pending_config in the response. @@ -349,6 +351,10 @@ function registerKioskRoutes( local_key: body?.local_key ?? null, local_port: body?.local_port ?? null, local_last_ip: remoteIp, + reported_hostname: body?.reported_hostname ?? null, + network_interfaces_json: Array.isArray(body?.network_interfaces) + ? JSON.stringify(body.network_interfaces) + : null, }); // Managed-config echo: kiosk reports the version it has successfully @@ -381,6 +387,8 @@ function registerKioskRoutes( disk_free_mb: body?.disk_free_mb, disk_used_percent: body?.disk_used_percent, ip: remoteIp, + reported_hostname: body?.reported_hostname, + network_interfaces: body?.network_interfaces, }); // Sync displays reported by the kiosk @@ -657,6 +665,19 @@ function registerKioskRoutes( throw createError({ statusCode: 400, statusMessage: "version required" }); } repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null); + repo.insertEvent({ + source_kiosk_id: kiosk.id, + source_camera_id: null, + source_type: "system", + topic: "kiosk.log", + property_op: null, + payload: { + level: body.error ? "error" : "info", + message: body.error ? "firmware update failed" : "firmware update applied", + context: { version: body.version, error: body.error ?? null }, + }, + forwarded_to_nodered: false, + }); return { ok: true }; }); @@ -759,6 +780,19 @@ function registerKioskRoutes( throw createError({ statusCode: 400, statusMessage: "version required" }); } repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null); + repo.insertEvent({ + source_kiosk_id: kiosk.id, + source_camera_id: null, + source_type: "system", + topic: "kiosk.log", + property_op: null, + payload: { + level: body.error ? "error" : "info", + message: body.error ? "os update failed" : "os update applied", + context: { version: body.version, error: body.error ?? null }, + }, + forwarded_to_nodered: false, + }); return { ok: true }; }); } diff --git a/server/src/plugins/service-store/mappers.ts b/server/src/plugins/service-store/mappers.ts index 8685c33..cc87073 100644 --- a/server/src/plugins/service-store/mappers.ts +++ b/server/src/plugins/service-store/mappers.ts @@ -282,6 +282,8 @@ export function rowToKiosk(r: Row): Kiosk { local_key: sn(r["local_key"]), local_port: nn(r["local_port"]), local_last_ip: sn(r["local_last_ip"]), + reported_hostname: sn(r["reported_hostname"]), + network_interfaces_json: sn(r["network_interfaces_json"]), managed_image: b(r["managed_image"]), managed_config_json: sn(r["managed_config_json"]), managed_config_version: n(r["managed_config_version"] ?? 0), diff --git a/server/src/plugins/service-store/migrations.ts b/server/src/plugins/service-store/migrations.ts index 0a9b392..c4aac33 100644 --- a/server/src/plugins/service-store/migrations.ts +++ b/server/src/plugins/service-store/migrations.ts @@ -806,6 +806,8 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "kiosks", "local_key", "TEXT"); addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER"); addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT"); + addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT"); + addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT"); }, // ---- Audit log ----------------------------------------------------------- @@ -864,4 +866,19 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "displays", "actual_power_state", "TEXT NOT NULL DEFAULT 'unknown'"); addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT"); }, + + // Backfill any camera type with a direct RTSP URL. Earlier backfill only + // covered type=rtsp, but ONVIF imports can also retain rtsp_url. + (db: DatabaseSync) => { + db.exec(` + INSERT INTO camera_streams (camera_id, role, name, rtsp_uri, is_discovered) + SELECT c.id, 'main', 'Main', c.rtsp_url, 0 + FROM cameras c + WHERE c.rtsp_url IS NOT NULL + AND c.rtsp_url != '' + AND NOT EXISTS ( + SELECT 1 FROM camera_streams s WHERE s.camera_id = c.id + ) + `); + }, ]; diff --git a/server/src/plugins/service-store/repository.ts b/server/src/plugins/service-store/repository.ts index 033f8c1..d484469 100644 --- a/server/src/plugins/service-store/repository.ts +++ b/server/src/plugins/service-store/repository.ts @@ -1063,6 +1063,8 @@ export class Repository { local_key?: string | null; local_port?: number | null; local_last_ip?: string | null; + reported_hostname?: string | null; + network_interfaces_json?: string | null; }, ): void { this.prep( @@ -1082,7 +1084,9 @@ export class Repository { disk_used_percent = ?, local_key = COALESCE(?, local_key), local_port = COALESCE(?, local_port), - local_last_ip = COALESCE(?, local_last_ip) + local_last_ip = COALESCE(?, local_last_ip), + reported_hostname = COALESCE(?, reported_hostname), + network_interfaces_json = COALESCE(?, network_interfaces_json) WHERE id = ?`, ).run( isoNow(), @@ -1101,6 +1105,8 @@ export class Repository { patch.local_key ?? null, patch.local_port ?? null, patch.local_last_ip ?? null, + patch.reported_hostname ?? null, + patch.network_interfaces_json ?? null, id, ); } diff --git a/server/src/schemas/wire/events.ts b/server/src/schemas/wire/events.ts index c7a3cbb..52816b5 100644 --- a/server/src/schemas/wire/events.ts +++ b/server/src/schemas/wire/events.ts @@ -34,6 +34,17 @@ export const kioskHeartbeat = av.object( active_layout_id: av.optional(av.int().min(1)), streams_warm: av.optional(av.int().min(0)), streams_hot: av.optional(av.int().min(0)), + // Kiosk-reported network identity — host sees only the proxy IP behind + // Docker/Angie. reported_hostname lets the admin verify the helper applied + // the desired hostname. network_interfaces lists every non-loopback iface + // with its IPs (v4/v6, with CIDR), MAC, and operstate from `ip -j addr`. + reported_hostname: av.optional(av.string().maxLength(253)), + network_interfaces: av.optional(av.array(av.object({ + name: av.string().minLength(1).maxLength(64), + mac: av.optional(av.string().maxLength(32)), + operstate: av.optional(av.string().maxLength(32)), + ips: av.array(av.string().minLength(1).maxLength(64)), + }, { unknownKeys: "strip" }))), }, { unknownKeys: "strip" }, ); diff --git a/server/src/shared/bundle.ts b/server/src/shared/bundle.ts index c65a59f..cb9cec6 100644 --- a/server/src/shared/bundle.ts +++ b/server/src/shared/bundle.ts @@ -209,7 +209,7 @@ export function generateBundle( const bundleCameras: BundleCamera[] = cameras.map((cam) => { const streams = repo.listCameraStreams(cam.id); const effectiveStreams = streams.length > 0 ? streams : ( - cam.type === "rtsp" && cam.rtsp_url + cam.rtsp_url ? [{ id: 0, role: "main" as const, diff --git a/server/src/shared/types.ts b/server/src/shared/types.ts index afbf3c7..a3557b5 100644 --- a/server/src/shared/types.ts +++ b/server/src/shared/types.ts @@ -233,6 +233,8 @@ export interface Kiosk { local_key: string | null; local_port: number | null; local_last_ip: string | null; + reported_hostname: string | null; + network_interfaces_json: string | null; // Managed-image device config. Only meaningful when managed_image=true; for // BYO-OS kiosks these fields stay at defaults and the admin UI hides them. managed_image: boolean; diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 43a66a5..222079b 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -3049,10 +3049,45 @@ export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) { interface KioskLocalPanelProps { kiosk: Kiosk } +interface ReportedNetworkInterface { + name: string; + mac?: string | null; + operstate?: string | null; + ips: string[]; +} + +function parseReportedNetworkInterfaces(raw: string | null): ReportedNetworkInterface[] { + if (!raw) return []; + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + return parsed.map((item) => ({ + name: typeof item?.name === "string" ? item.name : "unknown", + mac: typeof item?.mac === "string" ? item.mac : null, + operstate: typeof item?.operstate === "string" ? item.operstate : null, + ips: Array.isArray(item?.ips) ? item.ips.filter((ip: unknown) => typeof ip === "string") : [], + })).filter((item) => item.ips.length > 0); + } catch { + return []; + } +} + +function ipWithoutCidr(ip: string): string { + return ip.includes("/") ? ip.slice(0, ip.indexOf("/")) : ip; +} + +function isUsableLanIp(ip: string): boolean { + const bare = ipWithoutCidr(ip); + return bare !== "::1" && !bare.startsWith("127.") && !bare.startsWith("169.254."); +} + export function KioskLocalPanel(props: KioskLocalPanelProps) { const k = props.kiosk; if (!k.local_key || !k.local_port) return ""; - const ip = k.local_last_ip || ""; + const reportedInterfaces = parseReportedNetworkInterfaces(k.network_interfaces_json); + const reportedIps = reportedInterfaces.flatMap((iface) => iface.ips); + const primaryReportedIp = reportedIps.find(isUsableLanIp); + const ip = primaryReportedIp ? ipWithoutCidr(primaryReportedIp) : (k.local_last_ip || ""); const base = `http://${ip}:${String(k.local_port)}`; const sample = `${base}/local/layout/?key=${k.local_key}`; const proxy = `${base}/proxy/admin/...`; @@ -3071,8 +3106,33 @@ export function KioskLocalPanel(props: KioskLocalPanelProps) { Admin proxy (forwards your Bearer to server):
{proxy}
+ {k.reported_hostname || reportedInterfaces.length > 0 ? ( +
+ {k.reported_hostname ?
Reported hostname: {k.reported_hostname}
: null} + {reportedInterfaces.length > 0 ? ( +
+ Reported interfaces: +
    + {reportedInterfaces.map((iface) => ( +
  • + {iface.name} + {iface.operstate ? <> ({iface.operstate}) : null} + {": "} + {iface.ips.map((ipAddr, idx) => ( + <> + {idx > 0 ? ", " : ""} + {ipAddr} + + ))} +
  • + ))} +
+
+ ) : null} +
+ ) : null}
- Last seen from IP: {k.local_last_ip ?? "—"}. Local key: + Heartbeat source IP: {k.local_last_ip ?? "--"}. Local key: {k.local_key.slice(0, 8)}…{k.local_key.slice(-4)}