fix(fleet-operator): dashboard UI bugs (mostly CSP-blocked inline JS) #323
@@ -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<String>) -> Result<Markup, AppError>
|
||||
}
|
||||
|
||||
async fn device_logs_stream_handler(
|
||||
Path(id): Path<String>,
|
||||
Path(_id): Path<String>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
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#"<div class="grid grid-cols-[5.5rem_1fr] gap-4 px-0 py-px hover:bg-white/2"><span class="tabular-nums text-slate-600">{now}</span><span class="text-slate-300"><span class="text-cyan-500">{id}</span> {msg}</span></div>"#,
|
||||
);
|
||||
|
||||
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#"<div class="px-0 py-px italic text-slate-600">— live agent log streaming is not implemented yet —</div>"#;
|
||||
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)))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::service::DashboardDetail;
|
||||
// ── Inline icons ────────────────────────────────────────────────────────
|
||||
const ICON_PLUS: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>"#;
|
||||
const ICON_CHEVRON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>"#;
|
||||
const ICON_LIST: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>"#;
|
||||
const ICON_ERROR: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>"#;
|
||||
const ICON_WARNING: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>"#;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ use crate::service::{DeploymentDetail, DeviceDetail};
|
||||
|
||||
// ── Inline icons ────────────────────────────────────────────────────────
|
||||
const ICON_PLUS: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>"#;
|
||||
const ICON_REFRESH: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg>"#;
|
||||
const ICON_PLAY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>"#;
|
||||
const ICON_PAUSE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>"#;
|
||||
const ICON_ROLLBACK: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>"#;
|
||||
const ICON_DEPLOY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>"#;
|
||||
const ICON_MORE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>"#;
|
||||
|
||||
// ── 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" {
|
||||
|
||||
@@ -6,8 +6,6 @@ use crate::service::{DeviceDetail, DeviceStatus};
|
||||
// ── Inline icons ────────────────────────────────────────────────────────
|
||||
const ICON_SEARCH: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>"#;
|
||||
const ICON_CHEVRON_DOWN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>"#;
|
||||
const ICON_LIST: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>"#;
|
||||
const ICON_MORE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>"#;
|
||||
const ICON_POWER: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>"#;
|
||||
const ICON_PAUSE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>"#;
|
||||
const ICON_BAN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>"#;
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
24
fleet/harmony-fleet-operator/vendor/app.js
vendored
24
fleet/harmony-fleet-operator/vendor/app.js
vendored
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user