feat: expand BF_DATA on first boot + wire update progress banner + partition reporting

- Add betterframe-expand-data systemd service: growpart + resize2fs on
  BF_DATA (last partition) so it fills the full SD card on first boot.
  Solves the "No space left on device" issue with OS update downloads.
- Change OS update staging dir from /var/tmp/betterframe to
  /var/lib/betterframe/tmp (on BF_DATA partition, not rootfs).
- Wire firmware and OS update progress callbacks into the GTK overlay
  banner — shows "OS Update v1.2.3: Downloading — 45%" etc.
- Add per-partition disk reporting in heartbeat (/, /boot/firmware,
  /var/lib/betterframe) with total/used/free/percent.
- Display partition table on kiosk detail page in admin UI.
- PG + SQLite migrations for partitions_json column on kiosks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mitchell R 2026-05-26 08:09:20 +02:00
parent 2e8e783eed
commit 0c74e26e42
No known key found for this signature in database
14 changed files with 273 additions and 38 deletions

View file

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

View file

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

View file

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

View file

@ -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."

View file

@ -173,8 +173,14 @@ pub fn check(server: &str, key: &str, current_version: &str) -> Option<UpdateInf
/// Download + verify + swap. Reports outcome to the server. On success the
/// process exits with code 0 so systemd's Restart=always picks up the new
/// binary. On failure the function returns Err and the kiosk keeps running.
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!("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);

View file

@ -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<f32>,
pub cpu_load_percent: Option<f32>,
@ -27,6 +27,17 @@ pub struct HwInfo {
pub disk_total_mb: Option<u64>,
pub disk_free_mb: Option<u64>,
pub disk_used_percent: Option<f32>,
pub partitions: Vec<PartitionInfo>,
}
#[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<PathBuf> {
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<PartitionInfo> {
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::<u64>().ok()?;
let used_kb = cols[2].parse::<u64>().ok()?;
let free_kb = cols[3].parse::<u64>().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()
}

View file

@ -123,7 +123,12 @@ pub fn check(server: &str, key: &str) -> Option<UpdateInfo> {
///
/// 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

View file

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

View file

@ -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<WorkerMsg>) {
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<WorkerMsg>) {
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::<&gtk::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::<gtk::Overlay>() {
overlay.add_overlay(&label);
return;
}
let overlay = gtk::Overlay::new();
st.window.set_child(None::<&gtk::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::<gtk::Overlay>() {
overlay.remove_overlay(label);
}
}
}
*b.borrow_mut() = None;
});

View file

@ -485,6 +485,14 @@ function registerKioskRoutes(
local_port?: number | null;
reported_hostname?: string | null;
network_interfaces?: Array<Record<string, unknown>>;
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

View file

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

View file

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

View file

@ -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<void> {
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,
],
);

View file

@ -1840,6 +1840,43 @@ export function KioskEditPage(props: KioskEditProps) {
<div>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)}%)` : ""}</div>
<div>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
</div>
{(() => {
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 (
<div style="margin-top:0.5rem">
<div style="font-size:0.8rem; color:#999; margin-bottom:0.25rem">Partitions</div>
<table style="font-size:0.8rem; border-collapse:collapse; width:100%">
<thead><tr style="border-bottom:1px solid #eee; text-align:left">
<th style="padding:2px 8px">Mount</th>
<th style="padding:2px 8px">Device</th>
<th style="padding:2px 8px">Total</th>
<th style="padding:2px 8px">Used</th>
<th style="padding:2px 8px">Free</th>
<th style="padding:2px 8px">%</th>
</tr></thead>
<tbody>
{parts.map((p: any) => (
<tr style="border-bottom:1px solid #f5f5f5">
<td style="padding:2px 8px; font-family:monospace">{p.mountpoint}</td>
<td style="padding:2px 8px; font-family:monospace; color:#999">{p.device}</td>
<td style="padding:2px 8px">{String(p.total_mb)} MB</td>
<td style="padding:2px 8px">{String(p.used_mb)} MB</td>
<td style="padding:2px 8px">{String(p.free_mb)} MB</td>
<td style="padding:2px 8px">{typeof p.used_percent === "number" ? `${p.used_percent.toFixed(1)}%` : "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
})()}
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
<button
type="button"