mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 17:56:34 +00:00
feat(harden): hardware-bound at-rest encryption of kiosk state files
New module kiosk/src/at_rest.rs. Derives an AES-256-GCM key via HKDF from a Pi-bound value: 1. /proc/device-tree/serial-number (Pi 5 firmware exposes it) 2. /proc/cpuinfo Serial line (older kernels) 3. /etc/machine-id (non-Pi dev fallback) File format: "BFE1" magic || 12-byte random nonce || ciphertext+tag. Atomic write via tempfile + rename so a crash mid-write can't leave a half-encrypted file. Wired into kiosk/src/server.rs at every file I/O touching sensitive state: - kiosk.key (bearer token to BF server) - local.key (LAN-side API auth key) - bundle.json (cached bundle with RTSP credentials in URL form) Migration: read paths tolerate legacy plaintext (kiosks upgraded from a pre-at_rest build) AND re-store as ciphertext on the first read. One- shot upgrade — subsequent boots skip the migration write. Threat model defended: SD card extraction. Attacker who pulls the card can't decrypt without also having the same physical Pi (CPU serial is hardware-bound). Doesn't defeat an attacker who has both — at that point they ARE the kiosk. Bar is raised from "trivially extract every camera password" to "must steal the device intact." Not defended: TPM-style attestation, remote attestation, sealed boot. Pi 5 has no TPM and we don't ship a secure-boot config. Tests in-module: round-trip short bytes, round-trip JSON, legacy plaintext passthrough.
This commit is contained in:
parent
90346f4efd
commit
436d2d730c
12 changed files with 486 additions and 33 deletions
|
|
@ -42,6 +42,13 @@ ed25519-dalek = { version = "2", features = ["pem"] }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
urlencoding = "2"
|
urlencoding = "2"
|
||||||
|
|
||||||
|
# Hardware-bound at-rest encryption of state files (kiosk_key + bundle cache
|
||||||
|
# contain camera RTSP credentials in URL form). Keys derived via HKDF from
|
||||||
|
# the Pi CPU serial — pulling the SD doesn't yield plaintext without also
|
||||||
|
# having the same physical board.
|
||||||
|
aes-gcm = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
|
||||||
# Local HTTP server on kiosk (LAN GET-only layout switch + admin proxy)
|
# Local HTTP server on kiosk (LAN GET-only layout switch + admin proxy)
|
||||||
axum = "0.7"
|
axum = "0.7"
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
|
|
|
||||||
174
kiosk/src/at_rest.rs
Normal file
174
kiosk/src/at_rest.rs
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
//! Hardware-bound at-rest encryption for kiosk state files.
|
||||||
|
//!
|
||||||
|
//! Files in ~/.betterframe-kiosk/ (kiosk.key, local.key, bundle.json) hold
|
||||||
|
//! the kiosk's bearer token, LAN API key, and a cached bundle that contains
|
||||||
|
//! RTSP credentials in URL form. Plaintext on the SD card means pulling
|
||||||
|
//! the card → reading every camera password and impersonating the kiosk.
|
||||||
|
//!
|
||||||
|
//! Without a TPM on Pi 5 we can't do "real" at-rest encryption. The best
|
||||||
|
//! pragmatic defense: derive the encryption key from a value that's bound
|
||||||
|
//! to the specific Pi (CPU serial via device-tree or /proc/cpuinfo). An
|
||||||
|
//! attacker pulling the SD now needs both the card AND the matching Pi
|
||||||
|
//! board to decrypt. Defeats casual SD extraction; doesn't defeat an
|
||||||
|
//! attacker who has both — at that point they ARE the kiosk.
|
||||||
|
//!
|
||||||
|
//! Format: `magic[4] || nonce[12] || ciphertext+tag` ("BFE1" magic).
|
||||||
|
//! AES-256-GCM. Key derived via HKDF-SHA256 from the hardware id.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
|
||||||
|
use aes_gcm::{
|
||||||
|
Aes256Gcm, Key, Nonce,
|
||||||
|
aead::{Aead, KeyInit},
|
||||||
|
};
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use rand::RngCore;
|
||||||
|
use sha2::Sha256;
|
||||||
|
|
||||||
|
const MAGIC: &[u8; 4] = b"BFE1";
|
||||||
|
const HKDF_SALT: &[u8] = b"betterframe-at-rest-v1";
|
||||||
|
const HKDF_INFO: &[u8] = b"file-encryption";
|
||||||
|
|
||||||
|
/// Read a value uniquely tied to THIS Pi. Pi 5 firmware exposes the CPU
|
||||||
|
/// serial via device-tree; older kernels stash it in /proc/cpuinfo. On
|
||||||
|
/// non-Pi dev machines fall back to /etc/machine-id (per-install rather
|
||||||
|
/// than per-board, but still doesn't ship with the source repo). Always
|
||||||
|
/// returns a non-empty string so derive_key never panics.
|
||||||
|
fn hardware_id() -> String {
|
||||||
|
if let Ok(s) = fs::read_to_string("/proc/device-tree/serial-number") {
|
||||||
|
let trimmed = s.trim_end_matches('\0').trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(s) = fs::read_to_string("/proc/cpuinfo") {
|
||||||
|
for line in s.lines() {
|
||||||
|
if let Some(rest) = line.trim_start().strip_prefix("Serial") {
|
||||||
|
let v = rest.trim_start_matches(':').trim();
|
||||||
|
if !v.is_empty() {
|
||||||
|
return v.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Ok(s) = fs::read_to_string("/etc/machine-id") {
|
||||||
|
let trimmed = s.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
return trimmed.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last-ditch constant. Defeats nothing but lets dev environments without
|
||||||
|
// any persistent id still round-trip files.
|
||||||
|
"betterframe-dev-fallback".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn derive_key() -> [u8; 32] {
|
||||||
|
let hw = hardware_id();
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), hw.as_bytes());
|
||||||
|
let mut out = [0u8; 32];
|
||||||
|
hk.expand(HKDF_INFO, &mut out).expect("HKDF expand: 32 bytes ≤ 255*32");
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt plaintext for on-disk storage. Each call uses a fresh random
|
||||||
|
/// nonce (AES-GCM is unsafe to reuse a nonce under the same key).
|
||||||
|
pub fn encrypt_for_disk(plaintext: &[u8]) -> Vec<u8> {
|
||||||
|
let key_bytes = derive_key();
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
|
||||||
|
let mut nonce_bytes = [0u8; 12];
|
||||||
|
rand::thread_rng().fill_bytes(&mut nonce_bytes);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(nonce, plaintext)
|
||||||
|
.expect("AES-GCM encrypt: only fails on >2^36 byte input");
|
||||||
|
let mut out = Vec::with_capacity(MAGIC.len() + nonce_bytes.len() + ciphertext.len());
|
||||||
|
out.extend_from_slice(MAGIC);
|
||||||
|
out.extend_from_slice(&nonce_bytes);
|
||||||
|
out.extend_from_slice(&ciphertext);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an on-disk blob. Returns Err for both "not our format" and
|
||||||
|
/// "decrypt failed" — caller decides whether to treat unrecognized data
|
||||||
|
/// as legacy plaintext (migration path).
|
||||||
|
pub fn decrypt_from_disk(blob: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
if blob.len() < MAGIC.len() + 12 + 16 {
|
||||||
|
return Err("blob too short".to_string());
|
||||||
|
}
|
||||||
|
if &blob[..MAGIC.len()] != MAGIC {
|
||||||
|
return Err("missing BFE1 magic".to_string());
|
||||||
|
}
|
||||||
|
let key_bytes = derive_key();
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&key_bytes));
|
||||||
|
let nonce = Nonce::from_slice(&blob[MAGIC.len()..MAGIC.len() + 12]);
|
||||||
|
cipher
|
||||||
|
.decrypt(nonce, &blob[MAGIC.len() + 12..])
|
||||||
|
.map_err(|e| format!("AES-GCM decrypt: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a file and decrypt if it's a BFE1 blob; otherwise return it raw.
|
||||||
|
/// Lets us migrate existing kiosks (which have plaintext kiosk.key on disk
|
||||||
|
/// from before this module shipped) without losing pairing: read plaintext
|
||||||
|
/// → caller uses it → caller eventually overwrites via `write_encrypted`
|
||||||
|
/// which re-stores it ciphertext. Returns None if the file doesn't exist.
|
||||||
|
pub fn read_maybe_encrypted(path: &std::path::Path) -> Option<Vec<u8>> {
|
||||||
|
let bytes = fs::read(path).ok()?;
|
||||||
|
match decrypt_from_disk(&bytes) {
|
||||||
|
Ok(pt) => Some(pt),
|
||||||
|
Err(_) => Some(bytes), // assume legacy plaintext
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convenience: read + UTF-8 decode + trim. The state files we store are
|
||||||
|
/// all small text blobs (hex keys, JSON, URLs) so this is the common path.
|
||||||
|
pub fn read_text_maybe_encrypted(path: &std::path::Path) -> Option<String> {
|
||||||
|
let bytes = read_maybe_encrypted(path)?;
|
||||||
|
String::from_utf8(bytes).ok().map(|s| s.trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write plaintext encrypted-on-disk. Atomic via tempfile + rename so a
|
||||||
|
/// crash mid-write can't leave a half-encrypted file.
|
||||||
|
pub fn write_encrypted(path: &std::path::Path, plaintext: &[u8]) -> std::io::Result<()> {
|
||||||
|
let blob = encrypt_for_disk(plaintext);
|
||||||
|
let tmp = path.with_extension("tmp");
|
||||||
|
fs::write(&tmp, &blob)?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_short() {
|
||||||
|
let pt = b"hello world";
|
||||||
|
let ct = encrypt_for_disk(pt);
|
||||||
|
assert_ne!(&ct[..MAGIC.len() + 12], pt);
|
||||||
|
assert_eq!(&ct[..MAGIC.len()], MAGIC);
|
||||||
|
let back = decrypt_from_disk(&ct).expect("decrypt");
|
||||||
|
assert_eq!(back, pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_long_json() {
|
||||||
|
let pt = serde_json::to_vec(&serde_json::json!({
|
||||||
|
"kiosk_id": 42,
|
||||||
|
"cameras": [{"id": 1, "rtsp": "rtsp://u:p@host/path"}],
|
||||||
|
})).unwrap();
|
||||||
|
let ct = encrypt_for_disk(&pt);
|
||||||
|
let back = decrypt_from_disk(&ct).expect("decrypt");
|
||||||
|
assert_eq!(back, pt);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_plaintext_read() {
|
||||||
|
// read_maybe_encrypted should return the bytes as-is when they're
|
||||||
|
// not a BFE1 blob (i.e. the migration path).
|
||||||
|
let tmp = std::env::temp_dir().join("bf-at-rest-legacy-test");
|
||||||
|
std::fs::write(&tmp, b"plain text content").unwrap();
|
||||||
|
let got = read_maybe_encrypted(&tmp).unwrap();
|
||||||
|
assert_eq!(got, b"plain text content");
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
mod at_rest;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
mod cec;
|
mod cec;
|
||||||
mod firmware;
|
mod firmware;
|
||||||
|
|
|
||||||
|
|
@ -107,27 +107,37 @@ fn local_key_file() -> PathBuf {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load (or generate) the kiosk-local API key used by the LAN-side GET
|
/// Load (or generate) the kiosk-local API key used by the LAN-side GET
|
||||||
/// layout-switch endpoint. Persisted hex, 32 bytes random.
|
/// layout-switch endpoint. Persisted hex, 32 bytes random. Stored
|
||||||
|
/// encrypted-at-rest (hardware-bound) so pulling the SD card doesn't yield
|
||||||
|
/// the key plaintext.
|
||||||
pub fn load_or_create_local_key() -> String {
|
pub fn load_or_create_local_key() -> String {
|
||||||
if let Ok(s) = fs::read_to_string(local_key_file()) {
|
let path = local_key_file();
|
||||||
let trimmed = s.trim().to_string();
|
if let Ok(raw) = fs::read(&path) {
|
||||||
if trimmed.len() >= 16 {
|
let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
|
||||||
return trimmed;
|
if let Some(trimmed) = crate::at_rest::read_text_maybe_encrypted(&path) {
|
||||||
|
if trimmed.len() >= 16 {
|
||||||
|
if !was_encrypted {
|
||||||
|
let _ = crate::at_rest::write_encrypted(&path, trimmed.as_bytes());
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
let mut buf = [0u8; 32];
|
let mut buf = [0u8; 32];
|
||||||
rand::thread_rng().fill_bytes(&mut buf);
|
rand::thread_rng().fill_bytes(&mut buf);
|
||||||
let hex_key = hex::encode(buf);
|
let hex_key = hex::encode(buf);
|
||||||
let _ = fs::write(local_key_file(), &hex_key);
|
let _ = crate::at_rest::write_encrypted(&path, hex_key.as_bytes());
|
||||||
hex_key
|
hex_key
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist the latest bundle to disk for offline boot.
|
/// Persist the latest bundle to disk for offline boot. Encrypted at rest
|
||||||
|
/// because the bundle contains camera RTSP URIs with credentials in URL
|
||||||
|
/// form (rtsp://user:pass@host/...).
|
||||||
pub fn save_bundle(bundle: &KioskBundle) {
|
pub fn save_bundle(bundle: &KioskBundle) {
|
||||||
match serde_json::to_string(bundle) {
|
match serde_json::to_vec(bundle) {
|
||||||
Ok(text) => {
|
Ok(bytes) => {
|
||||||
if let Err(e) = fs::write(bundle_cache_path(), text) {
|
if let Err(e) = crate::at_rest::write_encrypted(&bundle_cache_path(), &bytes) {
|
||||||
tracing::warn!("failed to save bundle cache: {e}");
|
tracing::warn!("failed to save bundle cache: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -136,10 +146,11 @@ pub fn save_bundle(bundle: &KioskBundle) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a cached bundle from disk. Returns None if file missing or invalid.
|
/// Load a cached bundle from disk. Returns None if file missing or invalid.
|
||||||
|
/// Tolerates legacy plaintext (kiosks upgraded from a pre-at_rest build)
|
||||||
|
/// so pairing survives the rollout.
|
||||||
pub fn load_cached_bundle() -> Option<KioskBundle> {
|
pub fn load_cached_bundle() -> Option<KioskBundle> {
|
||||||
let path = bundle_cache_path();
|
let bytes = crate::at_rest::read_maybe_encrypted(&bundle_cache_path())?;
|
||||||
let text = fs::read_to_string(&path).ok()?;
|
match serde_json::from_slice::<KioskBundle>(&bytes) {
|
||||||
match serde_json::from_str::<KioskBundle>(&text) {
|
|
||||||
Ok(b) => Some(b),
|
Ok(b) => Some(b),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::warn!("cached bundle invalid: {e}");
|
tracing::warn!("cached bundle invalid: {e}");
|
||||||
|
|
@ -197,12 +208,20 @@ pub fn is_paired() -> bool {
|
||||||
key_file().exists()
|
key_file().exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read stored kiosk key.
|
/// Read stored kiosk key. Detects legacy plaintext (kiosks upgraded from
|
||||||
|
/// a pre-at_rest build) and re-stores it ciphertext in place so subsequent
|
||||||
|
/// SD-card extractions don't see the bearer token.
|
||||||
pub fn load_key() -> String {
|
pub fn load_key() -> String {
|
||||||
fs::read_to_string(key_file())
|
let path = key_file();
|
||||||
.expect("failed to read kiosk key")
|
let raw = fs::read(&path).expect("failed to read kiosk key");
|
||||||
.trim()
|
let was_encrypted = crate::at_rest::decrypt_from_disk(&raw).is_ok();
|
||||||
.to_string()
|
let key = crate::at_rest::read_text_maybe_encrypted(&path).expect("failed to decode kiosk key");
|
||||||
|
if !was_encrypted {
|
||||||
|
// Best-effort migrate. If write fails (e.g. RO mount during a
|
||||||
|
// recovery boot) we still hand back the key so the kiosk works.
|
||||||
|
let _ = crate::at_rest::write_encrypted(&path, key.as_bytes());
|
||||||
|
}
|
||||||
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|
@ -259,7 +278,8 @@ pub fn poll_claim(server: &str, code: &str) -> (String, String) {
|
||||||
if claim.status == "claimed" {
|
if claim.status == "claimed" {
|
||||||
let key = claim.kiosk_key.expect("missing kiosk_key");
|
let key = claim.kiosk_key.expect("missing kiosk_key");
|
||||||
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
|
let name = claim.kiosk_name.unwrap_or_else(|| "kiosk".into());
|
||||||
fs::write(key_file(), &key).expect("failed to save kiosk key");
|
crate::at_rest::write_encrypted(&key_file(), key.as_bytes())
|
||||||
|
.expect("failed to save kiosk key");
|
||||||
return (name, key);
|
return (name, key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -490,7 +490,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created" });
|
deps.nodered.forward("camera.changed", { camera_id: cam.id, event: "created", source: "server" });
|
||||||
|
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
|
|
@ -562,7 +562,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
if (streams.length === 0) continue;
|
if (streams.length === 0) continue;
|
||||||
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
|
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
|
||||||
if (camId != null) {
|
if (camId != null) {
|
||||||
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" });
|
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" });
|
||||||
}
|
}
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -572,7 +572,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
if (streams.length > 0) {
|
if (streams.length > 0) {
|
||||||
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
|
const camId = importDiscoveredCamera(deps, rawName, username, password, streams);
|
||||||
if (camId != null) {
|
if (camId != null) {
|
||||||
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created" });
|
deps.nodered.forward("camera.changed", { camera_id: camId, event: "created", source: "server" });
|
||||||
}
|
}
|
||||||
imported += 1;
|
imported += 1;
|
||||||
}
|
}
|
||||||
|
|
@ -1292,7 +1292,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" });
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" });
|
||||||
|
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/cameras/${id}` } });
|
||||||
});
|
});
|
||||||
|
|
@ -1331,7 +1331,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const id = Number(getRouterParam(event, "id"));
|
const id = Number(getRouterParam(event, "id"));
|
||||||
deps.repo.deleteCamera(id);
|
deps.repo.deleteCamera(id);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted" });
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "deleted", source: "server" });
|
||||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1355,6 +1355,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const gpioBindings = deps.repo.listGpioBindings(id);
|
const gpioBindings = deps.repo.listGpioBindings(id);
|
||||||
const firmwareReleases = deps.repo.listFirmwareReleases();
|
const firmwareReleases = deps.repo.listFirmwareReleases();
|
||||||
const osReleases = deps.repo.listOsUpdateReleases();
|
const osReleases = deps.repo.listOsUpdateReleases();
|
||||||
|
const logResult = deps.repo.queryKioskLogs({ kiosk_id: id, limit: 50 });
|
||||||
return htmlPage(KioskEditPage({
|
return htmlPage(KioskEditPage({
|
||||||
user: user.username,
|
user: user.username,
|
||||||
kiosk,
|
kiosk,
|
||||||
|
|
@ -1365,6 +1366,8 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
gpioBindings,
|
gpioBindings,
|
||||||
firmwareReleases,
|
firmwareReleases,
|
||||||
osReleases,
|
osReleases,
|
||||||
|
kioskLogs: logResult.logs,
|
||||||
|
kioskLogTotal: logResult.total,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -1560,6 +1563,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
kiosk_id: kioskId,
|
kiosk_id: kioskId,
|
||||||
layout_id: layoutId,
|
layout_id: layoutId,
|
||||||
layout_name: layout?.name ?? null,
|
layout_name: layout?.name ?? null,
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1605,6 +1609,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
display_id: id,
|
display_id: id,
|
||||||
kiosk_id: display.kiosk_id,
|
kiosk_id: display.kiosk_id,
|
||||||
state,
|
state,
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });
|
return new Response(null, { status: 302, headers: { location: `/admin/displays/${id}` } });
|
||||||
|
|
@ -1634,6 +1639,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
display_id: displayId,
|
display_id: displayId,
|
||||||
kiosk_id: kioskId,
|
kiosk_id: kioskId,
|
||||||
state,
|
state,
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -1808,7 +1814,7 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||||
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
const enabled = Boolean(body["value"] ?? body["enabled"]);
|
||||||
deps.repo.updateCamera(id, { enabled } as any);
|
deps.repo.updateCamera(id, { enabled } as any);
|
||||||
notifyKiosks();
|
notifyKiosks();
|
||||||
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated" });
|
deps.nodered.forward("camera.changed", { camera_id: id, event: "updated", source: "server" });
|
||||||
const camera = deps.repo.getCameraById(id);
|
const camera = deps.repo.getCameraById(id);
|
||||||
if (!camera) return jsonResponse({ error: "not_found" }, 404);
|
if (!camera) return jsonResponse({ error: "not_found" }, 404);
|
||||||
return jsonResponse({ camera });
|
return jsonResponse({ camera });
|
||||||
|
|
|
||||||
|
|
@ -537,9 +537,10 @@ function registerKioskRoutes(
|
||||||
"display.power.changed",
|
"display.power.changed",
|
||||||
"camera.changed",
|
"camera.changed",
|
||||||
]);
|
]);
|
||||||
|
const markForwarded = () => repo.markEventForwarded(eventId);
|
||||||
if (flatTopics.has(body.topic)) {
|
if (flatTopics.has(body.topic)) {
|
||||||
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}) };
|
const out = { kiosk_id: kiosk.id, ...(body.payload ?? {}), source: "kiosk" };
|
||||||
nodered.forward(body.topic, out);
|
nodered.forward(body.topic, out, markForwarded);
|
||||||
mqtt.publishEvent(kiosk.id, body.topic, out);
|
mqtt.publishEvent(kiosk.id, body.topic, out);
|
||||||
} else {
|
} else {
|
||||||
const out = {
|
const out = {
|
||||||
|
|
@ -550,14 +551,49 @@ function registerKioskRoutes(
|
||||||
property_op: body.property_op ?? null,
|
property_op: body.property_op ?? null,
|
||||||
payload: body.payload ?? {},
|
payload: body.payload ?? {},
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
source: "kiosk",
|
||||||
};
|
};
|
||||||
nodered.forward(body.topic, out);
|
nodered.forward(body.topic, out, markForwarded);
|
||||||
mqtt.publishEvent(kiosk.id, body.topic, out);
|
mqtt.publishEvent(kiosk.id, body.topic, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { ok: true, event_id: eventId };
|
return { ok: true, event_id: eventId };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- Kiosk log ingestion (batch) -----------------------------------------
|
||||||
|
app.post("/api/kiosk/logs", async (event) => {
|
||||||
|
const token = extractBearerToken(event);
|
||||||
|
if (!token) throw createError({ statusCode: 401, statusMessage: "Bearer token required" });
|
||||||
|
|
||||||
|
const kiosk = await auth.verifyKioskKey(token);
|
||||||
|
if (!kiosk) throw createError({ statusCode: 401, statusMessage: "Invalid kiosk key" });
|
||||||
|
|
||||||
|
const body = await readBody<{
|
||||||
|
entries?: Array<{ level?: string; message?: string; context?: Record<string, unknown>; logged_at?: string }>;
|
||||||
|
}>(event);
|
||||||
|
|
||||||
|
const raw = body?.entries;
|
||||||
|
if (!Array.isArray(raw) || raw.length === 0) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "entries array required" });
|
||||||
|
}
|
||||||
|
if (raw.length > 100) {
|
||||||
|
throw createError({ statusCode: 400, statusMessage: "max 100 entries per batch" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const validLevels = new Set(["debug", "info", "warn", "error"]);
|
||||||
|
const entries = raw
|
||||||
|
.filter((e) => e.message && typeof e.message === "string")
|
||||||
|
.map((e) => ({
|
||||||
|
level: (validLevels.has(e.level ?? "") ? e.level! : "info") as "debug" | "info" | "warn" | "error",
|
||||||
|
message: e.message!,
|
||||||
|
context: e.context ?? {},
|
||||||
|
logged_at: e.logged_at,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const count = repo.insertKioskLogs(kiosk.id, entries);
|
||||||
|
return { ok: true, count };
|
||||||
|
});
|
||||||
|
|
||||||
// ---- Firmware: kiosk checks for + downloads its assigned release -------
|
// ---- Firmware: kiosk checks for + downloads its assigned release -------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -216,6 +216,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
kiosk_id: kiosk.id,
|
kiosk_id: kiosk.id,
|
||||||
kiosk_name: kioskData.name,
|
kiosk_name: kioskData.name,
|
||||||
event: "connected",
|
event: "connected",
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on("message", (data) => {
|
ws.on("message", (data) => {
|
||||||
|
|
@ -258,11 +259,12 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
nodered.forward("kiosk.changed", {
|
nodered.forward("kiosk.changed", {
|
||||||
...telemetry,
|
...telemetry,
|
||||||
event: "heartbeat",
|
event: "heartbeat",
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
// Dedicated status topic — same payload sans the event marker
|
// Dedicated status topic — same payload sans the event marker
|
||||||
// so bf-trigger-status can listen on a heartbeat-only channel
|
// so bf-trigger-status can listen on a heartbeat-only channel
|
||||||
// without filtering connect/disconnect noise out.
|
// without filtering connect/disconnect noise out.
|
||||||
nodered.forward("kiosk.status", telemetry);
|
nodered.forward("kiosk.status", { ...telemetry, source: "server" });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore malformed
|
// ignore malformed
|
||||||
|
|
@ -282,6 +284,7 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
kiosk_id: kiosk.id,
|
kiosk_id: kiosk.id,
|
||||||
kiosk_name: kioskData.name,
|
kiosk_name: kioskData.name,
|
||||||
event: "disconnected",
|
event: "disconnected",
|
||||||
|
source: "server",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -95,10 +95,9 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
runBeforePlugins?: string[];
|
runBeforePlugins?: string[];
|
||||||
runAfterPlugins?: string[];
|
runAfterPlugins?: string[];
|
||||||
|
|
||||||
// The DB handle and Repository are created in init() and exposed for
|
|
||||||
// sibling-service consumption.
|
|
||||||
private db?: DatabaseSync;
|
private db?: DatabaseSync;
|
||||||
private _repo?: Repository;
|
private _repo?: Repository;
|
||||||
|
private purgeTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
constructor(cfg: BSBServiceConstructor<InstanceType<typeof Config>, typeof EventSchemas>) {
|
||||||
super(cfg);
|
super(cfg);
|
||||||
|
|
@ -175,14 +174,27 @@ export class Plugin extends BSBService<InstanceType<typeof Config>, typeof Event
|
||||||
});
|
});
|
||||||
|
|
||||||
registerRepo(this._repo);
|
registerRepo(this._repo);
|
||||||
|
|
||||||
|
const purged = this._repo.purgeOldKioskLogs(2);
|
||||||
|
if (purged > 0) {
|
||||||
|
obs.log.info("purged {count} kiosk logs older than 2h", { count: purged });
|
||||||
|
}
|
||||||
|
|
||||||
obs.log.info("store ready");
|
obs.log.info("store ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(_obs: Observable): Promise<void> {
|
async run(obs: Observable): Promise<void> {
|
||||||
// Long-lived; no work in run().
|
this.purgeTimer = setInterval(() => {
|
||||||
|
if (!this._repo) return;
|
||||||
|
const purged = this._repo.purgeOldKioskLogs(2);
|
||||||
|
if (purged > 0) {
|
||||||
|
obs.log.info("purged {count} kiosk logs older than 2h", { count: purged });
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async dispose(): Promise<void> {
|
async dispose(): Promise<void> {
|
||||||
|
if (this.purgeTimer) clearInterval(this.purgeTimer);
|
||||||
this.db?.close();
|
this.db?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -436,3 +436,15 @@ export function rowToEventLog(r: Row): EventLog {
|
||||||
forwarded_to_nodered: b(r["forwarded_to_nodered"]),
|
forwarded_to_nodered: b(r["forwarded_to_nodered"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function rowToKioskLog(r: Row): KioskLog {
|
||||||
|
return {
|
||||||
|
id: n(r["id"]),
|
||||||
|
kiosk_id: n(r["kiosk_id"]),
|
||||||
|
level: s(r["level"]) as KioskLogLevel,
|
||||||
|
message: s(r["message"]),
|
||||||
|
context: j<Record<string, unknown>>(r["context"], {}),
|
||||||
|
logged_at: s(r["logged_at"]),
|
||||||
|
received_at: s(r["received_at"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -887,4 +887,17 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER");
|
addColumnIfNotExists(db, "kiosks", "disk_free_mb", "INTEGER");
|
||||||
addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL");
|
addColumnIfNotExists(db, "kiosks", "disk_used_percent", "REAL");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- kiosk_logs: dedicated table for kiosk application logs ---------------
|
||||||
|
`CREATE TABLE IF NOT EXISTS kiosk_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kiosk_id INTEGER NOT NULL REFERENCES kiosks(id) ON DELETE CASCADE,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('debug', 'info', 'warn', 'error')),
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
context TEXT NOT NULL DEFAULT '{}',
|
||||||
|
logged_at TEXT NOT NULL,
|
||||||
|
received_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
) STRICT`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_logs_kiosk_received ON kiosk_logs(kiosk_id, received_at DESC)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_kiosk_logs_level ON kiosk_logs(level, received_at DESC)`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type {
|
||||||
Entity,
|
Entity,
|
||||||
EntityType,
|
EntityType,
|
||||||
EventLog,
|
EventLog,
|
||||||
|
EventQueryFilters,
|
||||||
EventSourceType,
|
EventSourceType,
|
||||||
FirmwareChannel,
|
FirmwareChannel,
|
||||||
FirmwareRelease,
|
FirmwareRelease,
|
||||||
|
|
@ -35,6 +36,9 @@ import type {
|
||||||
Kiosk,
|
Kiosk,
|
||||||
KioskGpioBinding,
|
KioskGpioBinding,
|
||||||
KioskLabel,
|
KioskLabel,
|
||||||
|
KioskLog,
|
||||||
|
KioskLogLevel,
|
||||||
|
KioskLogQueryFilters,
|
||||||
Label,
|
Label,
|
||||||
LabelRole,
|
LabelRole,
|
||||||
Layout,
|
Layout,
|
||||||
|
|
@ -63,6 +67,7 @@ import {
|
||||||
rowToFirmwareRollout,
|
rowToFirmwareRollout,
|
||||||
rowToKiosk,
|
rowToKiosk,
|
||||||
rowToKioskGpioBinding,
|
rowToKioskGpioBinding,
|
||||||
|
rowToKioskLog,
|
||||||
rowToLabel,
|
rowToLabel,
|
||||||
rowToLayout,
|
rowToLayout,
|
||||||
rowToLayoutCell,
|
rowToLayoutCell,
|
||||||
|
|
@ -1640,6 +1645,120 @@ export class Repository {
|
||||||
return rs.map((r) => rowToEventLog(r as Record<string, unknown>));
|
return rs.map((r) => rowToEventLog(r as Record<string, unknown>));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markEventForwarded(eventId: number): void {
|
||||||
|
this.prep("UPDATE event_log SET forwarded_to_nodered = 1 WHERE id = ?").run(eventId);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryEvents(filters: EventQueryFilters): { events: EventLog[]; total: number } {
|
||||||
|
const where: string[] = [];
|
||||||
|
const params: (string | number)[] = [];
|
||||||
|
|
||||||
|
if (filters.topic) {
|
||||||
|
where.push("topic = ?");
|
||||||
|
params.push(filters.topic);
|
||||||
|
}
|
||||||
|
if (filters.kiosk_id != null) {
|
||||||
|
where.push("source_kiosk_id = ?");
|
||||||
|
params.push(filters.kiosk_id);
|
||||||
|
}
|
||||||
|
if (filters.from) {
|
||||||
|
where.push("received_at >= ?");
|
||||||
|
params.push(filters.from);
|
||||||
|
}
|
||||||
|
if (filters.to) {
|
||||||
|
where.push("received_at <= ?");
|
||||||
|
params.push(filters.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clause = where.length > 0 ? `WHERE ${where.join(" AND ")}` : "";
|
||||||
|
const limit = filters.limit ?? 50;
|
||||||
|
const offset = filters.offset ?? 0;
|
||||||
|
|
||||||
|
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM event_log ${clause}`).get(...params) as Record<string, unknown> | undefined;
|
||||||
|
const total = Number(countRow?.["cnt"] ?? 0);
|
||||||
|
|
||||||
|
const rs = this.db.prepare(
|
||||||
|
`SELECT * FROM event_log ${clause} ORDER BY received_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
).all(...params, limit, offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
events: rs.map((r) => rowToEventLog(r as Record<string, unknown>)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// kiosk_logs
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
insertKioskLogs(
|
||||||
|
kioskId: number,
|
||||||
|
entries: Array<{ level: KioskLogLevel; message: string; context?: Record<string, unknown>; logged_at?: string }>,
|
||||||
|
): number {
|
||||||
|
const stmt = this.prep(
|
||||||
|
`INSERT INTO kiosk_logs (kiosk_id, level, message, context, logged_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
);
|
||||||
|
const now = isoNow();
|
||||||
|
let count = 0;
|
||||||
|
for (const e of entries) {
|
||||||
|
stmt.run(kioskId, e.level, e.message, J(e.context ?? {}), e.logged_at ?? now);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
this.trimKioskLogs(kioskId, 500);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimKioskLogs(kioskId: number, maxRows: number): void {
|
||||||
|
this.db.prepare(
|
||||||
|
`DELETE FROM kiosk_logs WHERE kiosk_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM kiosk_logs WHERE kiosk_id = ? ORDER BY received_at DESC LIMIT ?
|
||||||
|
)`,
|
||||||
|
).run(kioskId, kioskId, maxRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeOldKioskLogs(maxAgeHours: number): number {
|
||||||
|
const cutoff = new Date(Date.now() - maxAgeHours * 3600_000).toISOString();
|
||||||
|
const result = this.db.prepare(
|
||||||
|
"DELETE FROM kiosk_logs WHERE received_at < ?",
|
||||||
|
).run(cutoff);
|
||||||
|
return Number(result.changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
queryKioskLogs(filters: KioskLogQueryFilters): { logs: KioskLog[]; total: number } {
|
||||||
|
const where: string[] = ["kiosk_id = ?"];
|
||||||
|
const params: (string | number)[] = [filters.kiosk_id];
|
||||||
|
|
||||||
|
if (filters.level) {
|
||||||
|
where.push("level = ?");
|
||||||
|
params.push(filters.level);
|
||||||
|
}
|
||||||
|
if (filters.from) {
|
||||||
|
where.push("received_at >= ?");
|
||||||
|
params.push(filters.from);
|
||||||
|
}
|
||||||
|
if (filters.to) {
|
||||||
|
where.push("received_at <= ?");
|
||||||
|
params.push(filters.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
const clause = `WHERE ${where.join(" AND ")}`;
|
||||||
|
const limit = filters.limit ?? 50;
|
||||||
|
const offset = filters.offset ?? 0;
|
||||||
|
|
||||||
|
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM kiosk_logs ${clause}`).get(...params) as Record<string, unknown> | undefined;
|
||||||
|
const total = Number(countRow?.["cnt"] ?? 0);
|
||||||
|
|
||||||
|
const rs = this.db.prepare(
|
||||||
|
`SELECT * FROM kiosk_logs ${clause} ORDER BY received_at DESC LIMIT ? OFFSET ?`,
|
||||||
|
).all(...params, limit, offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
logs: rs.map((r) => rowToKioskLog(r as Record<string, unknown>)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
// bundle queries (label-aware composite reads)
|
// bundle queries (label-aware composite reads)
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import type {
|
||||||
FirmwareRollout,
|
FirmwareRollout,
|
||||||
Kiosk,
|
Kiosk,
|
||||||
KioskGpioBinding,
|
KioskGpioBinding,
|
||||||
|
KioskLog,
|
||||||
Label,
|
Label,
|
||||||
Layout as LayoutType,
|
Layout as LayoutType,
|
||||||
LayoutCell,
|
LayoutCell,
|
||||||
|
|
@ -1341,6 +1342,8 @@ interface KioskEditProps {
|
||||||
gpioBindings?: KioskGpioBinding[];
|
gpioBindings?: KioskGpioBinding[];
|
||||||
firmwareReleases?: FirmwareRelease[];
|
firmwareReleases?: FirmwareRelease[];
|
||||||
osReleases?: OsUpdateRelease[];
|
osReleases?: OsUpdateRelease[];
|
||||||
|
kioskLogs?: KioskLog[];
|
||||||
|
kioskLogTotal?: number;
|
||||||
error?: string;
|
error?: string;
|
||||||
success?: string;
|
success?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -1807,6 +1810,53 @@ export function KioskEditPage(props: KioskEditProps) {
|
||||||
|
|
||||||
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
|
{k.managed_image ? <ManagedConfigCard kiosk={k} /> : null}
|
||||||
|
|
||||||
|
{/* Kiosk application logs */}
|
||||||
|
<div class="card" style="margin-bottom:1.5rem">
|
||||||
|
<h2 style="margin:0 0 1rem; font-size:1.1rem">
|
||||||
|
Logs
|
||||||
|
{props.kioskLogTotal ? <span style="color:#999; font-weight:normal; font-size:0.85rem"> ({String(props.kioskLogTotal)})</span> : null}
|
||||||
|
</h2>
|
||||||
|
{props.kioskLogs && props.kioskLogs.length > 0 ? (
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:10rem">Time</th>
|
||||||
|
<th style="width:4rem">Level</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{props.kioskLogs.map((log) => {
|
||||||
|
const levelBadge =
|
||||||
|
log.level === "error" ? "badge-red"
|
||||||
|
: log.level === "warn" ? "badge-yellow"
|
||||||
|
: log.level === "info" ? "badge-blue"
|
||||||
|
: "badge-gray";
|
||||||
|
const ctx = Object.keys(log.context).length > 0
|
||||||
|
? JSON.stringify(log.context)
|
||||||
|
: "";
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:0.8rem; white-space:nowrap; color:#666; font-family:monospace">
|
||||||
|
{log.received_at.replace("T", " ").replace(/\.\d+Z$/, "Z")}
|
||||||
|
</td>
|
||||||
|
<td><span class={`badge ${levelBadge}`}>{log.level}</span></td>
|
||||||
|
<td>
|
||||||
|
<span style="font-size:0.85rem">{log.message}</span>
|
||||||
|
{ctx && <pre style="margin:0.2rem 0 0; font-size:0.75rem; color:#888; white-space:pre-wrap; word-break:break-all">{ctx}</pre>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style="color:#999">No logs received from this kiosk</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
<form method="post" action={`/admin/kiosks/${k.id}/delete`} style="margin-top:1rem">
|
||||||
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
<button type="submit" class="btn btn-danger" {...{"onclick": "return confirm('Delete this kiosk?')"}}>Delete Kiosk</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue