diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b934a2a..e9354cb 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -223,6 +223,10 @@ jobs: deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ cp deploy/systemd/betterframe-firstboot.sh \ deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-expand-data.service \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ + cp deploy/systemd/betterframe-expand-data.sh \ + deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/ # CA cert is operator-supplied — generated locally via # scripts/gen-rauc-signing-keys.sh and committed at # deploy/rauc/ca-cert.pem. Without it the image installs but diff --git a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh index b3d4279..90acc1f 100755 --- a/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh +++ b/deploy/pi-gen/stage-betterframe-client/01-install-kiosk/01-run-chroot.sh @@ -22,6 +22,9 @@ for grp in video render input audio systemd-journal; do fi done +# --- Deps for first-boot partition expansion --- +apt-get -y install cloud-guest-utils e2fsprogs 2>/dev/null || true + # --- Binary --- install -d -o bfkiosk -g bfkiosk -m 755 /opt/betterframe/kiosk install -m 755 /tmp/bf-files/betterframe-kiosk /opt/betterframe/kiosk/betterframe-kiosk @@ -37,6 +40,10 @@ install -m 644 /tmp/bf-files/betterframe-rauc-mark-good.service \ /etc/systemd/system/betterframe-rauc-mark-good.service install -m 755 /tmp/bf-files/betterframe-rauc-mark-good.sh \ /usr/local/sbin/betterframe-rauc-mark-good.sh +install -m 644 /tmp/bf-files/betterframe-expand-data.service \ + /etc/systemd/system/betterframe-expand-data.service +install -m 755 /tmp/bf-files/betterframe-expand-data.sh \ + /usr/local/sbin/betterframe-expand-data.sh install -d -m 755 /etc/tmpfiles.d install -m 644 /tmp/bf-files/betterframe-kiosk.conf /etc/tmpfiles.d/betterframe-kiosk.conf install -d -m 755 /etc/udev/rules.d @@ -165,6 +172,7 @@ systemctl enable systemd-timesyncd 2>/dev/null || true systemctl enable seatd systemctl enable betterframe-kiosk.service systemctl enable betterframe-rauc-mark-good.service +systemctl enable betterframe-expand-data.service systemctl enable rauc.service 2>/dev/null || true # Boot to multi-user, no display manager, no welcome wizard, no getty on tty1. diff --git a/deploy/systemd/betterframe-expand-data.service b/deploy/systemd/betterframe-expand-data.service new file mode 100644 index 0000000..115533e --- /dev/null +++ b/deploy/systemd/betterframe-expand-data.service @@ -0,0 +1,14 @@ +[Unit] +Description=Expand BF_DATA partition to fill SD card +DefaultDependencies=no +After=local-fs.target +Before=betterframe-kiosk.service betterframe-firstboot.service +ConditionPathExists=!/var/lib/betterframe/.data-expanded + +[Service] +Type=oneshot +ExecStart=/usr/local/sbin/betterframe-expand-data.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/betterframe-expand-data.sh b/deploy/systemd/betterframe-expand-data.sh new file mode 100644 index 0000000..40348a2 --- /dev/null +++ b/deploy/systemd/betterframe-expand-data.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Expand BF_DATA (last partition) to fill the SD card on first boot. +# BF_DATA is mounted at /var/lib/betterframe. On a fresh image it's ~512MB; +# after expansion it fills whatever SD card size the operator used. +# +# Safe to re-run: growpart is idempotent (exits 0 "NOCHANGE" if already grown), +# and the marker file prevents systemd from re-invoking after success. +set -euo pipefail + +MARKER="/var/lib/betterframe/.data-expanded" +MOUNTPOINT="/var/lib/betterframe" + +if [ -f "$MARKER" ]; then + echo "BF_DATA already expanded, skipping." + exit 0 +fi + +# Find the block device backing BF_DATA from the mount table. +DATA_DEV="$(findmnt -n -o SOURCE "$MOUNTPOINT" 2>/dev/null || true)" +if [ -z "$DATA_DEV" ]; then + echo "WARNING: $MOUNTPOINT not mounted, cannot expand. Skipping." + exit 0 +fi + +# Extract disk + partition number (e.g. /dev/mmcblk0p5 → /dev/mmcblk0 + 5). +if [[ "$DATA_DEV" =~ ^(/dev/mmcblk[0-9]+)p([0-9]+)$ ]]; then + DISK="${BASH_REMATCH[1]}" + PARTNUM="${BASH_REMATCH[2]}" +elif [[ "$DATA_DEV" =~ ^(/dev/sd[a-z]+)([0-9]+)$ ]]; then + DISK="${BASH_REMATCH[1]}" + PARTNUM="${BASH_REMATCH[2]}" +else + echo "WARNING: cannot parse device $DATA_DEV, skipping expansion." + exit 0 +fi + +echo "Expanding partition ${DISK}p${PARTNUM} (BF_DATA) to fill disk..." + +# growpart expands the partition to use all trailing free space. +# Exit code 0 = grown, 1 = error, NOCHANGE printed if already at max. +if ! growpart "$DISK" "$PARTNUM"; then + echo "growpart returned non-zero (may already be at max size)." +fi + +# Inform the kernel of the new partition size. +partprobe "$DISK" 2>/dev/null || true + +# Resize the ext4 filesystem to fill the expanded partition. +resize2fs "$DATA_DEV" + +# Drop marker so this doesn't run again. +touch "$MARKER" + +echo "BF_DATA expanded successfully." diff --git a/kiosk/src/firmware.rs b/kiosk/src/firmware.rs index 3f03d06..4a0009c 100644 --- a/kiosk/src/firmware.rs +++ b/kiosk/src/firmware.rs @@ -173,8 +173,14 @@ pub fn check(server: &str, key: &str, current_version: &str) -> Option Result<(), String> { +pub fn apply( + server: &str, + key: &str, + info: &UpdateInfo, + on_progress: impl Fn(&str, u8), +) -> Result<(), String> { info!("firmware: applying {} ({} bytes)", info.version, info.size_bytes); + on_progress("Downloading", 0); // 1. Download let url = format!("{}{}", server, info.download_url); @@ -197,6 +203,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { )); } + on_progress("Verifying", 70); // 2. sha256 let mut hasher = Sha256::new(); hasher.update(&bytes); @@ -210,6 +217,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { verify_signature(&info.public_key_pem, &info.sha256, &info.signature) .map_err(|e| format!("signature verify: {e}"))?; + on_progress("Applying", 90); // 4. Atomic swap let bin = binary_path(); let new_path = bin.with_extension("new"); @@ -259,6 +267,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { .timeout(Duration::from_secs(5)) .send(); + on_progress("Restarting", 100); info!("firmware: swap complete → exiting for systemd to relaunch"); // systemd Restart=always picks up the new binary on next start. std::process::exit(0); diff --git a/kiosk/src/hwmon.rs b/kiosk/src/hwmon.rs index 601438d..e22bcf4 100644 --- a/kiosk/src/hwmon.rs +++ b/kiosk/src/hwmon.rs @@ -16,7 +16,7 @@ use std::process::Command; use std::time::Duration; use tracing::warn; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Serialize)] pub struct HwInfo { pub cpu_temp_c: Option, pub cpu_load_percent: Option, @@ -27,6 +27,17 @@ pub struct HwInfo { pub disk_total_mb: Option, pub disk_free_mb: Option, pub disk_used_percent: Option, + pub partitions: Vec, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct PartitionInfo { + pub device: String, + pub mountpoint: String, + pub total_mb: u64, + pub used_mb: u64, + pub free_mb: u64, + pub used_percent: f32, } pub fn read() -> HwInfo { @@ -42,6 +53,7 @@ pub fn read() -> HwInfo { disk_total_mb: disk.map(|d| d.0), disk_free_mb: disk.map(|d| d.1), disk_used_percent: disk.map(|d| d.2), + partitions: read_partitions(), } } @@ -169,10 +181,53 @@ fn find_fan_hwmon() -> Option { let entries = fs::read_dir("/sys/class/hwmon").ok()?; for entry in entries.flatten() { let path = entry.path(); - // Look for hwmon dirs that have pwm1 (the fan controller) if path.join("pwm1").exists() { return Some(path); } } None } + +fn read_partitions() -> Vec { + let out = match Command::new("df").args(["-kP"]).output() { + Ok(o) if o.status.success() => o, + _ => return Vec::new(), + }; + let text = match String::from_utf8(out.stdout) { + Ok(t) => t, + Err(_) => return Vec::new(), + }; + let interesting = ["/", "/boot/firmware", "/var/lib/betterframe"]; + text.lines() + .skip(1) + .filter_map(|line| { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 6 { + return None; + } + let mountpoint = cols[5]; + if !interesting.contains(&mountpoint) { + return None; + } + let total_kb = cols[1].parse::().ok()?; + let used_kb = cols[2].parse::().ok()?; + let free_kb = cols[3].parse::().ok()?; + let total_mb = total_kb / 1024; + let used_mb = used_kb / 1024; + let free_mb = free_kb / 1024; + let used_percent = if total_mb == 0 { + 0.0 + } else { + (used_mb as f32 / total_mb as f32) * 100.0 + }; + Some(PartitionInfo { + device: cols[0].to_string(), + mountpoint: mountpoint.to_string(), + total_mb, + used_mb, + free_mb, + used_percent, + }) + }) + .collect() +} diff --git a/kiosk/src/os_update.rs b/kiosk/src/os_update.rs index 217a09a..e68f286 100644 --- a/kiosk/src/os_update.rs +++ b/kiosk/src/os_update.rs @@ -123,7 +123,12 @@ pub fn check(server: &str, key: &str) -> Option { /// /// On success: reboots the system (does not return). On failure: posts the /// error to /api/kiosk/os/applied and returns Err so the caller logs it. -pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { +pub fn apply( + server: &str, + key: &str, + info: &UpdateInfo, + on_progress: impl Fn(&str, u8), +) -> Result<(), String> { info!( "os-update: applying {} ({} bytes, release {})", info.version, info.size_bytes, info.release_id @@ -134,7 +139,8 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { // resumes from where it left off using Range header. Retries up to // 5 times with 10s backoff between attempts. let url = format!("{}{}", server, info.download_url); - let staging_dir = PathBuf::from("/var/tmp/betterframe"); + on_progress("Preparing", 0); + let staging_dir = PathBuf::from("/var/lib/betterframe/tmp"); fs::create_dir_all(&staging_dir).map_err(|e| format!("mkdir staging: {e}"))?; let bundle_path = staging_dir.join(format!("os-{}.raucb", info.release_id)); @@ -198,11 +204,10 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { Ok(n) => { file.write_all(&buf[..n]).map_err(|e| format!("write chunk: {e}"))?; downloaded += n as u64; - // Log progress every ~50MB + let pct = ((downloaded as f64 / info.size_bytes as f64) * 90.0) as u8; + on_progress("Downloading", pct); if downloaded % (50 * 1024 * 1024) < (256 * 1024) as u64 { - info!("os-update: {downloaded} / {} bytes ({:.0}%)", - info.size_bytes, - (downloaded as f64 / info.size_bytes as f64) * 100.0); + info!("os-update: {downloaded} / {} bytes ({pct}%)", info.size_bytes); } } Err(e) => { @@ -226,6 +231,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { } } + on_progress("Verifying", 90); // 2. sha256 verify the complete file on disk. let file_size = fs::metadata(&bundle_path) .map(|m| m.len()) @@ -256,6 +262,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { return Err(format!("sha256 mismatch: expected {}, got {got_sha}", info.sha256)); } + on_progress("Installing", 95); // 4. Hand off to rauc. `rauc install` blocks until the bundle is fully // copied into the inactive slot and bootloader is flipped. Exit code 0 // = success; anything else = leave current slot booted, no reboot. @@ -279,6 +286,7 @@ pub fn apply(server: &str, key: &str, info: &UpdateInfo) -> Result<(), String> { // admin UI shows progress immediately. let _ = report_applied(server, key, &info.version, None); + on_progress("Rebooting", 100); info!("os-update: rauc install OK → rebooting into the new slot"); // RAUC's custom bootloader backend has already armed tryboot for the // freshly-written slot. Reboot picks it up. On failure to reach the diff --git a/kiosk/src/server.rs b/kiosk/src/server.rs index e7758ee..758546a 100644 --- a/kiosk/src/server.rs +++ b/kiosk/src/server.rs @@ -513,6 +513,7 @@ pub fn heartbeat( "reported_hostname": hostname, "network_interfaces": network_interfaces, "onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(), + "partitions": serde_json::to_value(&hw.partitions).unwrap_or_default(), })) .timeout(Duration::from_secs(5)) .send() diff --git a/kiosk/src/ui.rs b/kiosk/src/ui.rs index 2f2d1c1..f9ef4f5 100644 --- a/kiosk/src/ui.rs +++ b/kiosk/src/ui.rs @@ -277,10 +277,10 @@ fn activate(app: &Application) { }); } ServerMsg::FirmwareCheck => { - maybe_apply_firmware_update(&server_for_reload, &key_for_reload); + maybe_apply_firmware_update(&server_for_reload, &key_for_reload, &tx_for_reload); } ServerMsg::OsCheck => { - maybe_apply_os_update(&server_for_reload, &key_for_reload); + maybe_apply_os_update(&server_for_reload, &key_for_reload, &tx_for_reload); } ServerMsg::ShowTerminalCode(code) => { let _ = tx_for_reload.send(WorkerMsg::ShowTerminalCode(code)); @@ -298,25 +298,18 @@ fn activate(app: &Application) { // Reset terminal auth boot-attempt counter (lockout_count persists). remote_debug::reset_boot_attempts(); + let tx_progress = tx.clone(); let mut first_iter = true; loop { let heartbeat_ok = send_heartbeat_now(&server, &key); if first_iter && heartbeat_ok { - // Successfully heart-beat at least once → consider this boot a - // healthy one. Clears the rollback-pending marker so the next - // start doesn't try to roll back a healthy install, AND tells - // RAUC the current slot is good so its boot-attempts counter - // resets (otherwise three bad boots auto-roll back). firmware::mark_firmware_applied(); mark_kiosk_healthy(); mark_rauc_slot_good(); first_iter = false; } - // OS bundle first — if it succeeds it reboots and we never reach - // the firmware check below this iteration. Order matters: an OS - // bundle update can ship an app-binary change anyway. - maybe_apply_os_update(&server, &key); - maybe_apply_firmware_update(&server, &key); + maybe_apply_os_update(&server, &key, &tx_progress); + maybe_apply_firmware_update(&server, &key, &tx_progress); std::thread::sleep(std::time::Duration::from_secs(60)); } }); @@ -506,7 +499,7 @@ fn mark_rauc_slot_good() { /// kiosk. On hit, download + sha256 + `rauc install` + reboot. On miss or /// error: log + keep running. Gated by BF_ENABLE_OS_OTA=1 (default OFF /// for dev kiosks running a non-A/B image). -fn maybe_apply_os_update(server_url: &str, kiosk_key: &str) { +fn maybe_apply_os_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender) { if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") { return; } @@ -526,7 +519,14 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str) { "size_bytes": info.size_bytes, }), ); - if let Err(err) = os_update::apply(server_url, kiosk_key, &info) { + let version = info.version.clone(); + let tx_cb = tx.clone(); + let result = os_update::apply(server_url, kiosk_key, &info, move |phase, pct| { + let label = format!("OS Update {version}: {phase}"); + let _ = tx_cb.send(WorkerMsg::UpdateProgress(Some((label, pct)))); + }); + if let Err(err) = result { + let _ = tx.send(WorkerMsg::UpdateProgress(None)); warn!("os-update: apply failed: {err}"); server::report_kiosk_log( server_url, @@ -540,13 +540,12 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str) { }), ); } - // Success path doesn't return — apply() reboots the system. } /// Ask the server whether an update is available. On hit, download + verify /// + swap + report + exit (systemd brings up the new binary). On miss or /// error: log + keep running. Designed to be safe to call from any thread. -fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { +fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str, tx: &mpsc::Sender) { if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") { return; } @@ -567,14 +566,21 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) { "release_id": &info.release_id, }), ); - if let Err(err) = firmware::apply(server_url, kiosk_key, &info) { + let version = info.version.clone(); + let tx_cb = tx.clone(); + let result = firmware::apply(server_url, kiosk_key, &info, move |phase, pct| { + let label = format!("App Update {version}: {phase}"); + let _ = tx_cb.send(WorkerMsg::UpdateProgress(Some((label, pct)))); + }); + if let Err(err) = result { + let _ = tx.send(WorkerMsg::UpdateProgress(None)); warn!("firmware: apply failed: {err}"); server::report_kiosk_log( server_url, kiosk_key, "error", - "firmware update failed", - serde_json::json!({ + "firmware update failed", + serde_json::json!({ "target_version": &info.version, "release_id": &info.release_id, "error": &err, @@ -2274,22 +2280,36 @@ fn show_update_banner(progress: Option<(String, u8)>) { Some((text, pct)) => { let msg = format!("{text} — {pct}%"); UPDATE_BANNER_LABEL.with(|b| { - if let Some(label) = b.borrow().as_ref() { - label.set_text(&msg); - return; + let existing = b.borrow(); + if let Some(label) = existing.as_ref() { + if label.parent().is_some() { + label.set_text(&msg); + return; + } } - // Create new banner label + drop(existing); + let label = Label::new(Some(&msg)); add_css(&label, ".update-banner { font-size: 12px; color: #fff; background: rgba(0,0,0,0.75); padding: 6px 14px; border-radius: 4px; margin: 8px; }"); label.add_css_class("update-banner"); label.set_halign(gtk::Align::Start); label.set_valign(gtk::Align::End); - // Attach to pairing window (always exists) + DISPLAYS.with(|ds| { let ds = ds.borrow(); - if let Some((_, st)) = ds.iter().next() { - st.window.set_titlebar(None::<>k::Widget>); - // Use a simple approach: just show it + for (_, st) in ds.iter() { + if let Some(child) = st.window.child() { + if let Ok(overlay) = child.clone().downcast::() { + overlay.add_overlay(&label); + return; + } + let overlay = gtk::Overlay::new(); + st.window.set_child(None::<>k::Widget>); + overlay.set_child(Some(&child)); + overlay.add_overlay(&label); + st.window.set_child(Some(&overlay)); + return; + } } }); *b.borrow_mut() = Some(label); @@ -2298,7 +2318,11 @@ fn show_update_banner(progress: Option<(String, u8)>) { None => { UPDATE_BANNER_LABEL.with(|b| { if let Some(label) = b.borrow().as_ref() { - label.set_visible(false); + if let Some(parent) = label.parent() { + if let Some(overlay) = parent.downcast_ref::() { + overlay.remove_overlay(label); + } + } } *b.borrow_mut() = None; }); diff --git a/server/src/plugins/service-api-http/index.ts b/server/src/plugins/service-api-http/index.ts index 3de9ec4..778981e 100644 --- a/server/src/plugins/service-api-http/index.ts +++ b/server/src/plugins/service-api-http/index.ts @@ -485,6 +485,14 @@ function registerKioskRoutes( local_port?: number | null; reported_hostname?: string | null; network_interfaces?: Array>; + partitions?: Array<{ + device: string; + mountpoint: string; + total_mb: number; + used_mb: number; + free_mb: number; + used_percent: number; + }>; // 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. @@ -518,6 +526,9 @@ function registerKioskRoutes( network_interfaces_json: Array.isArray(body?.network_interfaces) ? JSON.stringify(body.network_interfaces) : null, + partitions_json: Array.isArray(body?.partitions) + ? JSON.stringify(body.partitions) + : null, }); // Managed-config echo: kiosk reports the version it has successfully diff --git a/server/src/shared/db/migrations-pg.ts b/server/src/shared/db/migrations-pg.ts index 67a53c3..05383e6 100644 --- a/server/src/shared/db/migrations-pg.ts +++ b/server/src/shared/db/migrations-pg.ts @@ -226,6 +226,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [ local_last_ip TEXT, reported_hostname TEXT, network_interfaces_json JSONB, + partitions_json JSONB, managed_image BOOLEAN NOT NULL DEFAULT false, managed_config_json JSONB, managed_config_version INTEGER NOT NULL DEFAULT 0, @@ -482,4 +483,6 @@ export const TENANT_MIGRATIONS: readonly string[] = [ UNIQUE(camera_id, topic) )`, `CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`, + + `ALTER TABLE kiosks ADD COLUMN IF NOT EXISTS partitions_json JSONB`, ]; diff --git a/server/src/shared/db/migrations.ts b/server/src/shared/db/migrations.ts index cf199bd..e15f09f 100644 --- a/server/src/shared/db/migrations.ts +++ b/server/src/shared/db/migrations.ts @@ -1098,4 +1098,8 @@ export const MIGRATIONS: readonly MigrationEntry[] = [ addColumnIfNotExists(db, "camera_streams", "rtsp_port", "INTEGER DEFAULT 554"); addColumnIfNotExists(db, "camera_streams", "rtsp_path", "TEXT"); }, + + (db: DatabaseSync) => { + addColumnIfNotExists(db, "kiosks", "partitions_json", "TEXT"); + }, ]; diff --git a/server/src/shared/db/repository.ts b/server/src/shared/db/repository.ts index 9bdb612..f7e4cbd 100644 --- a/server/src/shared/db/repository.ts +++ b/server/src/shared/db/repository.ts @@ -1393,6 +1393,7 @@ export class Repository { local_last_ip?: string | null; reported_hostname?: string | null; network_interfaces_json?: string | null; + partitions_json?: string | null; }, ): Promise { await this._run( @@ -1414,7 +1415,8 @@ export class Repository { local_port = COALESCE(?, local_port), local_last_ip = COALESCE(?, local_last_ip), reported_hostname = COALESCE(?, reported_hostname), - network_interfaces_json = COALESCE(?, network_interfaces_json) + network_interfaces_json = COALESCE(?, network_interfaces_json), + partitions_json = COALESCE(?, partitions_json) WHERE id = ?`, [ isoNow(), @@ -1435,6 +1437,7 @@ export class Repository { patch.local_last_ip ?? null, patch.reported_hostname ?? null, patch.network_interfaces_json ?? null, + patch.partitions_json ?? null, id, ], ); diff --git a/server/src/web-templates/admin-pages.tsx b/server/src/web-templates/admin-pages.tsx index 8c06400..6036a2c 100644 --- a/server/src/web-templates/admin-pages.tsx +++ b/server/src/web-templates/admin-pages.tsx @@ -1840,6 +1840,43 @@ export function KioskEditPage(props: KioskEditProps) {
Disk: {k.disk_free_mb != null && k.disk_total_mb != null ? `${String(k.disk_free_mb)} MB free / ${String(k.disk_total_mb)} MB` : "—"} {k.disk_used_percent != null ? `(${k.disk_used_percent.toFixed(1)}%)` : ""}
PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}
+ {(() => { + const parts = (() => { + const raw = (k as any).partitions_json; + if (!raw) return []; + if (Array.isArray(raw)) return raw; + if (typeof raw === "string") { try { return JSON.parse(raw); } catch { return []; } } + return []; + })(); + if (parts.length === 0) return null; + return ( +
+
Partitions
+ + + + + + + + + + + {parts.map((p: any) => ( + + + + + + + + + ))} + +
MountDeviceTotalUsedFree%
{p.mountpoint}{p.device}{String(p.total_mb)} MB{String(p.used_mb)} MB{String(p.free_mb)} MB{typeof p.used_percent === "number" ? `${p.used_percent.toFixed(1)}%` : "—"}
+
+ ); + })()}