diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs index 5f50b8fa..c1c46d76 100644 --- a/fleet/harmony-fleet-operator/src/frontend/server.rs +++ b/fleet/harmony-fleet-operator/src/frontend/server.rs @@ -18,7 +18,6 @@ use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar}; use maud::Markup; use serde::Deserialize; use tokio_stream::StreamExt; -use tokio_stream::wrappers::IntervalStream; use super::assets::{APP_JS, HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS}; use super::layout::page; @@ -426,12 +425,13 @@ async fn device_detail_handler( let tab = q.tab.as_deref().unwrap_or("overview"); - // If a specific tab is requested via query param, return tab content only (for HTMX) + // Tab click (HTMX): return the tabs block (bar + content) so the + // active highlight re-renders with the content. if q.tab.is_some() { - return Ok(devices_view::tab_content( + return Ok(devices_view::device_tabs( &device, - tab, deployment_version.as_deref(), + tab, )); } @@ -507,9 +507,14 @@ async fn deployment_handler( .filter(|a| !a.acked) .count(); - if q.tab.is_some() && q.tab.as_deref() != Some("overview") { - // HTMX tab content only - Ok(deployments_view::tab_content(&deployment, &devices, tab)) + if q.tab.is_some() { + // Tab click (HTMX): the tabs block (bar + content), so overview + // returns its content too and the active highlight re-renders. + Ok(deployments_view::tabs_and_content( + &deployment, + &devices, + tab, + )) } else { Ok(page( &deployment.name, @@ -593,37 +598,14 @@ async fn device_logs_handler(Path(id): Path) -> Result } async fn device_logs_stream_handler( - Path(id): Path, + Path(_id): Path, ) -> Sse>> { - let mut line_no = 0usize; - let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).map( - move |_| { - line_no += 1; - let now = chrono::Utc::now().format("%H:%M:%S"); - let sevs = ["debug", "info", "info", "info", "info", "warn", "error"]; - let _sev = sevs[line_no % sevs.len()]; - let msgs = [ - "agent heartbeat ok (latency 12ms)", - "mqtt connection established to broker.harmony.local", - "reporting metrics batch (48 samples)", - "config reload requested by control-plane", - "task t4 (install deps) progress 73%", - "unexpected schema version v3, falling back", - "sensord pid=2841 started", - "gpu temp 54\u{b0}C \u{2014} within range", - "apt-get: package libsensor-7 not found", - "flushed 19 pending events to ingest", - "network jitter 84ms \u{2014} degrading to backup link", - "gc cycle complete in 47ms", - ]; - let msg = msgs[line_no % msgs.len()]; - let html = format!( - r#"
{now}{id} {msg}
"#, - ); - - Ok::<_, Infallible>(Event::default().event("log").data(html)) - }, - ); + // One honest notice, then keep-alive. Real agent-log streaming (over + // NATS) is pending — don't fabricate log lines. + let html = r#"
— live agent log streaming is not implemented yet —
"#; + let stream = futures_util::stream::once(async move { + Ok::<_, Infallible>(Event::default().event("log").data(html)) + }); Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) } diff --git a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs index f99bee95..73f77b2a 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs @@ -6,7 +6,6 @@ use crate::service::DashboardDetail; // ── Inline icons ──────────────────────────────────────────────────────── const ICON_PLUS: &str = r#""#; const ICON_CHEVRON: &str = r#""#; -const ICON_LIST: &str = r#""#; const ICON_ERROR: &str = r#""#; const ICON_WARNING: &str = r#""#; @@ -201,14 +200,13 @@ fn lower_row(d: &DashboardDetail) -> Markup { th { "Status" } th { "Deployment" } th { "Last seen" } - th class="text-right" { "Action" } } } tbody { @for dev in &d.attention_devices { tr class="cursor-pointer" hx-get={"/device/" (dev.id)} - hx-target="closest main" + hx-target="body" hx-push-url="true" { td { span class="font-mono text-slate-100 whitespace-nowrap" { (&dev.id) } @@ -221,16 +219,6 @@ fn lower_row(d: &DashboardDetail) -> Markup { td class="text-slate-500 text-[12px] tabular-nums" { (time_ago(dev.minutes_ago)) } - td class="text-right" { - button - class="btn btn-ghost py-1" - hx-get={"/devices/" (dev.id) "/logs"} - hx-target="#modal-root" - hx-swap="innerHTML" - onclick="event.stopPropagation();" { - (PreEscaped(ICON_LIST)) " Logs" - } - } } } } diff --git a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs index 013fdd60..7b42b577 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs @@ -5,12 +5,6 @@ use crate::service::{DeploymentDetail, DeviceDetail}; // ── Inline icons ──────────────────────────────────────────────────────── const ICON_PLUS: &str = r#""#; -const ICON_REFRESH: &str = r#""#; -const ICON_PLAY: &str = r#""#; -const ICON_PAUSE: &str = r#""#; -const ICON_ROLLBACK: &str = r#""#; -const ICON_DEPLOY: &str = r#""#; -const ICON_MORE: &str = r#""#; // ── Deployments list page ────────────────────────────────────────────── @@ -106,16 +100,6 @@ pub fn detail(deployment: &DeploymentDetail, devices: &[DeviceDetail]) -> Markup span { span class="text-slate-600" { "Updated" } " " span class="text-slate-300 tabular-nums" { (&deployment.updated_at) } } } } - div class="flex items-center gap-2 shrink-0" { - button class="btn btn-ghost" { (PreEscaped(ICON_REFRESH)) " Reconcile" } - @if deployment.status == crate::service::DeploymentStatus::Paused { - button class="btn btn-ghost" { (PreEscaped(ICON_PLAY)) " Resume" } - } @else { - button class="btn btn-ghost" { (PreEscaped(ICON_PAUSE)) " Pause" } - } - button class="btn btn-ghost" { (PreEscaped(ICON_ROLLBACK)) " Rollback" } - button class="btn btn-primary" { (PreEscaped(ICON_DEPLOY)) " Roll out" } - } } // Rollout progress @@ -158,49 +142,51 @@ pub fn detail(deployment: &DeploymentDetail, devices: &[DeviceDetail]) -> Markup } } - // Tabs - div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-100" - hx-get={"/deployment/" (deployment.name) "?tab=overview"} - hx-target="#dep-tab-content" - hx-swap="innerHTML" { - "Overview" - span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} - } - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" - hx-get={"/deployment/" (deployment.name) "?tab=devices"} - hx-target="#dep-tab-content" - hx-swap="innerHTML" { - "Devices (" (devices.len()) ")" - } - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" - hx-get={"/deployment/" (deployment.name) "?tab=config"} - hx-target="#dep-tab-content" - hx-swap="innerHTML" { - "Config" - } - } - - div id="dep-tab-content" { - (overview_tab(devices)) + // The whole tabs block re-renders on switch so the active + // highlight follows the content. + div id="dep-tabs" { + (tabs_and_content(deployment, devices, "overview")) } } } } -pub fn tab_content(deployment: &DeploymentDetail, devices: &[DeviceDetail], tab: &str) -> Markup { - match tab { +/// Tab bar (active highlighted) + the active tab's content. Swapped as a +/// unit into `#dep-tabs`. +pub fn tabs_and_content( + deployment: &DeploymentDetail, + devices: &[DeviceDetail], + active: &str, +) -> Markup { + let content = match active { "devices" => devices_tab(devices), "config" => config_tab(deployment), - _ => overview_tab(devices), + _ => per_device_grid(devices), + }; + html! { + div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { + (tab_button(&deployment.name, "Overview".to_string(), "overview", active == "overview")) + (tab_button(&deployment.name, format!("Devices ({})", devices.len()), "devices", active == "devices")) + (tab_button(&deployment.name, "Config".to_string(), "config", active == "config")) + } + div class="mt-4" { (content) } } } -fn overview_tab(devices: &[DeviceDetail]) -> Markup { - per_device_grid(devices) +fn tab_button(name: &str, label: String, tab: &str, active: bool) -> Markup { + html! { + button + class={"px-3 py-2 text-[13px] font-medium relative " + (if active { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })} + hx-get={"/deployment/" (name) "?tab=" (tab)} + hx-target="#dep-tabs" + hx-swap="innerHTML" { + (label) + @if active { + span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} + } + } + } } fn devices_tab(devices: &[DeviceDetail]) -> Markup { @@ -214,7 +200,6 @@ fn devices_tab(devices: &[DeviceDetail]) -> Markup { th { "Region" } th { "Agent" } th { "Last seen" } - th class="text-right" { "Action" } } } tbody { @@ -229,9 +214,6 @@ fn devices_tab(devices: &[DeviceDetail]) -> Markup { @if let Some(inv) = &d.inventory { (&inv.agent_version) } @else { "\u{2014}" } } } td { span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } } - td class="text-right" { - button class="text-slate-400 hover:text-slate-100 px-1.5 py-1" { (PreEscaped(ICON_MORE)) } - } } } } @@ -243,40 +225,28 @@ fn devices_tab(devices: &[DeviceDetail]) -> Markup { fn config_tab(deployment: &DeploymentDetail) -> Markup { html! { div class="card p-5 mt-4" { - div class="section-title mb-2" { "Deployment manifest" } - pre class="font-mono text-[12px] text-slate-300 leading-6 p-4 rounded" style="background:#050608; border:1px solid var(--border)" { - "apiVersion: harmony/v1\n" - "kind: Deployment\n" - "metadata:\n" - " name: " (deployment.name) "\n" - " version: " (deployment.version) "\n" - "spec:\n" - " target:\n" - " selector: tags has \"prod\"\n" - " count: " (deployment.target) "\n" - " strategy:\n" - " type: rolling\n" - " maxUnavailable: 10%\n" - " tasks:\n" - " - id: fetch_artifact\n" - " run: hf-agent pull oci://registry/" (deployment.name) ":" (deployment.version) "\n" - " - id: verify_signature\n" - " run: cosign verify --key /etc/harmony/pub.key\n" - " after: [fetch_artifact]\n" - " - id: install_deps\n" - " run: hf-agent apt install -y libsensor3 libcrypto3\n" - " after: [verify_signature]\n" - " - id: launch_services\n" - " run: systemctl restart sensord relayd\n" - " after: [install_deps]\n" - " - id: health_probe\n" - " run: hf-agent probe --timeout 30s\n" - " after: [launch_services]\n" + div class="section-title mb-3" { "Configuration" } + (definition("Name", &deployment.name)) + (definition("Version", &deployment.version)) + (definition("Status", deployment.status.label())) + (definition("Targeted devices", &deployment.target.to_string())) + (definition("Updated", &deployment.updated_at)) + div class="text-[11px] text-slate-600 mt-3" { + "Full spec (target selector, score, rollout strategy) is not surfaced here yet." } } } } +fn definition(label: &str, value: &str) -> Markup { + html! { + div class="flex items-center justify-between py-1.5 text-[12px] border-b last:border-b-0" style="border-color:var(--border)" { + span class="text-slate-500" { (label) } + span class="font-mono text-slate-200 whitespace-nowrap" { (value) } + } + } +} + fn per_device_grid(devices: &[DeviceDetail]) -> Markup { html! { div class="card" { diff --git a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs index 69609268..4db94bc5 100644 --- a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs +++ b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs @@ -6,8 +6,6 @@ use crate::service::{DeviceDetail, DeviceStatus}; // ── Inline icons ──────────────────────────────────────────────────────── const ICON_SEARCH: &str = r#""#; const ICON_CHEVRON_DOWN: &str = r#""#; -const ICON_LIST: &str = r#""#; -const ICON_MORE: &str = r#""#; const ICON_POWER: &str = r#""#; const ICON_PAUSE: &str = r#""#; const ICON_BAN: &str = r#""#; @@ -51,7 +49,8 @@ pub fn page( name="deployment" class="input pl-3 pr-8 appearance-none font-mono text-[12px]" style="padding-left:10px" - onchange="this.form.requestSubmit()" { + hx-trigger="change" + hx-include="closest form" { option value="" selected[deployment_filter.is_none()] { "All deployments" } @for dep in deployments { option value=(dep.as_str()) selected[deployment_filter == Some(dep.as_str())] { (dep) } @@ -72,7 +71,8 @@ pub fn page( name="region" class="input pl-3 pr-8 appearance-none font-mono text-[12px]" style="padding-left:10px" - onchange="this.form.requestSubmit()" { + hx-trigger="change" + hx-include="closest form" { option value="" selected[region_filter.is_none()] { "All regions" } @for r in &all_regions { option value=(r) selected[region_filter == Some(r)] { (r) } @@ -109,25 +109,17 @@ pub fn page( table class="tbl" { thead class="sticky top-0 z-10" { tr { - th style="width:36px" {} th { "Device ID" } th { "Status" } th { "Deployment" } th { "Region" } th { "Agent" } th { "Last seen" } - th class="text-right" { "Action" } } } tbody { @for d in devices { tr hx-get={"/device/" (d.id)} hx-target="body" hx-push-url="true" class="cursor-pointer" { - td { - input - type="checkbox" - class="accent-(--accent) w-3.5 h-3.5 rounded" - onclick="event.stopPropagation()"; - } td { span class="font-mono text-slate-100 hover:text-(--accent-fg) hover:underline underline-offset-2 whitespace-nowrap" { (&d.id) @@ -150,30 +142,11 @@ pub fn page( td { span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } } - td class="text-right" { - div class="inline-flex items-center gap-1" { - button - class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" - title="Quick logs" - hx-get={"/devices/" (d.id) "/logs"} - hx-target="#modal-root" - hx-swap="innerHTML" - onclick="event.stopPropagation()" { - (PreEscaped(ICON_LIST)) - } - button - class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" - title="More" - onclick="event.stopPropagation()" { - (PreEscaped(ICON_MORE)) - } - } - } } } @if devices.is_empty() { tr { - td colspan="8" class="text-center py-12 text-slate-500 text-[13px]" { + td colspan="6" class="text-center py-12 text-slate-500 text-[13px]" { "No devices match these filters" } } @@ -233,52 +206,59 @@ pub fn detail(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup } } - // Tab bar - div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-100" - hx-get={"/device/" (device.id) "?tab=overview"} - hx-target="#device-tab-content" - hx-swap="innerHTML" { - "Overview" - span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} - } - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" - hx-get={"/device/" (device.id) "?tab=logs"} - hx-target="#device-tab-content" - hx-swap="innerHTML" { - "Logs" - } - button - class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300" - hx-get={"/device/" (device.id) "?tab=command"} - hx-target="#device-tab-content" - hx-swap="innerHTML" { - "Run command" - } - div class="flex-1" {} - button - class="btn btn-ghost mb-1" - hx-get={"/devices/" (device.id) "/logs"} - hx-target="#modal-root" - hx-swap="innerHTML" { - (PreEscaped(ICON_EXPAND)) " Pop-out logs" - } - } - - div id="device-tab-content" { - (overview_tab(device, deployment_version)) + // The whole tabs block re-renders on switch so the active + // highlight follows (only the content swapping would leave it + // stuck on Overview). + div id="device-tabs" { + (device_tabs(device, deployment_version, "overview")) } } } } -pub fn tab_content(device: &DeviceDetail, tab: &str, deployment_version: Option<&str>) -> Markup { - match tab { +/// Tab bar (active highlighted) + the active tab's content. Swapped as a +/// unit into `#device-tabs`. +pub fn device_tabs( + device: &DeviceDetail, + deployment_version: Option<&str>, + active: &str, +) -> Markup { + let content = match active { "logs" => logs_tab(device), "command" => command_tab(&device.id), _ => overview_tab(device, deployment_version), + }; + html! { + div class="flex items-center gap-1 border-b" style="border-color:var(--border)" { + (tab_button(&device.id, "Overview", "overview", active == "overview")) + (tab_button(&device.id, "Logs", "logs", active == "logs")) + (tab_button(&device.id, "Run command", "command", active == "command")) + div class="flex-1" {} + button + class="btn btn-ghost mb-1" + hx-get={"/devices/" (device.id) "/logs"} + hx-target="#modal-root" + hx-swap="innerHTML" { + (PreEscaped(ICON_EXPAND)) " Pop-out logs" + } + } + div { (content) } + } +} + +fn tab_button(device_id: &str, label: &str, tab: &str, active: bool) -> Markup { + html! { + button + class={"px-3 py-2 text-[13px] font-medium relative " + (if active { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })} + hx-get={"/device/" (device_id) "?tab=" (tab)} + hx-target="#device-tabs" + hx-swap="innerHTML" { + (label) + @if active { + span class="absolute left-0 right-0 -bottom-px h-0.5" style="background:var(--accent)" {} + } + } } } @@ -439,8 +419,6 @@ pub fn logs_modal(device_id: &str) -> Markup { id="device-logs-modal" class="m-auto grid grid-rows-[auto_1fr] h-[88vh] w-[min(96vw,82rem)] overflow-hidden rounded-none border-t-2 border-x-0 border-b-0 p-0 text-slate-100 shadow-[0_32px_64px_rgba(0,0,0,0.9),0_0_0_1px_rgba(148,163,184,0.06)] backdrop:bg-black/85" style="border-color:var(--accent); background:#080a0c" - onclick="if (event.target === this) this.close()" - onclose="document.getElementById('modal-root').innerHTML = ''" { div class="flex items-center justify-between border-b px-5 py-3" style="background:#0c1018; border-color:var(--border)" { div class="flex items-center gap-3" { @@ -473,18 +451,6 @@ pub fn logs_modal(device_id: &str) -> Markup { div class="py-px italic text-slate-700" { "\u{2014} connecting \u{2014}" } } } - script { - (PreEscaped(r#" -(function(){ - var modal = document.getElementById('device-logs-modal'); - modal?.showModal(); - var body = modal?.querySelector('[hx-swap]'); - if (!body) return; - new MutationObserver(function(){ body.scrollTop = body.scrollHeight; }) - .observe(body, { childList: true }); -})(); -"#)) - } } } @@ -493,12 +459,8 @@ pub fn logs_modal(device_id: &str) -> Markup { pub fn row(d: &DeviceDetail) -> Markup { html! { tr id={"device-" (d.id)} hx-get={"/device/" (d.id)} hx-target="body" hx-push-url="true" class="cursor-pointer" { - td { - input type="checkbox" class="accent-(--accent) w-3.5 h-3.5 rounded" onclick="event.stopPropagation()" {} - } td { span class="font-mono text-slate-100 hover:text-(--accent-fg) hover:underline underline-offset-2 whitespace-nowrap" { - (&d.id) } } @@ -510,17 +472,6 @@ pub fn row(d: &DeviceDetail) -> Markup { td { span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } } td { span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (agent_version(d)) } } td { span class="text-[12px] text-slate-500 tabular-nums" { (time_ago(d.minutes_ago)) } } - td class="text-right" { - div class="inline-flex items-center gap-1" { - button - class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" - hx-get={"/devices/" (d.id) "/logs"} - hx-target="#modal-root" - hx-swap="innerHTML" - onclick="event.stopPropagation()" { (PreEscaped(ICON_LIST)) } - button class="text-slate-400 hover:text-slate-100 px-1.5 py-1 rounded hover:bg-white/4" onclick="event.stopPropagation()" { (PreEscaped(ICON_MORE)) } - } - } } } } @@ -603,14 +554,14 @@ mod tests { #[test] fn command_tab_posts_to_exec_seam() { - let html = tab_content(&sample(), "command", None).into_string(); + let html = device_tabs(&sample(), None, "command").into_string(); assert!(html.contains("/devices/hf-edge-001/exec")); assert!(html.contains(r#"name="command""#)); } #[test] fn logs_tab_connects_to_stream() { - let html = tab_content(&sample(), "logs", None).into_string(); + let html = device_tabs(&sample(), None, "logs").into_string(); assert!(html.contains("/devices/hf-edge-001/logs/stream")); } } diff --git a/fleet/harmony-fleet-operator/vendor/app.js b/fleet/harmony-fleet-operator/vendor/app.js index 218b0e6f..bb382ab7 100644 --- a/fleet/harmony-fleet-operator/vendor/app.js +++ b/fleet/harmony-fleet-operator/vendor/app.js @@ -1,3 +1,27 @@ document.body.addEventListener('htmx:configRequest', (event) => { event.detail.headers['x-csrf-token'] = '1'; }); + +// Open a modal dialog swapped into #modal-root. Lives here (not inline) +// because the production CSP forbids inline scripts/handlers. +document.body.addEventListener('htmx:afterSwap', (event) => { + if (!event.target || event.target.id !== 'modal-root') return; + const dialog = event.target.querySelector('dialog'); + if (!dialog || typeof dialog.showModal !== 'function') return; + + dialog.showModal(); + // Backdrop click closes; closing clears the root so it can re-open. + dialog.addEventListener('click', (e) => { + if (e.target === dialog) dialog.close(); + }); + dialog.addEventListener('close', () => { + event.target.innerHTML = ''; + }); + // Keep a streaming log body scrolled to the latest line. + const body = dialog.querySelector('[sse-connect]'); + if (body) { + new MutationObserver(() => { + body.scrollTop = body.scrollHeight; + }).observe(body, { childList: true }); + } +});