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)" { div class="flex items-center px-4 py-3 border-b" style="border-color:var(--border)" {
span class="section-title" { "Recent logs" } 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 { fn logs_tab(device: &DeviceDetail, deployments: &[String]) -> Markup {
// Default to the first matched deployment; the selector (only shown // With more than one deployment the selector drives the poll URL via
// when the device runs more than one) swaps the log body to another. // hx-include; with one (or none) the deployment is baked into the URL.
let selected = deployments.first().map(String::as_str); let multi = deployments.len() > 1;
let selected = if multi {
None
} else {
deployments.first().map(String::as_str)
};
html! { html! {
div class="card overflow-hidden mt-4" { 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)" { 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" } span class="text-[11px] text-slate-500" { "Deployment" }
// htmx sends this select's value as `?deployment=` and // 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 /// Shared one-shot log viewer: a scrollable body that fetches the
/// `podman logs` tail on load, plus a bottom bar (integrated into the /// `podman logs` tail on load, plus a bottom bar (integrated into the
/// same container) with the source label and an explicit Refresh that /// same container) with the source label and an explicit Refresh that
/// re-fetches into the body. The frontend drives updates — re-fetch on /// re-fetches into the body. Used in the overview card, logs tab, and pop-out
/// demand rather than a long-lived stream. Used in the overview card, /// modal; `body_id` keeps the targets distinct.
/// 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 { /// The body **re-fetches every 2 s** (HTMX `every 2s`), so the panel behaves
let tail_url = match deployment { /// like a live tail without a long-lived stream — the pragmatic stand-in until
Some(dep) => format!("/devices/{device_id}/logs/tail?deployment={dep}"), /// we want a real TTY/streaming channel (v0.4 #13). Refresh forces an immediate
None => format!("/devices/{device_id}/logs/tail"), /// 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! { html! {
div class="flex flex-col min-h-0" style={"height:" (height)} { div class="flex flex-col min-h-0" style={"height:" (height)} {
div 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" class="logfeed flex-1 font-mono text-[11.5px] leading-6 px-4 py-2 overflow-auto whitespace-pre-wrap"
style="background:#050608" style="background:#050608"
hx-get=(tail_url) hx-get=(tail_url)
hx-trigger="load" hx-trigger="load, every 2s"
hx-include=[include]
hx-target="this" 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="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" 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" { 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 button
class="flex items-center gap-1 text-slate-400 hover:text-slate-100" class="flex items-center gap-1 text-slate-400 hover:text-slate-100"
hx-get=(tail_url) hx-get=(tail_url)
hx-include=[include]
hx-target={"#" (body_id)} hx-target={"#" (body_id)}
hx-swap="innerHTML" { hx-swap="innerHTML" {
(PreEscaped(ICON_REFRESH)) "Refresh" (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(r##"hx-target="#logtab-logs""##));
assert!(two.contains("sensor-fw")); 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. // Log panels re-fetch every 2s. Tail-follow only when the user is already at
document.body.addEventListener('htmx:afterSwap', (event) => { // the bottom, so polling doesn't yank them back down while they read history.
const el = event.target; // Record "was pinned" before the swap; honor it after.
if (el && el.classList && el.classList.contains('logfeed')) { function logfeed(el) {
el.scrollTop = el.scrollHeight; 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;
}); });