From 15f908a542d070a8d7e33cba859f792ef03bef94 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Wed, 3 Jun 2026 23:27:15 -0400 Subject: [PATCH] feat(fleet-operator): live-tail device logs via 2s frontend polling (Ch3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fake log streaming by re-fetching the podman-logs tail every 2s from the frontend (HTMX `every 2s`) — the pragmatic stand-in until a real TTY/streaming channel (v0.4 #13), per the agreed Ch3 scope. - log_viewer polls `load, every 2s` with `hx-sync="this:drop"` so slow/offline devices can't pile up requests. - Multi-deployment selector: the poll URL is driven by `hx-include` of the `` in the same card, so changing the selection is +/// picked up by the next poll (and immediately by the select's own handler) +/// without re-rendering the viewer or polling a stale URL. +fn log_viewer( + device_id: &str, + deployment: Option<&str>, + height: &str, + body_id: &str, + selector_driven: bool, +) -> Markup { + let base_url = format!("/devices/{device_id}/logs/tail"); + let tail_url = match (selector_driven, deployment) { + (true, _) => base_url.clone(), + (false, Some(dep)) => format!("{base_url}?deployment={dep}"), + (false, None) => base_url.clone(), }; + let include = selector_driven.then_some("[name='deployment']"); html! { div class="flex flex-col min-h-0" style={"height:" (height)} { div @@ -332,17 +356,22 @@ fn log_viewer(device_id: &str, deployment: Option<&str>, height: &str, body_id: class="logfeed flex-1 font-mono text-[11.5px] leading-6 px-4 py-2 overflow-auto whitespace-pre-wrap" style="background:#050608" hx-get=(tail_url) - hx-trigger="load" + hx-trigger="load, every 2s" + hx-include=[include] hx-target="this" - hx-swap="innerHTML" { + hx-swap="innerHTML" + // Skip a tick if the previous fetch is still in flight (slow or + // offline device), so polls can't pile up. + hx-sync="this:drop" { div class="px-0 py-px italic text-slate-700" { "\u{2014} loading logs \u{2014}" } } div class="flex items-center justify-between px-3 py-1.5 border-t text-[10px] font-mono text-slate-600" style="border-color:var(--border); background:#0a0c10" { - span { "podman logs \u{b7} last 200 lines" } + span { "podman logs \u{b7} live, every 2s \u{b7} last 200 lines" } button class="flex items-center gap-1 text-slate-400 hover:text-slate-100" hx-get=(tail_url) + hx-include=[include] hx-target={"#" (body_id)} hx-swap="innerHTML" { (PreEscaped(ICON_REFRESH)) "Refresh" @@ -460,7 +489,7 @@ pub fn logs_modal(device_id: &str, deployment: Option<&str>) -> Markup { } } - (log_viewer(device_id, deployment, "100%", "modal-logs")) + (log_viewer(device_id, deployment, "100%", "modal-logs", false)) } } } @@ -588,4 +617,36 @@ mod tests { assert!(two.contains(r##"hx-target="#logtab-logs""##)); assert!(two.contains("sensor-fw")); } + + #[test] + fn log_viewer_polls_every_2s() { + let html = device_tabs(&sample(), None, &["edge-app".into()], "logs").into_string(); + assert!( + html.contains(r#"hx-trigger="load, every 2s""#), + "log body should re-fetch every 2s (fake streaming)" + ); + } + + #[test] + fn single_deployment_bakes_deployment_into_poll_url() { + let html = device_tabs(&sample(), None, &["edge-app".into()], "logs").into_string(); + // One deployment → no selector, deployment baked into the URL, no include. + assert!(html.contains("/devices/hf-edge-001/logs/tail?deployment=edge-app")); + assert!(!html.contains("hx-include")); + } + + #[test] + fn multi_deployment_drives_poll_url_from_selector() { + let html = device_tabs( + &sample(), + None, + &["edge-app".into(), "sensor-fw".into()], + "logs", + ) + .into_string(); + // Selector-driven: the poll includes the