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:
co-authored by
Claude Opus 4.7
parent
115ba779da
commit
ec171ede47
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user