v0.4.0: upload telemetry, host log, jet-black UI

- Upload reliability + diagnostics:
  - api_upload_iso now distinguishes clean EOF from mid-stream errors;
    a truncated multipart body (proxy buffer cap, network drop) returns
    400 with the cause and a "try the LAN IP" hint instead of silently
    finalising a partial file.
  - Per-stage tracing (begin/MB-watermark/finish/abort) so a stuck
    upload is debuggable from the Terminal tab.
  - Web upload UI surfaces bytes/total, percent, throughput, ETA, and
    maps 413/502/504/network-drop to actionable hints.
- New BootLog feature under Hosts:
  - openpxe-core::BootLog — bounded in-memory ring (500) + append-only
    JSONL on disk, recording (timestamp, mac, ip, target_id,
    target_title) every time a boot entry script is served.
  - iPXE per-entry chain URLs grow ?mac=${mac}; password prompt
    submission carries it through; host-binding short-circuit uses the
    bound MAC. ConnectInfo<SocketAddr> wired for peer IP capture (with
    optional fallback so tower::oneshot in tests still works).
  - GET /api/boot-log endpoint + Host log table under the Hosts tab.
- UI changes:
  - Queue card header "Forge" → "Status".
  - Removed Tinkerbell attribution sentence from Hosts tab.
  - Topbar readiness chip moved into the sidebar footer as
    "Service status: Ready / Advertised to clients / <url>", grouping
    advertised PXE URL with operator-relevant status.
  - Jet-black dark palette (#000 / #0a0a0a / #141414 / #1c1c1c)
    replacing the blue-tinted ramp; terminal toolbar/input recoloured
    to match.
- 89 tests passing (was 85 in v0.3.2); cargo clippy --workspace
  --all-targets clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Miles Ward
2026-05-24 13:10:40 -04:00
co-authored by Claude Opus 4.7
parent 115ba779da
commit ec171ede47
11 changed files with 767 additions and 78 deletions
+249
View File
@@ -0,0 +1,249 @@
//! Boot-event log — "who installed what, when, from where".
//!
//! Each `/boot/<entry>.ipxe` fetch that actually goes on to serve a boot
//! script lands an entry here. The log is bounded in memory (newest-first,
//! ring-buffered at [`BootLog::CAP`]) and is mirrored append-only to
//! `<work_dir>/boot_log.jsonl`. Mirrors `HostBindings`'s "in-memory is
//! authoritative, disk is a cache" policy — a corrupt log file should
//! never block PXE for the network.
//!
//! We deliberately don't push these onto the `LogBus` (the operator
//! terminal stream). The terminal already shows the http traces; the
//! Host log is a curated, persistent, easy-to-scan view of "what got
//! imaged on what hardware" and conflating the two would be noisy.
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::collections::VecDeque;
use std::io::Write;
use std::net::IpAddr;
use std::path::PathBuf;
use std::sync::Arc;
use time::OffsetDateTime;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BootEvent {
#[serde(with = "time::serde::rfc3339")]
pub timestamp: OffsetDateTime,
/// Lowercase, colon-separated. `None` when iPXE didn't supply
/// `?mac=${mac}` in the chain URL (older bookmarks, custom scripts).
pub mac: Option<String>,
/// Connecting peer's IP — taken from the TCP socket when available
/// (PXE clients connect direct, no reverse proxy), and falls back to
/// `X-Forwarded-For` for the rare case where one is present.
pub ip: Option<IpAddr>,
/// `BootEntry::id` — the same id used in `/boot/<id>.ipxe`.
pub target_id: String,
/// Human-friendly label: the ISO's filename / volume label / entry
/// title. Pre-resolved at log time so the UI can render without
/// joining against the ISO store (and so "what image was installed?"
/// survives the operator deleting the ISO later).
pub target_title: String,
}
/// In-memory ring + disk-backed append log of boot events. Cheap to
/// clone; the inner state is `Arc<RwLock<_>>`.
#[derive(Debug, Clone)]
pub struct BootLog {
path: Arc<PathBuf>,
inner: Arc<RwLock<VecDeque<BootEvent>>>,
}
impl BootLog {
/// Newest entries we retain in memory. Past this, the oldest gets
/// evicted — the on-disk JSONL keeps the full history for offline
/// inspection. 500 covers a typical install-day's worth without
/// turning the Hosts tab into a wall of text.
pub const CAP: usize = 500;
/// Load up to `CAP` newest events from `<work_dir>/boot_log.jsonl`,
/// or start empty if the file is missing / unreadable.
#[must_use]
pub fn load_or_default(work_dir: &std::path::Path) -> Self {
let path = work_dir.join("boot_log.jsonl");
let mut events = VecDeque::with_capacity(Self::CAP);
if let Ok(text) = std::fs::read_to_string(&path) {
for line in text.lines() {
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<BootEvent>(line) {
Ok(ev) => {
if events.len() == Self::CAP {
events.pop_front();
}
events.push_back(ev);
}
Err(e) => {
tracing::warn!(
target: "openpxe::boot_log",
"skipping unparseable boot_log line: {e}"
);
}
}
}
}
Self {
path: Arc::new(path),
inner: Arc::new(RwLock::new(events)),
}
}
/// Append an event. Persistence is best-effort and never blocks the
/// caller on a failed write (the in-memory copy is the source of
/// truth for the live UI; the JSONL is just for crash survival).
pub fn record(&self, ev: &BootEvent) {
// Push into the ring first so a slow / failing disk doesn't lose
// events for the live UI.
{
let mut g = self.inner.write();
if g.len() == Self::CAP {
g.pop_front();
}
g.push_back(ev.clone());
}
tracing::info!(
target: "openpxe::boot_log",
mac = ev.mac.as_deref().unwrap_or("?"),
ip = ev.ip.map(|i| i.to_string()).as_deref().unwrap_or("?"),
target = %ev.target_id,
"boot event"
);
// Append to disk. We tolerate write failures — they'd show up as
// missing entries on the next restart only.
let mut line = match serde_json::to_string(ev) {
Ok(s) => s,
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "serialize boot event: {e}");
return;
}
};
line.push('\n');
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.path.as_path())
{
Ok(mut f) => {
if let Err(e) = f.write_all(line.as_bytes()) {
tracing::warn!(target: "openpxe::boot_log", "append boot_log.jsonl: {e}");
}
}
Err(e) => {
tracing::warn!(target: "openpxe::boot_log", "open boot_log.jsonl: {e}");
}
}
}
/// Newest-first snapshot, up to `CAP` entries.
#[must_use]
pub fn list(&self) -> Vec<BootEvent> {
let g = self.inner.read();
// VecDeque preserves insertion order; reverse so newest is first.
g.iter().rev().cloned().collect()
}
#[must_use]
pub fn len(&self) -> usize {
self.inner.read().len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Wipe in-memory + the on-disk file. Used by the `terminal clear`
/// equivalent or future operator action; not currently wired to a UI
/// button but exposed for completeness.
pub fn clear(&self) {
self.inner.write().clear();
let _ = std::fs::remove_file(self.path.as_path());
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn ev(target: &str, mac: Option<&str>) -> BootEvent {
BootEvent {
timestamp: OffsetDateTime::now_utc(),
mac: mac.map(str::to_string),
ip: Some("10.0.0.42".parse().unwrap()),
target_id: target.into(),
target_title: format!("{target}.iso"),
}
}
#[test]
fn record_then_list_is_newest_first() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
assert!(log.is_empty());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", Some("aa:bb:cc:00:00:02")));
let list = log.list();
assert_eq!(list.len(), 2);
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
}
#[test]
fn round_trip_through_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", Some("aa:bb:cc:00:00:01")));
log.record(&ev("beta", None));
drop(log);
let log2 = BootLog::load_or_default(dir.path());
assert_eq!(log2.len(), 2);
let list = log2.list();
assert_eq!(list[0].target_id, "beta");
assert_eq!(list[1].target_id, "alpha");
assert!(list[0].mac.is_none());
assert_eq!(list[1].mac.as_deref(), Some("aa:bb:cc:00:00:01"));
}
#[test]
fn ring_evicts_oldest_past_cap() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
for i in 0..(BootLog::CAP + 5) {
log.record(&ev(&format!("e{i}"), None));
}
assert_eq!(log.len(), BootLog::CAP);
let list = log.list();
// Newest first; the most recent push is the last index inserted.
assert_eq!(list[0].target_id, format!("e{}", BootLog::CAP + 4));
// Oldest in-memory should be the 6th push (0..5 were evicted).
assert_eq!(list[BootLog::CAP - 1].target_id, "e5");
}
#[test]
fn clear_wipes_memory_and_disk() {
let dir = tempdir().unwrap();
let log = BootLog::load_or_default(dir.path());
log.record(&ev("alpha", None));
log.clear();
assert!(log.is_empty());
let log2 = BootLog::load_or_default(dir.path());
assert!(log2.is_empty());
}
#[test]
fn corrupt_disk_lines_are_skipped_not_fatal() {
// Write a file with one valid + one garbage line; loader should
// surface the valid one and skip the garbage.
let dir = tempdir().unwrap();
let path = dir.path().join("boot_log.jsonl");
let valid = serde_json::to_string(&ev("ok", Some("aa:bb:cc:00:00:09"))).unwrap();
std::fs::write(&path, format!("{valid}\nNOT_JSON\n{valid}\n")).unwrap();
let log = BootLog::load_or_default(dir.path());
assert_eq!(log.len(), 2);
}
}
+2
View File
@@ -3,6 +3,7 @@
#![forbid(unsafe_code)]
pub mod arch;
pub mod boot_log;
pub mod client;
pub mod config;
pub mod error;
@@ -13,6 +14,7 @@ pub mod queue;
pub mod settings;
pub use arch::{ClientArch, FirmwareClass};
pub use boot_log::{BootEvent, BootLog};
pub use client::{ClientEvent, ClientRegistry, ClientSnapshot};
pub use config::{Config, DhcpMode, NetworkConfig, Paths, ServerConfig};
pub use error::{Error, Result};