mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
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:
parent
2e8e783eed
commit
0c74e26e42
14 changed files with 273 additions and 38 deletions
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -223,6 +223,10 @@ jobs:
|
||||||
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/
|
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/
|
||||||
cp deploy/systemd/betterframe-firstboot.sh \
|
cp deploy/systemd/betterframe-firstboot.sh \
|
||||||
deploy/pi-gen/stage-betterframe-client/01-install-kiosk/files/
|
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
|
# CA cert is operator-supplied — generated locally via
|
||||||
# scripts/gen-rauc-signing-keys.sh and committed at
|
# scripts/gen-rauc-signing-keys.sh and committed at
|
||||||
# deploy/rauc/ca-cert.pem. Without it the image installs but
|
# deploy/rauc/ca-cert.pem. Without it the image installs but
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ for grp in video render input audio systemd-journal; do
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# --- Deps for first-boot partition expansion ---
|
||||||
|
apt-get -y install cloud-guest-utils e2fsprogs 2>/dev/null || true
|
||||||
|
|
||||||
# --- Binary ---
|
# --- Binary ---
|
||||||
install -d -o bfkiosk -g bfkiosk -m 755 /opt/betterframe/kiosk
|
install -d -o bfkiosk -g bfkiosk -m 755 /opt/betterframe/kiosk
|
||||||
install -m 755 /tmp/bf-files/betterframe-kiosk /opt/betterframe/kiosk/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
|
/etc/systemd/system/betterframe-rauc-mark-good.service
|
||||||
install -m 755 /tmp/bf-files/betterframe-rauc-mark-good.sh \
|
install -m 755 /tmp/bf-files/betterframe-rauc-mark-good.sh \
|
||||||
/usr/local/sbin/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 -d -m 755 /etc/tmpfiles.d
|
||||||
install -m 644 /tmp/bf-files/betterframe-kiosk.conf /etc/tmpfiles.d/betterframe-kiosk.conf
|
install -m 644 /tmp/bf-files/betterframe-kiosk.conf /etc/tmpfiles.d/betterframe-kiosk.conf
|
||||||
install -d -m 755 /etc/udev/rules.d
|
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 seatd
|
||||||
systemctl enable betterframe-kiosk.service
|
systemctl enable betterframe-kiosk.service
|
||||||
systemctl enable betterframe-rauc-mark-good.service
|
systemctl enable betterframe-rauc-mark-good.service
|
||||||
|
systemctl enable betterframe-expand-data.service
|
||||||
systemctl enable rauc.service 2>/dev/null || true
|
systemctl enable rauc.service 2>/dev/null || true
|
||||||
|
|
||||||
# Boot to multi-user, no display manager, no welcome wizard, no getty on tty1.
|
# Boot to multi-user, no display manager, no welcome wizard, no getty on tty1.
|
||||||
|
|
|
||||||
14
deploy/systemd/betterframe-expand-data.service
Normal file
14
deploy/systemd/betterframe-expand-data.service
Normal 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
|
||||||
54
deploy/systemd/betterframe-expand-data.sh
Normal file
54
deploy/systemd/betterframe-expand-data.sh
Normal 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."
|
||||||
|
|
@ -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
|
/// 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
|
/// 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.
|
/// 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);
|
info!("firmware: applying {} ({} bytes)", info.version, info.size_bytes);
|
||||||
|
on_progress("Downloading", 0);
|
||||||
|
|
||||||
// 1. Download
|
// 1. Download
|
||||||
let url = format!("{}{}", server, info.download_url);
|
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
|
// 2. sha256
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(&bytes);
|
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)
|
verify_signature(&info.public_key_pem, &info.sha256, &info.signature)
|
||||||
.map_err(|e| format!("signature verify: {e}"))?;
|
.map_err(|e| format!("signature verify: {e}"))?;
|
||||||
|
|
||||||
|
on_progress("Applying", 90);
|
||||||
// 4. Atomic swap
|
// 4. Atomic swap
|
||||||
let bin = binary_path();
|
let bin = binary_path();
|
||||||
let new_path = bin.with_extension("new");
|
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))
|
.timeout(Duration::from_secs(5))
|
||||||
.send();
|
.send();
|
||||||
|
|
||||||
|
on_progress("Restarting", 100);
|
||||||
info!("firmware: swap complete → exiting for systemd to relaunch");
|
info!("firmware: swap complete → exiting for systemd to relaunch");
|
||||||
// systemd Restart=always picks up the new binary on next start.
|
// systemd Restart=always picks up the new binary on next start.
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ use std::process::Command;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default, serde::Serialize)]
|
||||||
pub struct HwInfo {
|
pub struct HwInfo {
|
||||||
pub cpu_temp_c: Option<f32>,
|
pub cpu_temp_c: Option<f32>,
|
||||||
pub cpu_load_percent: Option<f32>,
|
pub cpu_load_percent: Option<f32>,
|
||||||
|
|
@ -27,6 +27,17 @@ pub struct HwInfo {
|
||||||
pub disk_total_mb: Option<u64>,
|
pub disk_total_mb: Option<u64>,
|
||||||
pub disk_free_mb: Option<u64>,
|
pub disk_free_mb: Option<u64>,
|
||||||
pub disk_used_percent: Option<f32>,
|
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 {
|
pub fn read() -> HwInfo {
|
||||||
|
|
@ -42,6 +53,7 @@ pub fn read() -> HwInfo {
|
||||||
disk_total_mb: disk.map(|d| d.0),
|
disk_total_mb: disk.map(|d| d.0),
|
||||||
disk_free_mb: disk.map(|d| d.1),
|
disk_free_mb: disk.map(|d| d.1),
|
||||||
disk_used_percent: disk.map(|d| d.2),
|
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()?;
|
let entries = fs::read_dir("/sys/class/hwmon").ok()?;
|
||||||
for entry in entries.flatten() {
|
for entry in entries.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
// Look for hwmon dirs that have pwm1 (the fan controller)
|
|
||||||
if path.join("pwm1").exists() {
|
if path.join("pwm1").exists() {
|
||||||
return Some(path);
|
return Some(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
None
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// 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.
|
/// 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!(
|
info!(
|
||||||
"os-update: applying {} ({} bytes, release {})",
|
"os-update: applying {} ({} bytes, release {})",
|
||||||
info.version, info.size_bytes, info.release_id
|
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
|
// resumes from where it left off using Range header. Retries up to
|
||||||
// 5 times with 10s backoff between attempts.
|
// 5 times with 10s backoff between attempts.
|
||||||
let url = format!("{}{}", server, info.download_url);
|
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}"))?;
|
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));
|
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) => {
|
Ok(n) => {
|
||||||
file.write_all(&buf[..n]).map_err(|e| format!("write chunk: {e}"))?;
|
file.write_all(&buf[..n]).map_err(|e| format!("write chunk: {e}"))?;
|
||||||
downloaded += n as u64;
|
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 {
|
if downloaded % (50 * 1024 * 1024) < (256 * 1024) as u64 {
|
||||||
info!("os-update: {downloaded} / {} bytes ({:.0}%)",
|
info!("os-update: {downloaded} / {} bytes ({pct}%)", info.size_bytes);
|
||||||
info.size_bytes,
|
|
||||||
(downloaded as f64 / info.size_bytes as f64) * 100.0);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
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.
|
// 2. sha256 verify the complete file on disk.
|
||||||
let file_size = fs::metadata(&bundle_path)
|
let file_size = fs::metadata(&bundle_path)
|
||||||
.map(|m| m.len())
|
.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));
|
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
|
// 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
|
// copied into the inactive slot and bootloader is flipped. Exit code 0
|
||||||
// = success; anything else = leave current slot booted, no reboot.
|
// = 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.
|
// admin UI shows progress immediately.
|
||||||
let _ = report_applied(server, key, &info.version, None);
|
let _ = report_applied(server, key, &info.version, None);
|
||||||
|
|
||||||
|
on_progress("Rebooting", 100);
|
||||||
info!("os-update: rauc install OK → rebooting into the new slot");
|
info!("os-update: rauc install OK → rebooting into the new slot");
|
||||||
// RAUC's custom bootloader backend has already armed tryboot for the
|
// RAUC's custom bootloader backend has already armed tryboot for the
|
||||||
// freshly-written slot. Reboot picks it up. On failure to reach the
|
// freshly-written slot. Reboot picks it up. On failure to reach the
|
||||||
|
|
|
||||||
|
|
@ -513,6 +513,7 @@ pub fn heartbeat(
|
||||||
"reported_hostname": hostname,
|
"reported_hostname": hostname,
|
||||||
"network_interfaces": network_interfaces,
|
"network_interfaces": network_interfaces,
|
||||||
"onvif_subscriptions": serde_json::to_value(crate::onvif_events::get_statuses()).unwrap_or_default(),
|
"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))
|
.timeout(Duration::from_secs(5))
|
||||||
.send()
|
.send()
|
||||||
|
|
|
||||||
|
|
@ -277,10 +277,10 @@ fn activate(app: &Application) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
ServerMsg::FirmwareCheck => {
|
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 => {
|
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) => {
|
ServerMsg::ShowTerminalCode(code) => {
|
||||||
let _ = tx_for_reload.send(WorkerMsg::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).
|
// Reset terminal auth boot-attempt counter (lockout_count persists).
|
||||||
remote_debug::reset_boot_attempts();
|
remote_debug::reset_boot_attempts();
|
||||||
|
|
||||||
|
let tx_progress = tx.clone();
|
||||||
let mut first_iter = true;
|
let mut first_iter = true;
|
||||||
loop {
|
loop {
|
||||||
let heartbeat_ok = send_heartbeat_now(&server, &key);
|
let heartbeat_ok = send_heartbeat_now(&server, &key);
|
||||||
if first_iter && heartbeat_ok {
|
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();
|
firmware::mark_firmware_applied();
|
||||||
mark_kiosk_healthy();
|
mark_kiosk_healthy();
|
||||||
mark_rauc_slot_good();
|
mark_rauc_slot_good();
|
||||||
first_iter = false;
|
first_iter = false;
|
||||||
}
|
}
|
||||||
// OS bundle first — if it succeeds it reboots and we never reach
|
maybe_apply_os_update(&server, &key, &tx_progress);
|
||||||
// the firmware check below this iteration. Order matters: an OS
|
maybe_apply_firmware_update(&server, &key, &tx_progress);
|
||||||
// bundle update can ship an app-binary change anyway.
|
|
||||||
maybe_apply_os_update(&server, &key);
|
|
||||||
maybe_apply_firmware_update(&server, &key);
|
|
||||||
std::thread::sleep(std::time::Duration::from_secs(60));
|
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
|
/// kiosk. On hit, download + sha256 + `rauc install` + reboot. On miss or
|
||||||
/// error: log + keep running. Gated by BF_ENABLE_OS_OTA=1 (default OFF
|
/// error: log + keep running. Gated by BF_ENABLE_OS_OTA=1 (default OFF
|
||||||
/// for dev kiosks running a non-A/B image).
|
/// 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") {
|
if std::env::var("BF_ENABLE_OS_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -526,7 +519,14 @@ fn maybe_apply_os_update(server_url: &str, kiosk_key: &str) {
|
||||||
"size_bytes": info.size_bytes,
|
"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}");
|
warn!("os-update: apply failed: {err}");
|
||||||
server::report_kiosk_log(
|
server::report_kiosk_log(
|
||||||
server_url,
|
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
|
/// Ask the server whether an update is available. On hit, download + verify
|
||||||
/// + swap + report + exit (systemd brings up the new binary). On miss or
|
/// + 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.
|
/// 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") {
|
if std::env::var("BF_ENABLE_APP_OTA").as_deref() != Ok("1") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -567,7 +566,14 @@ fn maybe_apply_firmware_update(server_url: &str, kiosk_key: &str) {
|
||||||
"release_id": &info.release_id,
|
"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}");
|
warn!("firmware: apply failed: {err}");
|
||||||
server::report_kiosk_log(
|
server::report_kiosk_log(
|
||||||
server_url,
|
server_url,
|
||||||
|
|
@ -2274,22 +2280,36 @@ fn show_update_banner(progress: Option<(String, u8)>) {
|
||||||
Some((text, pct)) => {
|
Some((text, pct)) => {
|
||||||
let msg = format!("{text} — {pct}%");
|
let msg = format!("{text} — {pct}%");
|
||||||
UPDATE_BANNER_LABEL.with(|b| {
|
UPDATE_BANNER_LABEL.with(|b| {
|
||||||
if let Some(label) = b.borrow().as_ref() {
|
let existing = b.borrow();
|
||||||
|
if let Some(label) = existing.as_ref() {
|
||||||
|
if label.parent().is_some() {
|
||||||
label.set_text(&msg);
|
label.set_text(&msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Create new banner label
|
}
|
||||||
|
drop(existing);
|
||||||
|
|
||||||
let label = Label::new(Some(&msg));
|
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; }");
|
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.add_css_class("update-banner");
|
||||||
label.set_halign(gtk::Align::Start);
|
label.set_halign(gtk::Align::Start);
|
||||||
label.set_valign(gtk::Align::End);
|
label.set_valign(gtk::Align::End);
|
||||||
// Attach to pairing window (always exists)
|
|
||||||
DISPLAYS.with(|ds| {
|
DISPLAYS.with(|ds| {
|
||||||
let ds = ds.borrow();
|
let ds = ds.borrow();
|
||||||
if let Some((_, st)) = ds.iter().next() {
|
for (_, st) in ds.iter() {
|
||||||
st.window.set_titlebar(None::<>k::Widget>);
|
if let Some(child) = st.window.child() {
|
||||||
// Use a simple approach: just show it
|
if let Ok(overlay) = child.clone().downcast::<gtk::Overlay>() {
|
||||||
|
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);
|
*b.borrow_mut() = Some(label);
|
||||||
|
|
@ -2298,7 +2318,11 @@ fn show_update_banner(progress: Option<(String, u8)>) {
|
||||||
None => {
|
None => {
|
||||||
UPDATE_BANNER_LABEL.with(|b| {
|
UPDATE_BANNER_LABEL.with(|b| {
|
||||||
if let Some(label) = b.borrow().as_ref() {
|
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;
|
*b.borrow_mut() = None;
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -485,6 +485,14 @@ function registerKioskRoutes(
|
||||||
local_port?: number | null;
|
local_port?: number | null;
|
||||||
reported_hostname?: string | null;
|
reported_hostname?: string | null;
|
||||||
network_interfaces?: Array<Record<string, unknown>>;
|
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
|
// 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.
|
||||||
|
|
@ -518,6 +526,9 @@ function registerKioskRoutes(
|
||||||
network_interfaces_json: Array.isArray(body?.network_interfaces)
|
network_interfaces_json: Array.isArray(body?.network_interfaces)
|
||||||
? JSON.stringify(body.network_interfaces)
|
? JSON.stringify(body.network_interfaces)
|
||||||
: null,
|
: null,
|
||||||
|
partitions_json: Array.isArray(body?.partitions)
|
||||||
|
? JSON.stringify(body.partitions)
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Managed-config echo: kiosk reports the version it has successfully
|
// Managed-config echo: kiosk reports the version it has successfully
|
||||||
|
|
|
||||||
|
|
@ -226,6 +226,7 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
local_last_ip TEXT,
|
local_last_ip TEXT,
|
||||||
reported_hostname TEXT,
|
reported_hostname TEXT,
|
||||||
network_interfaces_json JSONB,
|
network_interfaces_json JSONB,
|
||||||
|
partitions_json JSONB,
|
||||||
managed_image BOOLEAN NOT NULL DEFAULT false,
|
managed_image BOOLEAN NOT NULL DEFAULT false,
|
||||||
managed_config_json JSONB,
|
managed_config_json JSONB,
|
||||||
managed_config_version INTEGER NOT NULL DEFAULT 0,
|
managed_config_version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
@ -482,4 +483,6 @@ export const TENANT_MIGRATIONS: readonly string[] = [
|
||||||
UNIQUE(camera_id, topic)
|
UNIQUE(camera_id, topic)
|
||||||
)`,
|
)`,
|
||||||
`CREATE INDEX IF NOT EXISTS idx_camera_event_subs_camera ON camera_event_subscriptions(camera_id)`,
|
`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`,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1098,4 +1098,8 @@ export const MIGRATIONS: readonly MigrationEntry[] = [
|
||||||
addColumnIfNotExists(db, "camera_streams", "rtsp_port", "INTEGER DEFAULT 554");
|
addColumnIfNotExists(db, "camera_streams", "rtsp_port", "INTEGER DEFAULT 554");
|
||||||
addColumnIfNotExists(db, "camera_streams", "rtsp_path", "TEXT");
|
addColumnIfNotExists(db, "camera_streams", "rtsp_path", "TEXT");
|
||||||
},
|
},
|
||||||
|
|
||||||
|
(db: DatabaseSync) => {
|
||||||
|
addColumnIfNotExists(db, "kiosks", "partitions_json", "TEXT");
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -1393,6 +1393,7 @@ export class Repository {
|
||||||
local_last_ip?: string | null;
|
local_last_ip?: string | null;
|
||||||
reported_hostname?: string | null;
|
reported_hostname?: string | null;
|
||||||
network_interfaces_json?: string | null;
|
network_interfaces_json?: string | null;
|
||||||
|
partitions_json?: string | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this._run(
|
await this._run(
|
||||||
|
|
@ -1414,7 +1415,8 @@ export class Repository {
|
||||||
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),
|
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 = ?`,
|
WHERE id = ?`,
|
||||||
[
|
[
|
||||||
isoNow(),
|
isoNow(),
|
||||||
|
|
@ -1435,6 +1437,7 @@ export class Repository {
|
||||||
patch.local_last_ip ?? null,
|
patch.local_last_ip ?? null,
|
||||||
patch.reported_hostname ?? null,
|
patch.reported_hostname ?? null,
|
||||||
patch.network_interfaces_json ?? null,
|
patch.network_interfaces_json ?? null,
|
||||||
|
patch.partitions_json ?? null,
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>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>PWM: {k.fan_pwm != null ? `${k.fan_pwm}/255` : "—"}</div>
|
||||||
</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">
|
<div style="display:flex; gap:0.5rem; flex-wrap:wrap">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue