feat(fleet-operator): live-tail device logs via 2s frontend polling (Ch3) #329

Open
johnride wants to merge 1 commits from feat/fleet-ch3-log-streaming into feat/fleet-ch2-operator-recovery
2 changed files with 95 additions and 23 deletions

View File

@@ -278,7 +278,7 @@ fn overview_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Mark
div class="flex items-center px-4 py-3 border-b" style="border-color:var(--border)" {
span class="section-title" { "Recent logs" }
}
(log_viewer(&device.id, None, "260px", "overview-logs"))
(log_viewer(&device.id, None, "260px", "overview-logs", false))
}
}
}
@@ -286,12 +286,17 @@ fn overview_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Mark
}
fn logs_tab(device: &DeviceDetail, deployments: &[String]) -> Markup {
// Default to the first matched deployment; the selector (only shown
// when the device runs more than one) swaps the log body to another.
let selected = deployments.first().map(String::as_str);
// With more than one deployment the selector drives the poll URL via
// hx-include; with one (or none) the deployment is baked into the URL.
let multi = deployments.len() > 1;
let selected = if multi {
None
} else {
deployments.first().map(String::as_str)
};
html! {
div class="card overflow-hidden mt-4" {
@if deployments.len() > 1 {
@if multi {
div class="flex items-center gap-2 px-4 py-2.5 border-b" style="border-color:var(--border)" {
span class="text-[11px] text-slate-500" { "Deployment" }
// htmx sends this select's value as `?deployment=` and
@@ -309,7 +314,7 @@ fn logs_tab(device: &DeviceDetail, deployments: &[String]) -> Markup {
}
}
}
(log_viewer(&device.id, selected, "520px", "logtab-logs"))
(log_viewer(&device.id, selected, "520px", "logtab-logs", multi))
}
}
}
@@ -317,14 +322,33 @@ fn logs_tab(device: &DeviceDetail, deployments: &[String]) -> Markup {
/// Shared one-shot log viewer: a scrollable body that fetches the
/// `podman logs` tail on load, plus a bottom bar (integrated into the
/// same container) with the source label and an explicit Refresh that
/// re-fetches into the body. The frontend drives updates — re-fetch on
/// demand rather than a long-lived stream. Used in the overview card,
/// logs tab, and pop-out modal; `body_id` keeps the targets distinct.
fn log_viewer(device_id: &str, deployment: Option<&str>, height: &str, body_id: &str) -> Markup {
let tail_url = match deployment {
Some(dep) => format!("/devices/{device_id}/logs/tail?deployment={dep}"),
None => format!("/devices/{device_id}/logs/tail"),
/// re-fetches into the body. Used in the overview card, logs tab, and pop-out
/// modal; `body_id` keeps the targets distinct.
///
/// The body **re-fetches every 2 s** (HTMX `every 2s`), so the panel behaves
/// like a live tail without a long-lived stream — the pragmatic stand-in until
/// we want a real TTY/streaming channel (v0.4 #13). Refresh forces an immediate
/// re-fetch.
///
/// When `selector_driven` is set (the logs tab's multi-deployment case), the
/// poll URL carries no baked deployment — instead it `hx-include`s the
/// `deployment` `<select>` 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 <select> value and bakes no
// deployment into the body URL (so polling never targets a stale one).
assert!(html.contains(r#"hx-include="[name='deployment']""#));
assert!(!html.contains("logs/tail?deployment="));
}
}

View File

@@ -19,10 +19,21 @@ document.body.addEventListener('htmx:afterSwap', (event) => {
});
});
// After a log panel loads its tail, scroll to the latest line.
document.body.addEventListener('htmx:afterSwap', (event) => {
const el = event.target;
if (el && el.classList && el.classList.contains('logfeed')) {
el.scrollTop = el.scrollHeight;
}
// Log panels re-fetch every 2s. Tail-follow only when the user is already at
// the bottom, so polling doesn't yank them back down while they read history.
// Record "was pinned" before the swap; honor it after.
function logfeed(el) {
return el && el.classList && el.classList.contains('logfeed') ? el : null;
}
document.body.addEventListener('htmx:beforeSwap', (event) => {
const el = logfeed(event.target);
if (!el) return;
el.dataset.pinned =
el.scrollHeight - el.scrollTop - el.clientHeight < 40 ? '1' : '0';
});
document.body.addEventListener('htmx:afterSwap', (event) => {
const el = logfeed(event.target);
if (!el) return;
// Default (first load, no dataset) follows the tail.
if (el.dataset.pinned !== '0') el.scrollTop = el.scrollHeight;
});