diff --git a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs index 2bffe050..779e915a 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs @@ -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` ` 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=")); + } } diff --git a/fleet/harmony-fleet-operator/vendor/app.js b/fleet/harmony-fleet-operator/vendor/app.js index 978af995..21c5fcbd 100644 --- a/fleet/harmony-fleet-operator/vendor/app.js +++ b/fleet/harmony-fleet-operator/vendor/app.js @@ -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; });