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
+218 -27
View File
@@ -23,13 +23,14 @@ use crate::state::AppState;
use crate::terminal;
use axum::{
body::Body,
extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
extract::{ConnectInfo, DefaultBodyLimit, Multipart, Path as AxumPath, Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use openpxe_core::{ClientEvent, Settings};
use openpxe_core::{BootEvent, ClientEvent, Settings};
use std::net::SocketAddr;
use openpxe_ipxe_assets::asset_bytes;
use openpxe_iso_store::{IsoMeta, NfsAddRequest};
use serde::Deserialize;
@@ -91,6 +92,9 @@ pub fn build_router(state: AppState) -> Router {
// pins a MAC to a boot entry; /boot.ipxe?mac=... chains directly.
.route("/api/hosts", get(api_hosts_list).post(api_hosts_upsert))
.route("/api/hosts/:mac", delete(api_hosts_remove))
// v0.4.0: rolling "host log" of boot events — what image actually
// started installing on what MAC/IP, and when. Persisted to disk.
.route("/api/boot-log", get(api_boot_log))
// Phase 5: Prometheus scrape endpoint. Plain text exposition
// format. No auth — the metrics surface is intentionally
// boring (counts, no payloads).
@@ -173,7 +177,16 @@ fn text_plain(body: String) -> Response {
/// requesting client carries a `?mac=...` query param (iPXE's `${mac}`
/// substitution) and that MAC has a binding, we short-circuit straight
/// to the bound target instead of rendering the menu.
async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuParams>) -> Response {
async fn boot_top_menu(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
Query(p): Query<BootMenuParams>,
) -> Response {
// `ConnectInfo` is only populated when axum was started with
// `into_make_service_with_connect_info` (production path). Tests
// call the router via `oneshot`, which skips that wiring — we
// tolerate it by treating the peer as unknown rather than 500ing.
let peer_ip = peer.map(|c| c.0.ip());
state
.metrics
.record_http(openpxe_core::HttpRoute::BootScript);
@@ -192,14 +205,33 @@ async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuPa
mac = %binding.mac, target = %binding.target,
"host binding applied"
);
// Pre-record the host-binding event. Reserved menu shortcuts
// (`_local`, `_queue`, …) are operator-driven non-imaging
// targets — recording them would clutter the Host log with
// routine console activity, so we skip those and only record
// for real boot-entry ids.
if !binding.target.starts_with('_') {
let title = lookup_entry_title(&isos, &binding.target);
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: Some(binding.mac.clone()),
ip: peer_ip,
target_id: binding.target.clone(),
target_title: title,
});
}
let target = binding.target;
let bound_mac = binding.mac;
// Reserved menu shortcuts are emitted as `_xxx`; per-entry
// boot scripts are at `/boot/<id>.ipxe`. Both share the same
// `/boot/<name>` route, so the URL is identical.
// `/boot/<name>` route, so the URL is identical. We forward
// `?mac=` so the per-entry handler can record the boot into
// the Host log without depending on iPXE substitution at
// this stage.
return text_plain(format!(
"#!ipxe\n\
echo OpenPXE: per-MAC binding -> {target}\n\
chain {base}/boot/{target}.ipxe || chain {base}/boot.ipxe\n"
chain {base}/boot/{target}.ipxe?mac={bound_mac} || chain {base}/boot.ipxe\n"
));
}
}
@@ -207,6 +239,22 @@ async fn boot_top_menu(State(state): State<AppState>, Query(p): Query<BootMenuPa
text_plain(render_menu(&isos, &settings, base))
}
/// Best-effort human title for a boot entry id — falls back to the id
/// itself if the ISO has been deleted between record-time and now.
fn lookup_entry_title(isos: &[openpxe_iso_store::IsoMeta], target_id: &str) -> String {
for iso in isos {
for e in &iso.boot_entries {
if e.id == target_id {
// ISO filename plus the entry title gives the operator
// both "which image" and "which variant" (e.g. wimboot
// vs sanboot) at a glance.
return format!("{}{}", iso.filename, e.title);
}
}
}
target_id.to_string()
}
#[derive(Debug, Deserialize)]
struct BootMenuParams {
/// Client MAC, supplied by iPXE via `${mac}` variable in
@@ -222,13 +270,19 @@ struct BootSubParams {
/// encoding. Absent on the first request — that's how we know the
/// client hasn't been prompted yet.
token: Option<String>,
/// Client MAC, supplied by iPXE via `${mac}` in the chain URLs we
/// render. Optional — older bookmarks may omit it; the boot log
/// just records `None` in that case rather than refusing to boot.
mac: Option<String>,
}
async fn boot_sub(
State(state): State<AppState>,
peer: Option<ConnectInfo<SocketAddr>>,
AxumPath(filename): AxumPath<String>,
Query(p): Query<BootSubParams>,
) -> Response {
let peer_ip = peer.map(|c| c.0.ip());
// `/boot/<name>.ipxe` where `<name>` is either one of our reserved
// submenu names (prefixed `_`) or a boot entry id.
let name = filename.strip_suffix(".ipxe").unwrap_or(&filename);
@@ -300,6 +354,22 @@ async fn boot_sub(
}
}
}
// Record the boot event. This is the canonical
// moment: password gate (if any) passed, and the
// script is about to be served — i.e. the client
// is genuinely about to start imaging.
let mac_normalized = p
.mac
.as_deref()
.map(openpxe_core::normalize_mac)
.filter(|m| !m.is_empty());
state.boot_log.record(&BootEvent {
timestamp: time::OffsetDateTime::now_utc(),
mac: mac_normalized,
ip: peer_ip,
target_id: entry.id.clone(),
target_title: format!("{} — {}", iso.filename, entry.title),
});
return text_plain(render_entry(entry, &settings, base));
}
}
@@ -546,31 +616,146 @@ async fn api_clear_iso_password(
}
async fn api_upload_iso(State(state): State<AppState>, mut multipart: Multipart) -> Response {
while let Ok(Some(mut field)) = multipart.next_field().await {
if field.name() != Some("file") {
continue;
}
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
if !filename.to_ascii_lowercase().ends_with(".iso") {
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted").into_response();
}
let mut handle = match state.iso_store.begin_upload(&filename).await {
Ok(h) => h,
Err(e) => return (StatusCode::CONFLICT, format!("{e}")).into_response(),
};
while let Ok(Some(chunk)) = field.chunk().await {
if let Err(e) = handle.write_chunk(&chunk).await {
let _ = handle.abort().await;
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response();
// Walk multipart parts until we find the file. Each branch logs so an
// operator chasing a "stuck" upload in the Terminal tab can see
// exactly which stage failed (no field, wrong field name, parser
// error, mid-stream drop, sha mismatch on finish, etc.).
loop {
let field_res = multipart.next_field().await;
match field_res {
Ok(Some(mut field)) => {
if field.name() != Some("file") {
tracing::debug!(
target: "openpxe::http::upload",
field = field.name().unwrap_or("?"),
"skipping non-file multipart part"
);
continue;
}
let filename = field.file_name().unwrap_or("uploaded.iso").to_string();
if !filename.to_ascii_lowercase().ends_with(".iso") {
tracing::warn!(
target: "openpxe::http::upload",
filename = %filename, "rejecting non-.iso upload"
);
return (StatusCode::BAD_REQUEST, "only .iso uploads accepted")
.into_response();
}
tracing::info!(
target: "openpxe::http::upload",
filename = %filename, "upload started"
);
let mut handle = match state.iso_store.begin_upload(&filename).await {
Ok(h) => h,
Err(e) => {
tracing::warn!(
target: "openpxe::http::upload",
filename = %filename, error = %e,
"begin_upload rejected (likely duplicate name)"
);
return (StatusCode::CONFLICT, format!("{e}")).into_response();
}
};
// Streamed reader loop. We use an explicit `match` instead
// of `while let Ok(Some(_))` so a mid-stream `Err(_)` (a
// truncated body from a reverse proxy 524 / network drop)
// is treated as a failure rather than silently completing
// with a partial file.
let mut bytes: u64 = 0;
let mut next_log_at: u64 = 64 * 1024 * 1024;
loop {
match field.chunk().await {
Ok(Some(chunk)) => {
if let Err(e) = handle.write_chunk(&chunk).await {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, bytes,
error = %e, "write_chunk failed; aborting"
);
let _ = handle.abort().await;
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
.into_response();
}
bytes += chunk.len() as u64;
if bytes >= next_log_at {
tracing::info!(
target: "openpxe::http::upload",
filename = %filename,
received_bytes = bytes,
"upload streaming"
);
// Backoff log cadence: 64 MB, 128, 256, …
next_log_at = next_log_at.saturating_mul(2);
}
}
Ok(None) => break,
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, received_bytes = bytes,
error = %e,
"multipart stream ended with error (likely client \
disconnect or reverse-proxy buffer cap); aborting"
);
let _ = handle.abort().await;
return (
StatusCode::BAD_REQUEST,
format!(
"upload truncated after {bytes} bytes: {e}. \
If you went through a reverse proxy, try the \
LAN IP directly — large body buffering caps \
(Cloudflare free tier is 100 MB) commonly \
cause this."
),
)
.into_response();
}
}
}
tracing::info!(
target: "openpxe::http::upload",
filename = %filename, received_bytes = bytes,
"upload body complete; introspecting"
);
let meta = match handle.finish(&state.iso_store).await {
Ok(m) => m,
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
filename = %filename, error = %e,
"finish failed (rename/introspect)"
);
return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}"))
.into_response();
}
};
tracing::info!(
target: "openpxe::http::upload",
iso = %meta.id, size = meta.size_bytes,
family = ?meta.introspection.family,
entries = meta.boot_entries.len(),
"upload finished"
);
return (StatusCode::CREATED, Json(meta)).into_response();
}
Ok(None) => {
tracing::warn!(target: "openpxe::http::upload", "upload had no 'file' part");
return (StatusCode::BAD_REQUEST, "no 'file' part").into_response();
}
Err(e) => {
tracing::error!(
target: "openpxe::http::upload",
error = %e,
"multipart parser error before reading any field"
);
return (
StatusCode::BAD_REQUEST,
format!("multipart parse error: {e}"),
)
.into_response();
}
}
let meta = match handle.finish(&state.iso_store).await {
Ok(m) => m,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")).into_response(),
};
return (StatusCode::CREATED, Json(meta)).into_response();
}
(StatusCode::BAD_REQUEST, "no 'file' part").into_response()
}
// ─── health / readiness ───────────────────────────────────────────────────
@@ -988,6 +1173,12 @@ async fn api_hosts_remove(
}
}
// ─── Boot event log ───────────────────────────────────────────────────────
async fn api_boot_log(State(state): State<AppState>) -> Json<serde_json::Value> {
Json(json!({ "events": state.boot_log.list() }))
}
// ─── Prometheus metrics ───────────────────────────────────────────────────
async fn api_metrics(State(state): State<AppState>) -> Response {