mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56: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::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
use serde_json::Value;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::bundle::KioskBundle;
|
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"))
|
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 {
|
fn state_dir() -> PathBuf {
|
||||||
let home = dirs::home_dir().expect("no home directory");
|
let home = dirs::home_dir().expect("no home directory");
|
||||||
let dir = home.join(".betterframe-kiosk");
|
let dir = home.join(".betterframe-kiosk");
|
||||||
|
|
@ -263,6 +330,24 @@ pub fn report_layout_change(
|
||||||
.send();
|
.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(
|
pub fn heartbeat(
|
||||||
server: &str,
|
server: &str,
|
||||||
key: &str,
|
key: &str,
|
||||||
|
|
@ -286,6 +371,8 @@ pub fn heartbeat(
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse().ok())
|
.and_then(|s| s.parse().ok())
|
||||||
.unwrap_or(18090);
|
.unwrap_or(18090);
|
||||||
|
let hostname = reported_hostname();
|
||||||
|
let network_interfaces = read_network_interfaces();
|
||||||
client
|
client
|
||||||
.post(format!("{server}/api/kiosk/heartbeat"))
|
.post(format!("{server}/api/kiosk/heartbeat"))
|
||||||
.header("Authorization", format!("Bearer {key}"))
|
.header("Authorization", format!("Bearer {key}"))
|
||||||
|
|
@ -303,6 +390,8 @@ pub fn heartbeat(
|
||||||
"disk_used_percent": hw.disk_used_percent,
|
"disk_used_percent": hw.disk_used_percent,
|
||||||
"local_key": local_key,
|
"local_key": local_key,
|
||||||
"local_port": local_port,
|
"local_port": local_port,
|
||||||
|
"reported_hostname": hostname,
|
||||||
|
"network_interfaces": network_interfaces,
|
||||||
}))
|
}))
|
||||||
.timeout(Duration::from_secs(5))
|
.timeout(Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
|
|
@ -454,8 +454,31 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
info!("firmware: update {} → {} available", current, info.version);
|
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) {
|
if let Err(err) = firmware::apply(server_url, kiosk_key, &info) {
|
||||||
warn!("firmware: apply failed: {err}");
|
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()
|
let _ = reqwest::blocking::Client::new()
|
||||||
.post(format!("{server_url}/api/kiosk/firmware/applied"))
|
.post(format!("{server_url}/api/kiosk/firmware/applied"))
|
||||||
.header("Authorization", format!("Bearer {kiosk_key}"))
|
.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 {
|
function isHtmxRequest(event: Parameters<typeof getRequestHeader>[0]): boolean {
|
||||||
return getRequestHeader(event, "hx-request") === "true";
|
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) => {
|
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);
|
||||||
|
const kiosk = deps.repo.getKioskById(id);
|
||||||
deps.repo.updateKiosk(id, {
|
deps.repo.updateKiosk(id, {
|
||||||
name: body?.["name"],
|
name: body?.["name"],
|
||||||
enabled: body?.["enabled"] === "1",
|
enabled: body?.["enabled"] === "1",
|
||||||
} as any);
|
} 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}` } });
|
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 trim = (v: string | undefined) => (v ?? "").trim();
|
||||||
const cfg: Record<string, unknown> = {};
|
const cfg: Record<string, unknown> = {};
|
||||||
const hostname = trim(body?.["hostname"]);
|
const hostname = trim(body?.["hostname"]) || hostnameFromName(kiosk.name);
|
||||||
if (hostname) cfg["hostname"] = hostname;
|
if (hostname) cfg["hostname"] = hostname;
|
||||||
const timezone = trim(body?.["timezone"]);
|
const timezone = trim(body?.["timezone"]);
|
||||||
if (timezone) cfg["timezone"] = timezone;
|
if (timezone) cfg["timezone"] = timezone;
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,8 @@ function registerKioskRoutes(
|
||||||
disk_used_percent?: number | null;
|
disk_used_percent?: number | null;
|
||||||
local_key?: string | null;
|
local_key?: string | null;
|
||||||
local_port?: number | 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
|
// Managed-image kiosk echoes back the version it last applied, and the
|
||||||
// last apply error (if any). Server uses these to decide whether to
|
// last apply error (if any). Server uses these to decide whether to
|
||||||
// include pending_config in the response.
|
// include pending_config in the response.
|
||||||
|
|
@ -349,6 +351,10 @@ function registerKioskRoutes(
|
||||||
local_key: body?.local_key ?? null,
|
local_key: body?.local_key ?? null,
|
||||||
local_port: body?.local_port ?? null,
|
local_port: body?.local_port ?? null,
|
||||||
local_last_ip: remoteIp,
|
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
|
// Managed-config echo: kiosk reports the version it has successfully
|
||||||
|
|
@ -381,6 +387,8 @@ function registerKioskRoutes(
|
||||||
disk_free_mb: body?.disk_free_mb,
|
disk_free_mb: body?.disk_free_mb,
|
||||||
disk_used_percent: body?.disk_used_percent,
|
disk_used_percent: body?.disk_used_percent,
|
||||||
ip: remoteIp,
|
ip: remoteIp,
|
||||||
|
reported_hostname: body?.reported_hostname,
|
||||||
|
network_interfaces: body?.network_interfaces,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync displays reported by the kiosk
|
// Sync displays reported by the kiosk
|
||||||
|
|
@ -657,6 +665,19 @@ function registerKioskRoutes(
|
||||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||||
}
|
}
|
||||||
repo.recordKioskFirmwareAttempt(kiosk.id, body.version, body.error ?? null);
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -759,6 +780,19 @@ function registerKioskRoutes(
|
||||||
throw createError({ statusCode: 400, statusMessage: "version required" });
|
throw createError({ statusCode: 400, statusMessage: "version required" });
|
||||||
}
|
}
|
||||||
repo.recordKioskOsUpdateAttempt(kiosk.id, body.version, body.error ?? null);
|
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 };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,8 @@ export function rowToKiosk(r: Row): Kiosk {
|
||||||
local_key: sn(r["local_key"]),
|
local_key: sn(r["local_key"]),
|
||||||
local_port: nn(r["local_port"]),
|
local_port: nn(r["local_port"]),
|
||||||
local_last_ip: sn(r["local_last_ip"]),
|
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_image: b(r["managed_image"]),
|
||||||
managed_config_json: sn(r["managed_config_json"]),
|
managed_config_json: sn(r["managed_config_json"]),
|
||||||
managed_config_version: n(r["managed_config_version"] ?? 0),
|
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_key", "TEXT");
|
||||||
addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER");
|
addColumnIfNotExists(db, "kiosks", "local_port", "INTEGER");
|
||||||
addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT");
|
addColumnIfNotExists(db, "kiosks", "local_last_ip", "TEXT");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "reported_hostname", "TEXT");
|
||||||
|
addColumnIfNotExists(db, "kiosks", "network_interfaces_json", "TEXT");
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---- Audit log -----------------------------------------------------------
|
// ---- 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", "TEXT NOT NULL DEFAULT 'unknown'");
|
||||||
addColumnIfNotExists(db, "displays", "actual_power_state_at", "TEXT");
|
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_key?: string | null;
|
||||||
local_port?: number | null;
|
local_port?: number | null;
|
||||||
local_last_ip?: string | null;
|
local_last_ip?: string | null;
|
||||||
|
reported_hostname?: string | null;
|
||||||
|
network_interfaces_json?: string | null;
|
||||||
},
|
},
|
||||||
): void {
|
): void {
|
||||||
this.prep(
|
this.prep(
|
||||||
|
|
@ -1082,7 +1084,9 @@ export class Repository {
|
||||||
disk_used_percent = ?,
|
disk_used_percent = ?,
|
||||||
local_key = COALESCE(?, local_key),
|
local_key = COALESCE(?, local_key),
|
||||||
local_port = COALESCE(?, local_port),
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
).run(
|
).run(
|
||||||
isoNow(),
|
isoNow(),
|
||||||
|
|
@ -1101,6 +1105,8 @@ export class Repository {
|
||||||
patch.local_key ?? null,
|
patch.local_key ?? null,
|
||||||
patch.local_port ?? null,
|
patch.local_port ?? null,
|
||||||
patch.local_last_ip ?? null,
|
patch.local_last_ip ?? null,
|
||||||
|
patch.reported_hostname ?? null,
|
||||||
|
patch.network_interfaces_json ?? null,
|
||||||
id,
|
id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,17 @@ export const kioskHeartbeat = av.object(
|
||||||
active_layout_id: av.optional(av.int().min(1)),
|
active_layout_id: av.optional(av.int().min(1)),
|
||||||
streams_warm: av.optional(av.int().min(0)),
|
streams_warm: av.optional(av.int().min(0)),
|
||||||
streams_hot: 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" },
|
{ unknownKeys: "strip" },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export function generateBundle(
|
||||||
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
const bundleCameras: BundleCamera[] = cameras.map((cam) => {
|
||||||
const streams = repo.listCameraStreams(cam.id);
|
const streams = repo.listCameraStreams(cam.id);
|
||||||
const effectiveStreams = streams.length > 0 ? streams : (
|
const effectiveStreams = streams.length > 0 ? streams : (
|
||||||
cam.type === "rtsp" && cam.rtsp_url
|
cam.rtsp_url
|
||||||
? [{
|
? [{
|
||||||
id: 0,
|
id: 0,
|
||||||
role: "main" as const,
|
role: "main" as const,
|
||||||
|
|
|
||||||
|
|
@ -233,6 +233,8 @@ export interface Kiosk {
|
||||||
local_key: string | null;
|
local_key: string | null;
|
||||||
local_port: number | null;
|
local_port: number | null;
|
||||||
local_last_ip: string | 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
|
// 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.
|
// BYO-OS kiosks these fields stay at defaults and the admin UI hides them.
|
||||||
managed_image: boolean;
|
managed_image: boolean;
|
||||||
|
|
|
||||||
|
|
@ -3049,10 +3049,45 @@ export function KioskFirmwarePanel(props: KioskFirmwarePanelProps) {
|
||||||
|
|
||||||
interface KioskLocalPanelProps { kiosk: Kiosk }
|
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) {
|
export function KioskLocalPanel(props: KioskLocalPanelProps) {
|
||||||
const k = props.kiosk;
|
const k = props.kiosk;
|
||||||
if (!k.local_key || !k.local_port) return "";
|
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 base = `http://${ip}:${String(k.local_port)}`;
|
||||||
const sample = `${base}/local/layout/<layout_id>?key=${k.local_key}`;
|
const sample = `${base}/local/layout/<layout_id>?key=${k.local_key}`;
|
||||||
const proxy = `${base}/proxy/admin/...`;
|
const proxy = `${base}/proxy/admin/...`;
|
||||||
|
|
@ -3071,8 +3106,33 @@ export function KioskLocalPanel(props: KioskLocalPanelProps) {
|
||||||
<strong>Admin proxy (forwards your Bearer to server):</strong>
|
<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>
|
<pre style="background:#fafafa; padding:0.5rem; margin:0.25rem 0; font-size:0.75rem; white-space:pre-wrap">{proxy}</pre>
|
||||||
</div>
|
</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">
|
<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>
|
<code style="margin-left:0.25rem">{k.local_key.slice(0, 8)}…{k.local_key.slice(-4)}</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue