mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 19:06:34 +00:00
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:
parent
49e420dea5
commit
281c0adf44
11 changed files with 272 additions and 5 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}"))
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
`);
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue