fix(bundle): synthesize stream for any camera with rtsp_url

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.
This commit is contained in:
Mitchell R 2026-05-21 09:23:50 +02:00
parent 49e420dea5
commit 281c0adf44
No known key found for this signature in database
11 changed files with 272 additions and 5 deletions

View file

@ -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<String> {
hostname::get()
.ok()
.map(|h| h.to_string_lossy().trim().to_string())
.filter(|h| !h.is_empty())
}
fn read_network_interfaces() -> Vec<Value> {
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<Value> = 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()

View file

@ -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}"))

View file

@ -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<typeof getRequestHeader>[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<Record<string, string>>(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<string, unknown> = {};
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;

View file

@ -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<Record<string, unknown>>;
// 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 };
});
}

View file

@ -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),

View file

@ -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
)
`);
},
];

View file

@ -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,
);
}

View file

@ -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" },
);

View file

@ -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,

View file

@ -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;

View file

@ -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 || "<kiosk-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 || "<kiosk-ip>");
const base = `http://${ip}:${String(k.local_port)}`;
const sample = `${base}/local/layout/<layout_id>?key=${k.local_key}`;
const proxy = `${base}/proxy/admin/...`;
@ -3071,8 +3106,33 @@ export function KioskLocalPanel(props: KioskLocalPanelProps) {
<strong>Admin proxy (forwards your Bearer to server):</strong>
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap">{proxy}</pre>
</div>
{k.reported_hostname || reportedInterfaces.length > 0 ? (
<div style="font-size:0.8rem; margin-bottom:0.5rem">
{k.reported_hostname ? <div><strong>Reported hostname:</strong> <code>{k.reported_hostname}</code></div> : null}
{reportedInterfaces.length > 0 ? (
<div style="margin-top:0.35rem">
<strong>Reported interfaces:</strong>
<ul style="margin:0.25rem 0 0; padding-left:1.25rem">
{reportedInterfaces.map((iface) => (
<li>
<code>{iface.name}</code>
{iface.operstate ? <> ({iface.operstate})</> : null}
{": "}
{iface.ips.map((ipAddr, idx) => (
<>
{idx > 0 ? ", " : ""}
<code>{ipAddr}</code>
</>
))}
</li>
))}
</ul>
</div>
) : null}
</div>
) : null}
<div style="font-size:0.75rem; color:#999">
Last seen from IP: <code>{k.local_last_ip ?? "—"}</code>. Local key:
Heartbeat source IP: <code>{k.local_last_ip ?? "--"}</code>. Local key:
<code style="margin-left:0.25rem">{k.local_key.slice(0, 8)}{k.local_key.slice(-4)}</code>
</div>
</div>