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
+218
-27
@@ -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 {
|
||||
|
||||
@@ -182,7 +182,14 @@ pub fn render_family_menu(isos: &[IsoMeta], base_url: &str, is_windows: bool) ->
|
||||
s,
|
||||
"iseq ${{target}} back && chain {base}/boot.ipxe || goto menu"
|
||||
);
|
||||
let _ = writeln!(s, "chain {base}/boot/${{target}}.ipxe || goto menu");
|
||||
// Pass `?mac=${mac}` so the per-entry handler can record the booting
|
||||
// client into the Host log (v0.4.0). iPXE substitutes `${mac}` before
|
||||
// the HTTP fetch; if the firmware can't resolve it the literal
|
||||
// `${mac}` is sent and the server treats it as "unknown".
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"chain {base}/boot/${{target}}.ipxe?mac=${{mac}} || goto menu"
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
@@ -461,9 +468,14 @@ pub fn render_password_prompt(entry_id: &str, iso_filename: &str, base_url: &str
|
||||
);
|
||||
let _ = writeln!(s, ":submit");
|
||||
let _ = writeln!(s, "echo Verifying...");
|
||||
// Carry `mac=${mac}` alongside the token so a successful unlock
|
||||
// records the actual client MAC into the Host log (v0.4.0). On
|
||||
// older iPXE that can't resolve `${mac}` the server just stores it
|
||||
// as "unknown" rather than refusing to boot.
|
||||
let _ = writeln!(
|
||||
s,
|
||||
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}} || chain {base}/boot.ipxe"
|
||||
"chain {base}/boot/{entry_id}.ipxe?token=${{password:uristring}}&mac=${{mac}} \
|
||||
|| chain {base}/boot.ipxe"
|
||||
);
|
||||
s
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use openpxe_core::{ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore};
|
||||
use openpxe_core::{
|
||||
BootLog, ClientRegistry, DeploymentQueue, HostBindings, LogBus, Metrics, SettingsStore,
|
||||
};
|
||||
use openpxe_iso_store::{IsoStore, NfsManager, SmbManager};
|
||||
use std::sync::Arc;
|
||||
use time::OffsetDateTime;
|
||||
@@ -13,6 +15,10 @@ pub struct AppState {
|
||||
/// these MACs requests `/boot.ipxe`, we chain straight to the
|
||||
/// configured target instead of rendering the menu.
|
||||
pub hosts: HostBindings,
|
||||
/// Persistent boot-event log surfaced under the Hosts tab. Records
|
||||
/// every `/boot/<entry>.ipxe` chain that goes on to serve a script
|
||||
/// (i.e. an image actually starting to install on a machine).
|
||||
pub boot_log: BootLog,
|
||||
/// Lock-free metrics counters surfaced at `/metrics` in Prometheus
|
||||
/// text format. Cheap to clone (handles to atomics).
|
||||
pub metrics: Metrics,
|
||||
|
||||
Reference in New Issue
Block a user