feat(fleet-operator): live-tail device logs via 2s frontend polling (Ch3) #329
@@ -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="));
|
||||
}
|
||||
}
|
||||
|
||||
23
fleet/harmony-fleet-operator/vendor/app.js
vendored
23
fleet/harmony-fleet-operator/vendor/app.js
vendored
@@ -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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user