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
@@ -98,6 +98,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
iso_store.set_nfs_root(nfs.mount_root());
|
||||
let log_bus = LogBus::new(64);
|
||||
let hosts = HostBindings::load_or_default(dir.path());
|
||||
let boot_log = openpxe_core::BootLog::load_or_default(dir.path());
|
||||
let metrics = Metrics::new();
|
||||
let state = AppState {
|
||||
iso_store,
|
||||
@@ -105,6 +106,7 @@ async fn build_state() -> (AppState, tempfile::TempDir) {
|
||||
queue,
|
||||
settings,
|
||||
hosts,
|
||||
boot_log,
|
||||
metrics,
|
||||
smb: None,
|
||||
nfs,
|
||||
@@ -975,3 +977,109 @@ async fn set_password_for_unknown_iso_returns_404() {
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_log_records_entry_serve_with_mac() {
|
||||
// End-to-end: upload an ISO, fetch the entry's boot script with a
|
||||
// MAC query param, then GET /api/boot-log and assert the event is
|
||||
// there with the supplied mac.
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
|
||||
let (ct, body) = multipart_iso_body("fake-alpine.iso", &fake_alpine_iso());
|
||||
let upload = app
|
||||
.clone()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(upload.status(), StatusCode::CREATED);
|
||||
|
||||
// Fetch the per-entry script with ?mac=...
|
||||
let (s, _) = get(
|
||||
&app,
|
||||
"/boot/fake-alpine-linux.ipxe?mac=AA:BB:CC:00:00:09",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
// The boot log should now contain exactly one entry, with the
|
||||
// normalized MAC and our target id.
|
||||
let (s, body) = get(&app, "/api/boot-log").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
let events = v["events"].as_array().expect("events");
|
||||
assert_eq!(events.len(), 1);
|
||||
let ev = &events[0];
|
||||
assert_eq!(ev["target_id"], "fake-alpine-linux");
|
||||
assert_eq!(ev["mac"], "aa:bb:cc:00:00:09"); // normalized
|
||||
// Title should include the filename and entry title.
|
||||
let title = ev["target_title"].as_str().unwrap();
|
||||
assert!(title.contains("fake-alpine.iso"), "title was {title}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_log_endpoint_empty_when_no_boots() {
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (s, body) = get(&app, "/api/boot-log").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(v["events"].as_array().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn boot_log_does_not_record_reserved_menu_targets() {
|
||||
// Reserved targets (_local, _queue, …) are operator console actions,
|
||||
// not imaging events. The Hosts log skips them so it stays focused
|
||||
// on "what got installed where".
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state.clone());
|
||||
|
||||
// Bind a MAC to the _local shortcut and hit /boot.ipxe.
|
||||
let body = r#"{"mac":"aa:bb:cc:00:00:11","target":"_local","label":"q"}"#;
|
||||
let (s, _) = post_json(&app, "/api/hosts", body).await;
|
||||
assert_eq!(s, StatusCode::CREATED);
|
||||
let (s, _) = get(&app, "/boot.ipxe?mac=aa:bb:cc:00:00:11").await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
let (_, body) = get(&app, "/api/boot-log").await;
|
||||
let v: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||
assert!(
|
||||
v["events"].as_array().unwrap().is_empty(),
|
||||
"reserved targets should not appear in boot log; got {v}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_rejects_non_iso_filename_with_clear_message() {
|
||||
// Sanity for the upload-logging path: a wrong extension should land
|
||||
// a 400 with the human message rather than silently being eaten by
|
||||
// the multipart loop. (No iso ends up in the store either.)
|
||||
let (state, _dir) = build_state().await;
|
||||
let app = build_router(state);
|
||||
let (ct, body) = multipart_iso_body("not-an-iso.txt", b"hello world");
|
||||
let res = app
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/isos")
|
||||
.header("content-type", ct)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
|
||||
let body = axum::body::to_bytes(res.into_body(), usize::MAX)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&body).unwrap();
|
||||
assert!(text.contains("only .iso uploads accepted"), "got: {text}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user