diff --git a/.cargo/config.toml b/.cargo/config.toml
index a0b2a08a..d8baa144 100644
--- a/.cargo/config.toml
+++ b/.cargo/config.toml
@@ -6,3 +6,6 @@ rustflags = ["-C", "link-arg=-Wl,--stack,8000000"]
[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
+
+[profile.test]
+debug = 0
diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..2f4ae554
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,7 @@
+FLEET_AUTH_ISSUER_URL=
+FLEET_AUTH_AUTHORIZE_URL=
+FLEET_AUTH_TOKEN_URL=
+FLEET_AUTH_CLIENT_ID=
+FLEET_AUTH_REDIRECT_URI=
+FLEET_AUTH_SCOPE=
+FLEET_AUTH_TRUSTED_AUDIENCES=
diff --git a/.gitignore b/.gitignore
index 76ea8ec2..cce78a00 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
### General ###
private_repos/
+.env
### Harmony ###
harmony.log
diff --git a/Cargo.lock b/Cargo.lock
index b627afeb..5344ac04 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1010,6 +1010,81 @@ dependencies = [
"tracing",
]
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes 1.11.1",
+ "form_urlencoded",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "hyper 1.8.1",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper 1.0.2",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes 1.11.1",
+ "futures-core",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper 1.0.2",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-extra"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes 1.11.1",
+ "cookie 0.18.1",
+ "futures-util",
+ "http 1.4.0",
+ "http-body 1.0.1",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "serde_core",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
[[package]]
name = "backon"
version = "1.6.0"
@@ -1739,6 +1814,21 @@ dependencies = [
"version_check",
]
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "aes-gcm",
+ "base64 0.22.1",
+ "percent-encoding",
+ "rand 0.8.5",
+ "subtle",
+ "time",
+ "version_check",
+]
+
[[package]]
name = "cookie_store"
version = "0.20.0"
@@ -3992,22 +4082,32 @@ version = "0.1.0"
dependencies = [
"anyhow",
"async-nats",
+ "async-trait",
+ "axum",
+ "axum-extra",
+ "base64 0.22.1",
"chrono",
"clap",
+ "dotenvy",
"futures-util",
"harmony",
"harmony-fleet-auth",
"harmony-reconciler-contracts",
+ "harmony_zitadel_auth",
"k8s-openapi",
"kube",
+ "maud",
+ "reqwest 0.12.28",
"schemars 0.8.22",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
+ "tokio-stream",
"toml",
"tracing",
"tracing-subscriber",
+ "url",
]
[[package]]
@@ -4352,6 +4452,29 @@ dependencies = [
"url",
]
+[[package]]
+name = "harmony_zitadel_auth"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "arc-swap",
+ "axum",
+ "axum-extra",
+ "base64 0.22.1",
+ "chrono",
+ "jsonwebtoken",
+ "openidconnect",
+ "rand 0.9.2",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "sha2",
+ "time",
+ "tokio",
+ "tracing",
+ "url",
+]
+
[[package]]
name = "hashbrown"
version = "0.12.3"
@@ -5111,6 +5234,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.13.0"
@@ -5582,6 +5714,36 @@ dependencies = [
"regex-automata",
]
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "axum-core",
+ "http 1.4.0",
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "md-5"
version = "0.10.6"
@@ -5870,6 +6032,26 @@ dependencies = [
"libc",
]
+[[package]]
+name = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http 1.4.0",
+ "rand 0.8.5",
+ "reqwest 0.12.28",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2",
+ "thiserror 1.0.69",
+ "url",
+]
+
[[package]]
name = "objc2"
version = "0.6.4"
@@ -5962,6 +6144,37 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+[[package]]
+name = "openidconnect"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
+dependencies = [
+ "base64 0.21.7",
+ "chrono",
+ "dyn-clone",
+ "ed25519-dalek",
+ "hmac",
+ "http 1.4.0",
+ "itertools 0.10.5",
+ "log",
+ "oauth2",
+ "p256 0.13.2",
+ "p384",
+ "rand 0.8.5",
+ "rsa",
+ "serde",
+ "serde-value",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_plain",
+ "serde_with",
+ "sha2",
+ "subtle",
+ "thiserror 1.0.69",
+ "url",
+]
+
[[package]]
name = "openssl-probe"
version = "0.1.6"
@@ -6615,6 +6828,18 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+ "version_check",
+]
+
[[package]]
name = "psl-types"
version = "2.0.11"
@@ -6817,7 +7042,7 @@ dependencies = [
"crossterm 0.28.1",
"indoc",
"instability",
- "itertools",
+ "itertools 0.13.0",
"lru",
"paste",
"strum 0.26.3",
@@ -7718,6 +7943,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "serde_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
[[package]]
name = "serde_repr"
version = "0.1.20"
@@ -9070,7 +9304,7 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
dependencies = [
- "itertools",
+ "itertools 0.13.0",
"unicode-segmentation",
"unicode-width 0.1.14",
]
diff --git a/Cargo.toml b/Cargo.toml
index 1cf47d01..9c382a89 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
"examples/*",
"private_repos/*",
"harmony",
+ "harmony_zitadel_auth",
"harmony_types",
"harmony_macros",
"harmony_tui",
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index a61fbf68..10ae47c4 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -23,6 +23,7 @@
- [Writing a Score](./guides/writing-a-score.md)
- [Writing a Topology](./guides/writing-a-topology.md)
- [Adding Capabilities](./guides/adding-capabilities.md)
+- [Web Authentication and CSRF Security](./guides/web-auth-security.md)
## Configuration
diff --git a/docs/guides/web-auth-security.md b/docs/guides/web-auth-security.md
new file mode 100644
index 00000000..8c52a52e
--- /dev/null
+++ b/docs/guides/web-auth-security.md
@@ -0,0 +1,217 @@
+# Web Authentication and CSRF Security Guidelines
+
+These guidelines define the baseline for Harmony web frontends and future operator dashboards that use browser-based authentication, cookie sessions, Axum, HTMX, or OIDC providers such as Zitadel.
+
+## Goals
+
+- Prevent unauthenticated access.
+- Prevent authenticated users from performing actions they are not authorized to perform.
+- Prevent CSRF on state-changing endpoints.
+- Reduce XSS impact with CSP and safe rendering practices.
+- Keep authentication code understandable and reusable across projects.
+
+## Required Baseline
+
+Every browser-facing authenticated application must implement the following controls before production use:
+
+1. **OIDC Authorization Code + PKCE** for login.
+2. **OIDC nonce validation** on login callback.
+3. **Explicit authorization checks** using roles, groups, claims, or permissions.
+4. **CSRF protection** on all mutating routes.
+5. **Secure cookie settings**: `HttpOnly`, `Secure` in production, constrained `SameSite`, and appropriate path/domain scoping.
+6. **Strict security headers**, especially Content Security Policy.
+7. **No permissive credentialed CORS** for operator dashboards.
+8. **Generic client-facing errors** with detailed errors logged server-side only.
+
+## OIDC Login Requirements
+
+Use Authorization Code flow with PKCE. On login start, generate and persist a short-lived login attempt containing:
+
+- `state`
+- `pkce_code_verifier`
+- `nonce`
+- creation timestamp or cookie expiration
+
+Send `state`, PKCE challenge, and `nonce` to the authorization endpoint.
+
+On callback:
+
+1. Require a valid login-attempt cookie.
+2. Validate returned `state` against the stored state.
+3. Exchange the authorization code using the stored PKCE verifier.
+4. Validate the returned ID token as an OIDC ID token, including:
+ - signature
+ - issuer
+ - audience/client ID
+ - expiration/not-before
+ - nonce
+ - authorized party (`azp`) when applicable
+5. Create the application session only after all checks pass.
+6. Delete the login-attempt cookie.
+
+`state` and `nonce` are not interchangeable:
+
+- `state` binds the callback redirect to the browser login attempt.
+- `nonce` binds the returned ID token to the browser login attempt.
+- PKCE binds the code exchange to the client that started the flow.
+
+## Session Requirements
+
+For small internal dashboards, a verified short-lived ID token in an `HttpOnly` cookie may be acceptable. For higher-risk systems, prefer server-side sessions:
+
+- Store a random session ID in the browser cookie.
+- Store tokens and session metadata server-side.
+- Support revocation, rotation, idle timeout, and absolute timeout.
+
+Session cookies must use:
+
+- `HttpOnly`
+- `Secure` outside local development
+- `SameSite=Lax` or `SameSite=Strict`
+- `Path=/` unless a narrower path is possible
+- No broad `Domain` attribute unless explicitly required
+
+Production services should fail closed if HTTPS/secure-cookie configuration is inconsistent.
+
+## Authorization Requirements
+
+Authentication is not authorization. A valid identity provider token only proves who the user is.
+
+Every protected application must define required permissions for each state-changing or sensitive route. Examples:
+
+- `fleet:viewer` for read-only dashboard access
+- `fleet:operator` for alert acknowledgement and operational actions
+- `fleet:admin` for settings, user management, or destructive actions
+
+Authorization must be enforced server-side. UI hiding is not sufficient.
+
+## CSRF Protection Standard
+
+For Axum + HTMX dashboards, the recommended baseline is:
+
+1. Require a custom header on all mutating requests.
+2. Validate `Origin` or `Referer` against the configured application origin.
+3. Keep cookies `SameSite=Lax` or stricter.
+4. Do not enable permissive credentialed CORS.
+
+Mutating methods are:
+
+- `POST`
+- `PUT`
+- `PATCH`
+- `DELETE`
+
+Recommended behavior:
+
+- Reject mutating requests without `x-csrf-token`.
+- Reject mutating requests whose `Origin` is present and does not match the configured base URL origin.
+- If `Origin` is absent, require `Referer` to match the configured base URL origin.
+- Reject when neither `Origin` nor `Referer` is available, unless the route is explicitly exempted and documented.
+
+The CSRF header value may be static for HTMX dashboards, for example `x-csrf-token: 1`. The protection comes from the fact that cross-origin HTML forms cannot set custom headers, and cross-origin JavaScript cannot send custom headers with credentials unless CORS allows it.
+
+Do not rely on header presence alone if adding Origin/Referer validation is practical.
+
+## HTMX Integration
+
+Add the CSRF header globally from a static JavaScript file:
+
+```js
+document.body.addEventListener('htmx:configRequest', (event) => {
+ event.detail.headers['x-csrf-token'] = '1';
+});
+```
+
+Serve this as a static asset, for example `/static/app.js`. Avoid inline scripts so that the application can use a strict CSP without `unsafe-inline`.
+
+## Content Security Policy
+
+Every browser-facing dashboard should set a restrictive CSP. A good starting point is:
+
+```http
+Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
+```
+
+Meaning:
+
+- Only load scripts, styles, and API/SSE/HTMX connections from the same origin.
+- Prevent clickjacking with `frame-ancestors 'none'`.
+- Prevent plugin/object execution with `object-src 'none'`.
+- Prevent injected `` tags from rewriting relative URLs.
+- Prevent forms from submitting to external origins.
+
+If inline scripts or styles are unavoidable, prefer per-response nonces over `unsafe-inline`.
+
+## Other Security Headers
+
+Set these headers on all HTML responses, or globally when safe:
+
+```http
+X-Content-Type-Options: nosniff
+Referrer-Policy: same-origin
+Permissions-Policy: geolocation=(), microphone=(), camera=()
+```
+
+When the service is HTTPS-only, also set HSTS:
+
+```http
+Strict-Transport-Security: max-age=31536000; includeSubDomains
+```
+
+Only enable HSTS when the domain and subdomains are intended to be HTTPS-only.
+
+## CORS Policy
+
+Operator dashboards should normally not enable CORS.
+
+Never combine all of the following unless there is a reviewed, explicit integration need:
+
+- credentialed requests
+- arbitrary or reflected origins
+- custom request headers such as `x-csrf-token`
+
+A permissive credentialed CORS policy can bypass custom-header CSRF protection.
+
+## Error Handling
+
+Client-facing auth errors should be generic, for example:
+
+```text
+Authentication failed. Please start login again.
+```
+
+Detailed causes, provider responses, token validation failures, and stack traces should be logged server-side only.
+
+Avoid returning raw OIDC provider error bodies or JWT validation details to the browser.
+
+## Implementation Checklist
+
+Before shipping a Harmony web frontend:
+
+- [ ] Login uses Authorization Code + PKCE.
+- [ ] Login attempt stores `state`, PKCE verifier, `nonce`, and expires quickly.
+- [ ] Callback validates `state`.
+- [ ] Callback validates ID token nonce.
+- [ ] JWT validation checks issuer and exact intended audience/client.
+- [ ] Authorization roles/permissions are enforced server-side.
+- [ ] Mutating routes are protected by CSRF middleware.
+- [ ] CSRF middleware requires custom header and same-origin `Origin`/`Referer`.
+- [ ] Session cookies are `HttpOnly`, `Secure` in production, and `SameSite=Lax` or stricter.
+- [ ] No permissive credentialed CORS is enabled.
+- [ ] CSP is configured without `unsafe-inline` where practical.
+- [ ] Security headers are configured.
+- [ ] Auth errors shown to users are generic.
+- [ ] Detailed auth failures are logged server-side.
+
+## Recommended Default for Harmony Dashboards
+
+For current and future Axum + HTMX dashboards, use this default design:
+
+- Zitadel/OIDC Authorization Code + PKCE + nonce.
+- Short-lived encrypted login-attempt cookie.
+- Server-side authorization middleware based on roles/claims.
+- `HttpOnly`, `Secure`, `SameSite=Lax` or `Strict` session cookie.
+- CSRF middleware requiring `x-csrf-token` and same-origin `Origin`/`Referer`.
+- Static `/static/app.js` that adds the HTMX CSRF header.
+- Strict CSP that allows scripts only from `self`.
+- No CORS unless explicitly reviewed.
diff --git a/examples/fleet_auth_callout/tests/security_model.rs b/examples/fleet_auth_callout/tests/security_model.rs
index 9b1d05c8..80a54730 100644
--- a/examples/fleet_auth_callout/tests/security_model.rs
+++ b/examples/fleet_auth_callout/tests/security_model.rs
@@ -59,6 +59,7 @@ async fn connect_with_role(stack: &StackHandles, key_json: &str) -> Result Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
let stack = shared_stack().await?;
@@ -84,6 +85,7 @@ async fn admin_can_read_any_device_subject() -> Result<()> {
}
#[tokio::test]
+#[ignore = "requires k3d + docker environment"]
async fn device_can_only_access_own_subjects() -> Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
let stack = shared_stack().await?;
@@ -114,6 +116,7 @@ async fn device_can_only_access_own_subjects() -> Result<()> {
}
#[tokio::test]
+#[ignore = "requires k3d + docker environment"]
async fn unknown_role_is_rejected() -> Result<()> {
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
let stack = shared_stack().await?;
diff --git a/fleet/harmony-fleet-operator/Cargo.toml b/fleet/harmony-fleet-operator/Cargo.toml
index d4ef4d28..f272a067 100644
--- a/fleet/harmony-fleet-operator/Cargo.toml
+++ b/fleet/harmony-fleet-operator/Cargo.toml
@@ -11,12 +11,13 @@ default = []
# build time when the standalone `tailwindcss` CLI is on PATH; otherwise
# the bundled CSS is empty and `--css-from ` must be used at runtime
# (the sidecar-watch dev workflow does this).
-web-frontend = ["dep:axum", "dep:maud", "dep:tokio-stream"]
+web-frontend = ["dep:axum", "dep:axum-extra", "dep:maud", "dep:tokio-stream", "harmony_zitadel_auth/axum"]
[dependencies]
harmony = { path = "../../harmony", features = ["podman"] }
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
+harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" }
toml = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
kube = { workspace = true, features = ["runtime", "derive"] }
@@ -33,7 +34,12 @@ clap.workspace = true
futures-util = { workspace = true }
thiserror.workspace = true
async-trait.workspace = true
+url.workspace = true
+base64.workspace = true
+reqwest.workspace = true
axum = { version = "0.8", optional = true }
+axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
maud = { version = "0.27", features = ["axum"], optional = true }
tokio-stream = { version = "0.1", optional = true }
+dotenvy = "0.15"
diff --git a/fleet/harmony-fleet-operator/src/frontend/assets.rs b/fleet/harmony-fleet-operator/src/frontend/assets.rs
index 627bd85f..1b0be691 100644
--- a/fleet/harmony-fleet-operator/src/frontend/assets.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/assets.rs
@@ -8,3 +8,4 @@
pub const TAILWIND_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/tailwind.css"));
pub const HTMX_JS: &[u8] = include_bytes!("../../vendor/htmx.min.js");
pub const HTMX_SSE_JS: &[u8] = include_bytes!("../../vendor/htmx-ext-sse.js");
+pub const APP_JS: &[u8] = include_bytes!("../../vendor/app.js");
diff --git a/fleet/harmony-fleet-operator/src/frontend/auth.rs b/fleet/harmony-fleet-operator/src/frontend/auth.rs
new file mode 100644
index 00000000..e3d62567
--- /dev/null
+++ b/fleet/harmony-fleet-operator/src/frontend/auth.rs
@@ -0,0 +1,6 @@
+pub use harmony_zitadel_auth::{JwksCache, VerifiedSession as DashboardSession};
+
+pub use harmony_zitadel_auth::axum_login_flow::{
+ HARMONY_SESSION_COOKIE as DASHBOARD_SESSION_COOKIE, callback_handler, login_handler,
+ logout_handler,
+};
diff --git a/fleet/harmony-fleet-operator/src/frontend/layout.rs b/fleet/harmony-fleet-operator/src/frontend/layout.rs
index be075ae7..7e039d58 100644
--- a/fleet/harmony-fleet-operator/src/frontend/layout.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/layout.rs
@@ -1,8 +1,26 @@
-//! Page shell — ``, ``, top nav, body slot.
-
use maud::{DOCTYPE, Markup, PreEscaped, html};
-pub fn page(title: &str, live_reload: bool, content: Markup) -> Markup {
+use crate::frontend::auth::DashboardSession;
+
+// ── Inline SVG icons ────────────────────────────────────────────────────
+
+const ICON_DASHBOARD: &str = r#""#;
+const ICON_DEVICES: &str = r#""#;
+const ICON_DEPLOY: &str = r#""#;
+const ICON_BELL: &str = r#""#;
+const ICON_COG: &str = r#""#;
+const ICON_LOGOUT: &str = r#""#;
+const ICON_BRAND: &str = r#""#;
+
+/// Render a full page with sidebar + topbar layout.
+pub fn page(
+ title: &str,
+ live_reload: bool,
+ current_path: &str,
+ session: Option<&DashboardSession>,
+ unacked_alerts: usize,
+ content: Markup,
+) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
@@ -13,30 +31,179 @@ pub fn page(title: &str, live_reload: bool, content: Markup) -> Markup {
link rel="stylesheet" href="/static/tailwind.css";
script src="/static/htmx.min.js" defer {}
script src="/static/htmx-ext-sse.js" defer {}
+ script src="/static/app.js" defer {}
@if live_reload {
script { (PreEscaped(LIVE_RELOAD_JS)) }
}
}
- body class="min-h-screen bg-slate-950 text-slate-100" hx-ext="sse" {
- header class="border-b border-slate-800 px-6 py-4 flex items-baseline gap-6" {
- h1 class="text-xl font-semibold" { "Harmony Fleet Operator" }
- nav class="flex gap-4 text-sm text-slate-400" {
- a href="/" class="hover:text-slate-100" { "Dashboard" }
- a href="/devices" class="hover:text-slate-100" { "Devices" }
- a href="/deployments" class="hover:text-slate-100" { "Deployments" }
- }
- @if live_reload {
- span class="ml-auto text-xs text-amber-400" { "dev · live reload" }
+ body class="min-h-screen" hx-ext="sse" style="background:var(--bg); color:#e2e8f0; font-family:'Inter',sans-serif" {
+ div class="flex h-screen overflow-hidden" style="background:var(--bg)" {
+ (sidebar(current_path, session, unacked_alerts))
+ main class="flex-1 min-w-0 flex flex-col overflow-hidden" {
+ (topbar(title, unacked_alerts))
+ div class="flex-1 overflow-y-auto grid-bg" { (content) }
}
}
- main class="p-6 space-y-8" { (content) }
+ div id="modal-root" {}
}
}
}
}
-/// Tiny inline script: reconnects an EventSource to `/__dev/reload`;
-/// when the server comes back up after a restart, reload the page.
+fn sidebar(
+ current_path: &str,
+ session: Option<&DashboardSession>,
+ unacked_alerts: usize,
+) -> Markup {
+ let nav_items: [(&str, &str, &str, usize); 5] = [
+ ("/", ICON_DASHBOARD, "Dashboard", 0),
+ ("/devices", ICON_DEVICES, "Devices", 0),
+ ("/deployments", ICON_DEPLOY, "Deployments", 0),
+ ("/alerts", ICON_BELL, "Alerts", unacked_alerts),
+ ("/settings", ICON_COG, "Settings", 0),
+ ];
+
+ html! {
+ aside class="shrink-0 flex flex-col border-r w-[224px]" style="border-color:var(--border); background:var(--bg)" {
+ div class="flex items-center justify-between px-4 py-4 border-b" style="border-color:var(--border)" {
+ div class="flex items-center gap-2" {
+ div class="relative w-6 h-6 rounded-md flex items-center justify-center" style="background:var(--accent); color:#0c0c0c" {
+ (PreEscaped(ICON_BRAND))
+ }
+ span class="text-sm font-semibold tracking-tight text-slate-100" { "Harmony Fleet" }
+ }
+ }
+
+ nav class="flex-1 px-2 py-3 space-y-0.5" {
+ @for (href, icon, label, badge) in &nav_items {
+ @let active = is_active(current_path, href);
+ a
+ href=(*href)
+ class={"group w-full flex items-center gap-2.5 px-2.5 h-9 rounded-md text-[13px] transition-colors duration-150 relative "
+ (if active { "text-slate-100 font-medium" } else { "text-slate-400 hover:text-slate-100" })}
+ style={(if active { "background:rgba(148,163,184,0.06)" } else { "background:transparent" })}
+ {
+ @if active {
+ span class="absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-r" style="background:var(--accent)" {}
+ }
+ span class={(if active { "text-slate-100" } else { "text-slate-500 group-hover:text-slate-300" })} {
+ (PreEscaped(icon))
+ }
+ span class="flex-1 text-left" { (label) }
+ @if *badge > 0 {
+ span class="inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[10px] font-semibold px-1" style="background:var(--bad); color:#0c0c0c" {
+ (badge)
+ }
+ }
+ }
+ }
+ }
+
+ @if let Some(s) = session {
+ div class="border-t p-3" style="border-color:var(--border)" {
+ (user_footer(s))
+ }
+ }
+ }
+ }
+}
+
+fn user_footer(session: &DashboardSession) -> Markup {
+ let initials = session
+ .name
+ .as_deref()
+ .and_then(|name| {
+ let s: String = name
+ .split_whitespace()
+ .filter_map(|w| w.chars().next())
+ .collect::()
+ .to_uppercase();
+ if s.is_empty() { None } else { Some(s) }
+ })
+ .unwrap_or_else(|| {
+ session
+ .subject
+ .chars()
+ .take(2)
+ .collect::()
+ .to_uppercase()
+ });
+
+ let display = session
+ .name
+ .as_deref()
+ .or(session.email.as_deref())
+ .unwrap_or(&session.subject);
+
+ html! {
+ div class="flex items-center gap-3" {
+ div class="flex items-center justify-center w-8 h-8 rounded-full text-[11px] font-medium text-slate-300 shrink-0" style="background:rgba(148,163,184,0.1)" {
+ (initials)
+ }
+ div class="min-w-0" {
+ p class="text-[13px] text-slate-200 truncate leading-tight" { (display) }
+ @if let Some(email) = session.email.as_deref() {
+ p class="text-[11px] text-slate-500 truncate leading-tight" { (email) }
+ }
+ }
+ }
+ a
+ href="/logout"
+ class="mt-2 flex items-center gap-1.5 text-[11px] text-slate-500 hover:text-rose-400 transition-colors"
+ {
+ (PreEscaped(ICON_LOGOUT))
+ span { "Log out" }
+ }
+ }
+}
+
+fn topbar(title: &str, unacked_alerts: usize) -> Markup {
+ html! {
+ div class="flex items-center justify-between px-6 h-14 border-b shrink-0" style="border-color:var(--border); background:var(--bg)" {
+ div class="flex items-center gap-3 min-w-0" {
+ div class="min-w-0" {
+ h1 class="text-[15px] font-semibold text-slate-100 flex items-center gap-2 truncate" {
+ (title)
+ }
+ }
+ }
+ div class="flex items-center gap-2" {
+ div class="relative" {
+ input
+ class="input w-64"
+ type="text"
+ name="search"
+ placeholder="Search devices, deployments\u{2026}"
+ hx-get="/devices/search"
+ hx-trigger="keyup changed delay:300ms"
+ hx-target="#device-table-wrapper"
+ hx-swap="innerHTML";
+ }
+ a href="/alerts" class="relative btn btn-ghost py-1.5" {
+ (PreEscaped(ICON_BELL))
+ span { "Alerts" }
+ @if unacked_alerts > 0 {
+ span class="ml-1 inline-flex items-center justify-center min-w-[18px] h-[18px] rounded-full text-[10px] font-semibold px-1" style="background:var(--bad); color:#0c0c0c" {
+ (unacked_alerts)
+ }
+ }
+ }
+ a href="/settings" class="btn btn-ghost py-1.5" title="Settings" {
+ (PreEscaped(ICON_COG))
+ }
+ }
+ }
+ }
+}
+
+fn is_active(current: &str, href: &str) -> bool {
+ if href == "/" {
+ current == "/"
+ } else {
+ current.starts_with(href)
+ }
+}
+
const LIVE_RELOAD_JS: &str = r#"
(function(){
let connected = false;
diff --git a/fleet/harmony-fleet-operator/src/frontend/mod.rs b/fleet/harmony-fleet-operator/src/frontend/mod.rs
index c14d8059..3735b677 100644
--- a/fleet/harmony-fleet-operator/src/frontend/mod.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/mod.rs
@@ -9,6 +9,7 @@
//! future CLI.
pub mod assets;
+pub mod auth;
pub mod layout;
pub mod server;
pub mod views;
diff --git a/fleet/harmony-fleet-operator/src/frontend/server.rs b/fleet/harmony-fleet-operator/src/frontend/server.rs
index 588404d3..035362b2 100644
--- a/fleet/harmony-fleet-operator/src/frontend/server.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/server.rs
@@ -7,33 +7,64 @@ use std::time::Duration;
use anyhow::Result;
use axum::Router;
use axum::body::Body;
-use axum::extract::{Path, State};
-use axum::http::{StatusCode, header};
+use axum::extract::{Extension, FromRef, Path, Query, State};
+use axum::http::Request;
+use axum::http::{HeaderValue, Method, StatusCode, header};
+use axum::middleware::{self, Next};
use axum::response::sse::{Event, KeepAlive, Sse};
-use axum::response::{IntoResponse, Response};
+use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
+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::{HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS};
+use super::assets::{APP_JS, HTMX_JS, HTMX_SSE_JS, TAILWIND_CSS};
use super::layout::page;
-use super::views::{dashboard, deployments as deployments_view, devices as devices_view};
+use super::views::{
+ alerts as alerts_view, dashboard as dashboard_view, deployments as deployments_view,
+ devices as devices_view, settings as settings_view,
+};
+use crate::frontend::auth::{self, DASHBOARD_SESSION_COOKIE, DashboardSession, JwksCache};
use crate::service::FleetService;
+use harmony_zitadel_auth::ZitadelAuthConfig;
-/// Default high port — keeps clear of NATS (4222), k8s API (6443),
-/// and common metrics/webhook ports (8080/9090/9443).
pub const DEFAULT_PORT: u16 = 18080;
#[derive(Clone)]
pub struct AppState {
pub fleet: Arc,
- /// Read Tailwind CSS from this path on every request when set.
- /// Lets a sidecar `tailwindcss --watch` drive iteration without
- /// recompiling the binary.
+ pub cookie_key: Key,
pub css_override: Option,
- /// When true, inject the live-reload script into pages and expose
- /// `/__dev/reload`.
pub live_reload: bool,
+ pub config: ZitadelAuthConfig,
+ pub http_client: reqwest::Client,
+ pub jwks: JwksCache,
+}
+
+impl FromRef for Key {
+ fn from_ref(state: &AppState) -> Self {
+ state.cookie_key.clone()
+ }
+}
+
+impl FromRef for ZitadelAuthConfig {
+ fn from_ref(state: &AppState) -> Self {
+ state.config.clone()
+ }
+}
+
+impl FromRef for reqwest::Client {
+ fn from_ref(state: &AppState) -> Self {
+ state.http_client.clone()
+ }
+}
+
+impl FromRef for JwksCache {
+ fn from_ref(state: &AppState) -> Self {
+ state.jwks.clone()
+ }
}
pub struct Config {
@@ -56,20 +87,197 @@ impl Config {
}
pub fn router(state: AppState) -> Router {
- let mut r = Router::new()
- .route("/", get(dashboard_handler))
- .route("/devices", get(devices_handler))
- .route("/devices/{id}/blacklist", post(blacklist_handler))
- .route("/deployments", get(deployments_handler))
+ let public_routes = Router::new()
+ .route("/login", get(auth::login_handler))
+ .route("/auth/callback", get(auth::callback_handler))
.route("/static/tailwind.css", get(tailwind_css))
.route("/static/htmx.min.js", get(htmx_js))
- .route("/static/htmx-ext-sse.js", get(htmx_sse_js));
+ .route("/static/htmx-ext-sse.js", get(htmx_sse_js))
+ .route("/static/app.js", get(app_js));
+
+ let private_routes = Router::new()
+ // Dashboard
+ .route("/", get(dashboard_handler))
+ // Devices
+ .route("/devices", get(devices_handler))
+ .route("/devices/search", get(devices_search_handler))
+ .route("/devices/{id}/blacklist", post(blacklist_handler))
+ .route("/devices/{id}/logs", get(device_logs_handler))
+ .route("/devices/{id}/logs/stream", get(device_logs_stream_handler))
+ // Device detail
+ .route("/device/{id}", get(device_detail_handler))
+ // Deployments
+ .route("/deployments", get(deployments_handler))
+ .route("/deployment/{id}", get(deployment_handler))
+ // Alerts
+ .route("/alerts", get(alerts_handler))
+ .route("/alerts/{id}/ack", post(ack_alert_handler))
+ // Settings
+ .route("/settings", get(settings_handler))
+ .route("/settings/toggle/{key}", post(settings_toggle_handler))
+ // Logout
+ .route("/logout", get(auth::logout_handler))
+ .route_layer(middleware::from_fn_with_state(state.clone(), csrf_protect))
+ .route_layer(middleware::from_fn_with_state(state.clone(), require_auth));
+
+ let mut r = public_routes.merge(private_routes);
if state.live_reload {
r = r.route("/__dev/reload", get(dev_reload_sse));
}
- r.with_state(state)
+ r.layer(middleware::from_fn_with_state(
+ state.clone(),
+ security_headers,
+ ))
+ .with_state(state)
+}
+
+async fn require_auth(
+ State(state): State,
+ jar: PrivateCookieJar,
+ mut req: Request,
+ next: Next,
+) -> Response {
+ let Some(cookie) = jar.get(DASHBOARD_SESSION_COOKIE) else {
+ return unauthenticated_response(&req);
+ };
+
+ match state.jwks.verify(cookie.value(), &state.config).await {
+ Ok(session) => {
+ req.extensions_mut().insert(session);
+ next.run(req).await
+ }
+ Err(e) => {
+ tracing::warn!(%e, "invalid session cookie");
+ let jar = jar.remove(Cookie::from(DASHBOARD_SESSION_COOKIE));
+ (jar, unauthenticated_response(&req)).into_response()
+ }
+ }
+}
+
+async fn csrf_protect(State(state): State, req: Request, next: Next) -> Response {
+ if !is_mutating_method(req.method()) {
+ return next.run(req).await;
+ }
+
+ if req.headers().get("x-csrf-token").is_none() {
+ return (StatusCode::FORBIDDEN, "CSRF check failed").into_response();
+ }
+
+ if !is_same_origin_request(&req, &state.config.base_url) {
+ return (StatusCode::FORBIDDEN, "CSRF origin check failed").into_response();
+ }
+
+ next.run(req).await
+}
+
+fn is_mutating_method(method: &Method) -> bool {
+ matches!(
+ *method,
+ Method::POST | Method::PUT | Method::PATCH | Method::DELETE
+ )
+}
+
+fn is_same_origin_request(req: &Request, base_url: &str) -> bool {
+ let Ok(expected) = url::Url::parse(base_url) else {
+ tracing::error!(%base_url, "invalid BASE_URL; rejecting mutating request");
+ return false;
+ };
+
+ if let Some(origin) = req
+ .headers()
+ .get(header::ORIGIN)
+ .and_then(|v| v.to_str().ok())
+ {
+ return origin_matches(origin, &expected);
+ }
+
+ req.headers()
+ .get(header::REFERER)
+ .and_then(|v| v.to_str().ok())
+ .is_some_and(|referer| origin_matches(referer, &expected))
+}
+
+fn origin_matches(candidate: &str, expected: &url::Url) -> bool {
+ let Ok(candidate) = url::Url::parse(candidate) else {
+ return false;
+ };
+
+ candidate.scheme() == expected.scheme()
+ && candidate.host_str() == expected.host_str()
+ && candidate.port_or_known_default() == expected.port_or_known_default()
+}
+
+async fn security_headers(
+ State(state): State,
+ req: Request,
+ next: Next,
+) -> Response {
+ let mut response = next.run(req).await;
+ let headers = response.headers_mut();
+
+ let csp = if state.live_reload {
+ "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
+ } else {
+ "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
+ };
+
+ headers.insert(
+ header::CONTENT_SECURITY_POLICY,
+ HeaderValue::from_static(csp),
+ );
+ headers.insert(
+ header::X_CONTENT_TYPE_OPTIONS,
+ HeaderValue::from_static("nosniff"),
+ );
+ headers.insert(
+ header::REFERRER_POLICY,
+ HeaderValue::from_static("same-origin"),
+ );
+ headers.insert(
+ "Permissions-Policy",
+ HeaderValue::from_static("geolocation=(), microphone=(), camera=()"),
+ );
+
+ if state.config.use_secure_cookies() {
+ headers.insert(
+ header::STRICT_TRANSPORT_SECURITY,
+ HeaderValue::from_static("max-age=31536000; includeSubDomains"),
+ );
+ }
+
+ response
+}
+
+fn unauthenticated_response(req: &Request) -> Response {
+ if is_sse_request(req) {
+ return (StatusCode::UNAUTHORIZED, "authentication required").into_response();
+ }
+
+ if is_htmx_request(req) {
+ return Response::builder()
+ .status(StatusCode::UNAUTHORIZED)
+ .header("HX-Redirect", "/login")
+ .body(Body::empty())
+ .expect("well-formed HTMX auth response");
+ }
+
+ Redirect::to("/login").into_response()
+}
+
+fn is_htmx_request(req: &Request) -> bool {
+ req.headers()
+ .get("HX-Request")
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value == "true")
+}
+
+fn is_sse_request(req: &Request) -> bool {
+ req.headers()
+ .get(header::ACCEPT)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| value.contains("text/event-stream"))
}
pub async fn run(cfg: Config) -> Result<()> {
@@ -80,27 +288,356 @@ pub async fn run(cfg: Config) -> Result<()> {
Ok(())
}
-// ---- handlers: each is a 3-liner: extract state, call service, render. ----
+// ── Dashboard ──────────────────────────────────────────────────────────
-async fn dashboard_handler(State(s): State) -> Result {
- let summary = s.fleet.dashboard_summary().await?;
- Ok(page("Dashboard", s.live_reload, dashboard::page(&summary)))
+async fn dashboard_handler(
+ State(s): State,
+ session: Option>,
+) -> Result {
+ let detail = s.fleet.dashboard_detail().await?;
+ let unacked = detail.active_alerts.iter().filter(|a| !a.acked).count();
+ Ok(page(
+ "Dashboard",
+ s.live_reload,
+ "/",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ dashboard_view::page(&detail),
+ ))
}
-async fn devices_handler(State(s): State) -> Result {
- let devices = s.fleet.list_devices().await?;
- Ok(page("Devices", s.live_reload, devices_view::page(&devices)))
+// ── Devices ────────────────────────────────────────────────────────────
+
+#[derive(Deserialize, Default)]
+struct DevicesQuery {
+ status: Option,
+ deployment: Option,
+ region: Option,
+ search: Option,
}
-async fn deployments_handler(State(s): State) -> Result {
+async fn devices_handler(
+ State(s): State,
+ Query(q): Query,
+ session: Option>,
+) -> Result {
+ let status = q.status.as_deref().and_then(|s| parse_device_status(s));
+
+ let devices = s
+ .fleet
+ .filtered_devices(
+ status,
+ q.deployment.clone(),
+ q.region.clone(),
+ q.search.clone(),
+ )
+ .await?;
+
+ let all_devices = s.fleet.list_devices().await?;
+ let all_regions: Vec = {
+ let mut r: Vec = all_devices.iter().map(|d| d.region.clone()).collect();
+ r.sort();
+ r.dedup();
+ r
+ };
+ let all_deployments: Vec = {
+ let deps = s.fleet.list_deployments().await?;
+ deps.into_iter().map(|d| d.name).collect()
+ };
+
+ let unacked = s
+ .fleet
+ .list_alerts()
+ .await?
+ .iter()
+ .filter(|a| !a.acked)
+ .count();
+
+ Ok(page(
+ "Devices",
+ s.live_reload,
+ "/devices",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ devices_view::page(
+ &devices,
+ &all_regions,
+ &all_deployments,
+ status,
+ q.deployment.as_deref(),
+ q.region.as_deref(),
+ q.search.as_deref(),
+ ),
+ ))
+}
+
+async fn devices_search_handler(
+ State(s): State,
+ Query(q): Query,
+) -> Result {
+ let status = q.status.as_deref().and_then(|s| parse_device_status(s));
+
+ let devices = s
+ .fleet
+ .filtered_devices(
+ status,
+ q.deployment.clone(),
+ q.region.clone(),
+ q.search.clone(),
+ )
+ .await?;
+
+ Ok(devices_view::page(
+ &devices,
+ &[],
+ &[],
+ status,
+ q.deployment.as_deref(),
+ q.region.as_deref(),
+ q.search.as_deref(),
+ ))
+}
+
+// ── Device detail ──────────────────────────────────────────────────────
+
+#[derive(Deserialize, Default)]
+struct DeviceDetailQuery {
+ tab: Option,
+}
+
+async fn device_detail_handler(
+ State(s): State,
+ Path(id): Path,
+ Query(q): Query,
+ session: Option>,
+) -> Result {
+ let device = s
+ .fleet
+ .get_device(&id)
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("device not found: {id}"))?;
+
+ let deployment_version = if let Some(ref dep_name) = device.deployment {
+ s.fleet.get_deployment(dep_name).await?.map(|d| d.version)
+ } else {
+ None
+ };
+
+ let tab = q.tab.as_deref().unwrap_or("overview");
+
+ // If a specific tab is requested via query param, return tab content only (for HTMX)
+ if q.tab.is_some() {
+ return Ok(devices_view::tab_content(
+ &device,
+ tab,
+ deployment_version.as_deref(),
+ ));
+ }
+
+ let unacked = s
+ .fleet
+ .list_alerts()
+ .await?
+ .iter()
+ .filter(|a| !a.acked)
+ .count();
+
+ Ok(page(
+ &device.id,
+ s.live_reload,
+ "/devices",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ devices_view::detail(&device, deployment_version.as_deref()),
+ ))
+}
+
+// ── Deployments ────────────────────────────────────────────────────────
+
+async fn deployments_handler(
+ State(s): State,
+ session: Option>,
+) -> Result {
let deployments = s.fleet.list_deployments().await?;
+ let unacked = s
+ .fleet
+ .list_alerts()
+ .await?
+ .iter()
+ .filter(|a| !a.acked)
+ .count();
+
Ok(page(
"Deployments",
s.live_reload,
+ "/deployments",
+ session.as_ref().map(|e| &e.0),
+ unacked,
deployments_view::page(&deployments),
))
}
+// ── Deployment detail ──────────────────────────────────────────────────
+
+#[derive(Deserialize, Default)]
+struct DeploymentQuery {
+ tab: Option,
+ task_view: Option,
+}
+
+async fn deployment_handler(
+ State(s): State,
+ Path(id): Path,
+ Query(q): Query,
+ session: Option>,
+) -> Result {
+ let deployment = s
+ .fleet
+ .get_deployment(&id)
+ .await?
+ .ok_or_else(|| anyhow::anyhow!("deployment not found: {id}"))?;
+
+ let devices = s.fleet.get_deployment_devices(&id).await?;
+ let task_graph = s.fleet.get_task_graph(&id).await?;
+ let task_view = q.task_view.as_deref().unwrap_or("linear");
+ let tab = q.tab.as_deref().unwrap_or("overview");
+ let unacked = s
+ .fleet
+ .list_alerts()
+ .await?
+ .iter()
+ .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,
+ &task_graph,
+ task_view,
+ tab,
+ ))
+ } else {
+ Ok(page(
+ &deployment.name,
+ s.live_reload,
+ "/deployments",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ deployments_view::detail(&deployment, &devices, &task_graph, task_view),
+ ))
+ }
+}
+
+// ── Alerts ─────────────────────────────────────────────────────────────
+
+async fn alerts_handler(
+ State(s): State,
+ session: Option>,
+) -> Result {
+ let alerts = s.fleet.list_alerts().await?;
+ let unacked = alerts.iter().filter(|a| !a.acked).count();
+
+ Ok(page(
+ "Alerts",
+ s.live_reload,
+ "/alerts",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ alerts_view::page(&alerts),
+ ))
+}
+
+async fn ack_alert_handler(
+ State(s): State,
+ Path(id): Path,
+) -> Result {
+ s.fleet.ack_alert(&id).await?;
+ let alerts = s.fleet.list_alerts().await?;
+ let alert = alerts
+ .iter()
+ .find(|a| a.id == id)
+ .ok_or_else(|| anyhow::anyhow!("alert not found: {id}"))?;
+
+ // Return just the row (for HTMX swap)
+ Ok(alerts_view::alert_row(alert))
+}
+
+// ── Settings ───────────────────────────────────────────────────────────
+
+async fn settings_handler(
+ State(s): State,
+ session: Option>,
+) -> Result {
+ let unacked = s
+ .fleet
+ .list_alerts()
+ .await?
+ .iter()
+ .filter(|a| !a.acked)
+ .count();
+
+ Ok(page(
+ "Settings",
+ s.live_reload,
+ "/settings",
+ session.as_ref().map(|e| &e.0),
+ unacked,
+ settings_view::page(),
+ ))
+}
+
+async fn settings_toggle_handler(Path(_key): Path) -> Result {
+ // In a real app this would toggle a notification channel.
+ // For the mock, we return the same static content.
+ Ok(settings_view::page())
+}
+
+// ── Device logs ────────────────────────────────────────────────────────
+
+async fn device_logs_handler(Path(id): Path) -> Result {
+ Ok(devices_view::logs_modal(&id))
+}
+
+async fn device_logs_stream_handler(
+ 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))
+ },
+ );
+
+ Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
+}
+
+// ── Blacklist ──────────────────────────────────────────────────────────
+
async fn blacklist_handler(
State(s): State,
Path(id): Path,
@@ -109,7 +646,21 @@ async fn blacklist_handler(
Ok(devices_view::row(&updated))
}
-// ---- static assets ----
+// ── Helpers ────────────────────────────────────────────────────────────
+
+fn parse_device_status(s: &str) -> Option {
+ match s {
+ "healthy" => Some(crate::service::DeviceStatus::Healthy),
+ "pending" => Some(crate::service::DeviceStatus::Pending),
+ "failing" => Some(crate::service::DeviceStatus::Failing),
+ "stale" => Some(crate::service::DeviceStatus::Stale),
+ "blacklisted" => Some(crate::service::DeviceStatus::Blacklisted),
+ "unknown" => Some(crate::service::DeviceStatus::Unknown),
+ _ => None,
+ }
+}
+
+// ── Static assets ──────────────────────────────────────────────────────
async fn tailwind_css(State(s): State) -> Response {
let css: Vec = match &s.css_override {
@@ -136,6 +687,10 @@ async fn htmx_sse_js() -> Response {
)
}
+async fn app_js() -> Response {
+ static_response(APP_JS.to_vec(), "application/javascript; charset=utf-8")
+}
+
fn static_response(bytes: Vec, content_type: &'static str) -> Response {
Response::builder()
.status(StatusCode::OK)
@@ -144,19 +699,15 @@ fn static_response(bytes: Vec, content_type: &'static str) -> Response {
.expect("well-formed static response")
}
-// ---- dev live-reload SSE ----
+// ── Dev live-reload SSE ────────────────────────────────────────────────
async fn dev_reload_sse() -> Sse>> {
- // We never send actual reload events from here. The browser-side
- // pattern is simpler: on EventSource reconnect after the server
- // came back up, reload the page. So all we do is hold the
- // connection open with keep-alive pings.
let stream = tokio_stream::iter([Ok::<_, Infallible>(Event::default().data("ready"))])
.chain(tokio_stream::pending());
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
}
-// ---- error type ----
+// ── Error type ─────────────────────────────────────────────────────────
pub struct AppError(anyhow::Error);
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs b/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs
new file mode 100644
index 00000000..d3db66be
--- /dev/null
+++ b/fleet/harmony-fleet-operator/src/frontend/views/alerts.rs
@@ -0,0 +1,125 @@
+use maud::{Markup, html};
+
+use crate::frontend::views::badges;
+use crate::service::Alert;
+
+pub fn page(alerts: &[Alert]) -> Markup {
+ let unacked = alerts.iter().filter(|a| !a.acked).count();
+ html! {
+ div class="p-6 space-y-4" {
+ div class="flex items-center gap-2" {
+ h2 class="text-[15px] font-semibold text-slate-200" { "Alerts" }
+ span class="text-[11px] text-slate-500" { "\u{b7} " (unacked) " unacked" }
+ div class="flex-1" {}
+ button class="btn btn-ghost" { "Ack all" }
+ }
+ div class="card card-flush" {
+ table class="tbl" {
+ thead {
+ tr {
+ th style="width:32px" {}
+ th { "Severity" }
+ th { "Alert" }
+ th { "Source" }
+ th { "Time" }
+ th class="text-right" { "Action" }
+ }
+ }
+ tbody {
+ @for a in alerts {
+ tr class={(if a.acked { "opacity-50" } else { "" })} {
+ td {
+ @if !a.acked {
+ span class="w-1.5 h-1.5 rounded-full block"
+ style={"background:" (severity_color(a.severity))} {}
+ }
+ }
+ td { (badges::severity_pill(a.severity)) }
+ td class="text-slate-200" { (&a.title) }
+ td class="font-mono text-[12px] text-slate-400 whitespace-nowrap" {
+ @if let Some(dep) = &a.deployment { (dep) }
+ @else if let Some(dev) = &a.device { (dev) }
+ @else { "system" }
+ }
+ td class="text-[12px] text-slate-500 tabular-nums" { (&a.at) }
+ td class="text-right" {
+ div class="inline-flex items-center gap-1" {
+ @if a.deployment.is_some() {
+ a href={"/deployment/" (a.deployment.as_deref().unwrap())} class="btn btn-ghost py-1" {
+ "Open"
+ }
+ } @else if a.device.is_some() && a.deployment.is_none() {
+ a href={"/device/" (a.device.as_deref().unwrap())} class="btn btn-ghost py-1" {
+ "Open"
+ }
+ }
+ @if !a.acked {
+ button
+ class="btn btn-ghost py-1"
+ hx-post={"/alerts/" (a.id) "/ack"}
+ hx-target="closest tr"
+ hx-swap="outerHTML" {
+ "Ack"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+pub fn alert_row(a: &Alert) -> Markup {
+ html! {
+ tr class={(if a.acked { "opacity-50" } else { "" })} {
+ td {
+ @if !a.acked {
+ span class="w-1.5 h-1.5 rounded-full block"
+ style={"background:" (severity_color(a.severity))} {}
+ }
+ }
+ td { (badges::severity_pill(a.severity)) }
+ td class="text-slate-200" { (&a.title) }
+ td class="font-mono text-[12px] text-slate-400 whitespace-nowrap" {
+ @if let Some(dep) = &a.deployment { (dep) }
+ @else if let Some(dev) = &a.device { (dev) }
+ @else { "system" }
+ }
+ td class="text-[12px] text-slate-500 tabular-nums" { (&a.at) }
+ td class="text-right" {
+ div class="inline-flex items-center gap-1" {
+ @if a.deployment.is_some() {
+ a href={"/deployment/" (a.deployment.as_deref().unwrap())} class="btn btn-ghost py-1" {
+ "Open"
+ }
+ } @else if a.device.is_some() && a.deployment.is_none() {
+ a href={"/device/" (a.device.as_deref().unwrap())} class="btn btn-ghost py-1" {
+ "Open"
+ }
+ }
+ @if !a.acked {
+ button
+ class="btn btn-ghost py-1"
+ hx-post={"/alerts/" (a.id) "/ack"}
+ hx-target="closest tr"
+ hx-swap="outerHTML" {
+ "Ack"
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn severity_color(s: crate::service::AlertSeverity) -> &'static str {
+ match s {
+ crate::service::AlertSeverity::Critical => "var(--bad)",
+ crate::service::AlertSeverity::Warning => "var(--warn)",
+ crate::service::AlertSeverity::Info => "var(--info)",
+ }
+}
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/badges.rs b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs
new file mode 100644
index 00000000..e2b1f991
--- /dev/null
+++ b/fleet/harmony-fleet-operator/src/frontend/views/badges.rs
@@ -0,0 +1,91 @@
+use maud::{Markup, html};
+
+use crate::service::{AlertSeverity, DeploymentStatus, DeviceStatus};
+
+pub fn device_status(s: DeviceStatus) -> Markup {
+ let (color, bg, border, label) = match s {
+ DeviceStatus::Healthy => ("var(--ok)", "var(--ok-soft)", "rgba(52,211,153,0.25)", "healthy"),
+ DeviceStatus::Pending => (
+ "var(--warn)",
+ "var(--warn-soft)",
+ "rgba(251,191,36,0.25)",
+ "pending",
+ ),
+ DeviceStatus::Stale => (
+ "var(--bad)",
+ "var(--bad-soft)",
+ "rgba(251,113,133,0.25)",
+ "stale",
+ ),
+ DeviceStatus::Failing => (
+ "var(--bad)",
+ "var(--bad-soft)",
+ "rgba(251,113,133,0.25)",
+ "failing",
+ ),
+ DeviceStatus::Blacklisted => (
+ "#94a3b8",
+ "rgba(148,163,184,0.10)",
+ "rgba(148,163,184,0.25)",
+ "blacklisted",
+ ),
+ DeviceStatus::Unknown => (
+ "#64748b",
+ "rgba(100,116,139,0.10)",
+ "rgba(100,116,139,0.25)",
+ "unknown",
+ ),
+ };
+ status_badge(label, color, bg, border)
+}
+
+pub fn deployment_status(s: DeploymentStatus) -> Markup {
+ let (color, bg, border, label) = match s {
+ DeploymentStatus::Active => ("var(--ok)", "var(--ok-soft)", "#none", "active"),
+ DeploymentStatus::Rolling => ("var(--info)", "var(--info-soft)", "#none", "rolling"),
+ DeploymentStatus::Failing => ("var(--bad)", "var(--bad-soft)", "#none", "failing"),
+ DeploymentStatus::Paused => ("#94a3b8", "rgba(148,163,184,0.10)", "#none", "paused"),
+ };
+
+ let border_style = if border == "#none" {
+ "transparent"
+ } else {
+ border
+ };
+
+ status_badge(label, color, bg, border_style)
+}
+
+pub fn severity_pill(s: AlertSeverity) -> Markup {
+ let (color, bg, icon, label) = match s {
+ AlertSeverity::Critical => ("var(--bad)", "var(--bad-soft)", ICON_ERROR, "critical"),
+ AlertSeverity::Warning => ("var(--warn)", "var(--warn-soft)", ICON_WARNING, "warning"),
+ AlertSeverity::Info => ("var(--info)", "var(--info-soft)", ICON_INFO, "info"),
+ };
+
+ html! {
+ span class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium"
+ style={"background:" (bg) "; color:" (color)} {
+ (PreEscaped(icon))
+ (label)
+ }
+ }
+}
+
+fn status_badge(label: &str, color: &str, bg: &str, border: &str) -> Markup {
+ html! {
+ span class="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[11px] font-medium id-mono"
+ style={"background:" (bg) "; color:" (color) "; border:1px solid " (border)} {
+ span class="inline-block rounded-full" style={"width:5px; height:5px; background:" (color)} {}
+ (label)
+ }
+ }
+}
+
+// ── Tiny inline SVGs for severity icons ────────────────────────────────
+
+const ICON_ERROR: &str = r#""#;
+const ICON_WARNING: &str = r#""#;
+const ICON_INFO: &str = r#""#;
+
+use maud::PreEscaped;
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs
index 1e2a0170..1bae27bf 100644
--- a/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/views/dashboard.rs
@@ -1,35 +1,369 @@
-use maud::{Markup, html};
+use maud::{Markup, PreEscaped, html};
-use crate::service::DashboardSummary;
+use crate::frontend::views::badges;
+use crate::service::DashboardDetail;
-pub fn page(summary: &DashboardSummary) -> Markup {
+// ── 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#""#;
+
+pub fn page(d: &DashboardDetail) -> Markup {
html! {
- section {
- h2 class="text-lg font-medium mb-4 text-slate-300" { "Devices" }
- div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4" {
- (card("Total", &summary.devices_total.to_string(), "text-slate-50"))
- (card("Healthy", &summary.devices_healthy.to_string(), "text-emerald-400"))
- (card("Pending", &summary.devices_pending.to_string(), "text-amber-400"))
- (card("Stale", &summary.devices_stale.to_string(), "text-rose-400"))
- (card("Blacklisted", &summary.devices_blacklisted.to_string(), "text-slate-500"))
+ div class="p-6 space-y-5" {
+ // Alert strip (if there are unacked alerts)
+ @if !d.active_alerts.is_empty() {
+ @let top = &d.active_alerts[0];
+ @let more = d.active_alerts.len().saturating_sub(1);
+ (alert_strip(top, more))
}
+
+ (health_row(d))
+ (lower_row(d))
}
- section {
- h2 class="text-lg font-medium mb-4 text-slate-300" { "Deployments" }
- div class="grid grid-cols-2 sm:grid-cols-3 gap-4" {
- (card("Total", &summary.deployments_total.to_string(), "text-slate-50"))
- (card("Active / Rolling", &summary.deployments_active.to_string(), "text-emerald-400"))
- (card("Failing", &summary.deployments_failing.to_string(), "text-rose-400"))
+ }
+}
+
+fn alert_strip(alert: &crate::service::Alert, more: usize) -> Markup {
+ let is_crit = matches!(alert.severity, crate::service::AlertSeverity::Critical);
+ let border = if is_crit { "rgba(244,63,94,0.3)" } else { "rgba(251,191,36,0.3)" };
+ let bg = if is_crit { "rgba(244,63,94,0.06)" } else { "rgba(251,191,36,0.06)" };
+ let icon_bg = if is_crit { "var(--bad-soft)" } else { "var(--warn-soft)" };
+ let icon_color = if is_crit { "var(--bad)" } else { "var(--warn)" };
+
+ html! {
+ div class="card flex items-center gap-3 px-4 py-3" style={"border-color:" (border) "; background:" (bg)} {
+ span class="inline-flex items-center justify-center w-7 h-7 rounded-md" style={"background:" (icon_bg) "; color:" (icon_color)} {
+ @if is_crit { (PreEscaped(ICON_ERROR)) } @else { (PreEscaped(ICON_WARNING)) }
+ }
+ div class="min-w-0 flex-1" {
+ div class="text-[13px] text-slate-100 leading-snug truncate" { (&alert.title) }
+ div class="text-[11px] text-slate-500 mt-0.5" {
+ (&alert.at)
+ @if more > 0 {
+ span class="ml-2" { "\u{b7} " (more) " more alert" @if more > 1 { "s" } }
+ }
+ }
+ }
+ @if alert.deployment.is_some() {
+ a href={"/deployment/" (alert.deployment.as_deref().unwrap())} class="btn btn-ghost" { "Open deployment" }
+ } @else if alert.device.is_some() {
+ a href={"/device/" (alert.device.as_deref().unwrap())} class="btn btn-ghost" { "Open device" }
+ }
+ button
+ class="btn btn-ghost"
+ hx-post={"/alerts/" (alert.id) "/ack"}
+ hx-swap="none"
+ { "Ack" }
+ }
+ }
+}
+
+fn health_row(d: &DashboardDetail) -> Markup {
+ let health_trend_svg = sparkline_svg(&d.health_trend, "var(--ok)", 180.0, 36.0, "ok");
+ let ingest_trend_svg = sparkline_svg_u32(&d.ingest_trend, "var(--accent)", 240.0, 56.0, "ac");
+
+ html! {
+ div class="grid grid-cols-12 gap-4" {
+ // Big health card
+ div class="col-span-12 lg:col-span-5 card p-5 relative overflow-hidden" {
+ div class="flex items-start justify-between" {
+ div {
+ div class="section-title" { "Fleet Health" }
+ div class="mt-2 flex items-baseline gap-3" {
+ span class="text-[44px] font-semibold tracking-tight tabular-nums leading-none text-slate-50" {
+ (d.health_pct) "%"
+ }
+ span class="text-sm text-slate-400" {
+ "healthy across " (d.devices_total) " devices"
+ }
+ }
+ div class="mt-1.5 flex items-center gap-1.5 text-[11px] text-slate-500" {
+ span class="text-emerald-400" { "\u{25b2} 1.2%" }
+ span { "vs. 24h ago" }
+ }
+ }
+ div class="flex items-center gap-1.5 text-[11px] text-slate-400" {
+ span class="relative inline-flex w-1.5 h-1.5" {
+ span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background:var(--ok)" {}
+ span class="relative inline-flex w-1.5 h-1.5 rounded-full" style="background:var(--ok)" {}
+ }
+ span { "live" }
+ }
+ }
+
+ div class="mt-4" {
+ (segmented_progress(
+ &[
+ (0u32, d.devices_healthy, "var(--ok)", "healthy"),
+ (0u32, d.devices_pending, "var(--warn)", "pending"),
+ (0u32, d.devices_failing, "var(--bad)", "failing"),
+ (0u32, d.devices_stale, "rgba(251,113,133,0.6)", "stale"),
+ (0u32, d.devices_blacklisted, "#475569", "blacklisted"),
+ (0u32, d.devices_unknown, "#334155", "unknown"),
+ ],
+ d.devices_total,
+ 6,
+ ))
+ }
+
+ div class="mt-4 grid grid-cols-3 gap-x-6 gap-y-2 text-[12px]" {
+ (stat("Healthy", d.devices_healthy, "var(--ok)"))
+ (stat("Pending", d.devices_pending, "var(--warn)"))
+ (stat("Failing", d.devices_failing, "var(--bad)"))
+ (stat("Stale", d.devices_stale, "rgba(251,113,133,0.7)"))
+ (stat("Blacklisted", d.devices_blacklisted, "#94a3b8"))
+ (stat("Unknown", d.devices_unknown, "#64748b"))
+ }
+
+ div class="absolute right-5 bottom-3 opacity-90 pointer-events-none" {
+ (PreEscaped(health_trend_svg))
+ div class="text-[10px] text-slate-600 font-mono text-right mt-0.5" { "24h health" }
+ }
+ }
+
+ // Deployment summary
+ div class="col-span-12 lg:col-span-4 card p-5" {
+ div class="flex items-center justify-between" {
+ div {
+ div class="section-title" { "Deployments" }
+ div class="mt-2 text-[28px] font-semibold text-slate-50 tabular-nums leading-none" {
+ (d.deployments_total)
+ }
+ div class="text-[12px] text-slate-500 mt-1" {
+ (d.rolling_count) " rolling out \u{b7} " (d.failing_count) " failing"
+ }
+ }
+ a href="/deployments" class="btn btn-ghost" {
+ (PreEscaped(ICON_PLUS)) " New deployment"
+ }
+ }
+
+ div class="mt-5 space-y-1" {
+ @for dep in &d.top_deployments {
+ a href={"/deployment/" (dep.name)} class="w-full text-left flex items-center gap-3 py-1.5 px-1 rounded hover:bg-white/2.5" {
+ div class="flex-1 min-w-0" {
+ div class="flex items-center gap-2" {
+ span class="font-mono text-[12px] text-slate-200 truncate whitespace-nowrap" { (&dep.name) }
+ span class="font-mono text-[10px] text-slate-500 whitespace-nowrap shrink-0" { (&dep.version) }
+ }
+ div class="mt-1 flex items-center gap-2" {
+ div class="flex-1 max-w-[140px]" {
+ (segmented_progress(
+ &[
+ (0u32, dep.healthy, "var(--ok)", "healthy"),
+ (0u32, dep.pending, "var(--warn)", "pending"),
+ (0u32, dep.failing, "var(--bad)", "failing"),
+ ],
+ dep.target,
+ 3,
+ ))
+ }
+ span class="text-[10px] text-slate-500 tabular-nums" { (dep.healthy) "/" (dep.target) }
+ }
+ }
+ (badges::deployment_status(dep.status))
+ }
+ }
+ }
+ }
+
+ // Ingest rate
+ div class="col-span-12 lg:col-span-3 card p-5" {
+ div class="section-title" { "Ingest rate" }
+ div class="mt-2 flex items-baseline gap-2" {
+ span class="text-[28px] font-semibold text-slate-50 tabular-nums leading-none" { (d.ingest_rate) }
+ span class="text-[12px] text-slate-500" { "k events/min" }
+ }
+ div class="mt-3" {
+ (PreEscaped(ingest_trend_svg))
+ }
+ div class="flex justify-between text-[10px] text-slate-600 font-mono mt-1" {
+ span { "\u{2212}24h" }
+ span { "now" }
+ }
}
}
}
}
-fn card(title: &str, value: &str, value_class: &str) -> Markup {
+fn lower_row(d: &DashboardDetail) -> Markup {
html! {
- div class="rounded-lg border border-slate-800 bg-slate-900 p-4" {
- div class="text-xs uppercase tracking-wide text-slate-400" { (title) }
- div class={"mt-2 text-3xl font-semibold " (value_class)} { (value) }
+ div class="grid grid-cols-12 gap-4" {
+ // Needs attention
+ div class="col-span-12 lg:col-span-7 card" {
+ div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" {
+ div class="flex items-center gap-2" {
+ span class="section-title" { "Needs attention" }
+ span class="text-[10px] text-slate-600 font-mono" {
+ (d.attention_devices.len()) " devices"
+ }
+ }
+ a href="/devices?status=failing" class="text-[11px] text-slate-400 hover:text-slate-100 flex items-center gap-1" {
+ "View all " (PreEscaped(ICON_CHEVRON))
+ }
+ }
+ table class="tbl" {
+ thead {
+ tr {
+ th { "Device" }
+ 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-push-url="true" {
+ td {
+ span class="font-mono text-slate-100 whitespace-nowrap" { (&dev.id) }
+ }
+ td { (badges::device_status(dev.status)) }
+ td class="text-slate-300 font-mono text-[12px] whitespace-nowrap" {
+ @if let Some(dep) = &dev.deployment { (dep) }
+ @else { "\u{2014}" }
+ }
+ 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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Activity feed
+ div class="col-span-12 lg:col-span-5 card" {
+ div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" {
+ span class="section-title" { "Activity" }
+ span class="text-[10px] text-slate-600 font-mono" { "live" }
+ }
+ ul class="px-4 py-1 space-y-0" {
+ @for (i, a) in d.activity_feed.iter().enumerate() {
+ @let border = if i < d.activity_feed.len() - 1 { "border-b" } else { "" };
+ li class={"flex items-start gap-3 py-2.5 text-[13px] " (border)} style="border-color:var(--border)" {
+ span class="font-mono text-[10px] text-slate-600 mt-1 w-10 shrink-0 tabular-nums" { (&a.at) }
+ span class="text-slate-400 leading-snug" {
+ span class={(if a.who == "system" { "text-slate-500" } else { "text-slate-200" })} { (&a.who) }
+ span { " " (a.verb) " " }
+ @if !a.target.is_empty() {
+ span class="font-mono text-slate-300 whitespace-nowrap" { (&a.target) }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
+
+fn stat(label: &str, value: u32, color: &str) -> Markup {
+ html! {
+ div class="flex items-center gap-1.5" {
+ span class="w-1.5 h-1.5 rounded-full" style={"background:" (color)} {}
+ span class="text-slate-500" { (label) }
+ span class="ml-auto font-mono text-slate-200 tabular-nums" { (value) }
+ }
+ }
+}
+
+fn time_ago(minutes: i64) -> String {
+ if minutes < 1 {
+ "just now".into()
+ } else if minutes < 60 {
+ format!("{}m ago", minutes)
+ } else if minutes < 60 * 24 {
+ format!("{}h ago", minutes / 60)
+ } else {
+ format!("{}d ago", minutes / (60 * 24))
+ }
+}
+
+// ── Segmented progress bar ─────────────────────────────────────────────
+
+fn segmented_progress(segments: &[(u32, u32, &str, &str)], total: u32, height: u32) -> Markup {
+ html! {
+ div class="w-full rounded-full overflow-hidden progress-bg flex" style={"height:" (height) "px"} {
+ @for (_cum, val, color, _label) in segments {
+ @let width = if total > 0 {
+ (*val as f64 / total as f64) * 100.0
+ } else { 0.0 };
+ div class="h-full" style={"width:" (width) "%; background:" (color)} {}
+ }
+ }
+ }
+}
+
+// ── Sparkline SVG generators ───────────────────────────────────────────
+
+pub fn sparkline_svg(values: &[f64], color: &str, w: f64, h: f64, prefix: &str) -> String {
+ let max = values.iter().cloned().fold(0.0f64, f64::max).max(1.0);
+ let min = values.iter().cloned().fold(f64::MAX, f64::min).min(0.0);
+ let range = (max - min).max(1.0);
+ let step = w / (values.len().max(2) - 1) as f64;
+ let pts: Vec<(f64, f64)> = values
+ .iter()
+ .enumerate()
+ .map(|(i, &v)| (i as f64 * step, h - ((v - min) / range) * (h - 4.0) - 2.0))
+ .collect();
+
+ let path = pts
+ .iter()
+ .enumerate()
+ .map(|(i, (x, y))| {
+ if i == 0 {
+ format!("M {:.1} {:.1}", x, y)
+ } else {
+ format!("L {:.1} {:.1}", x, y)
+ }
+ })
+ .collect::>()
+ .join(" ");
+
+ let area = format!("{} L {:.0} {:.0} L {:.0} {:.0} Z", path, w, h, 0.0, h);
+ let (lx, ly) = pts.last().copied().unwrap_or((0.0, 0.0));
+ let gradient_id = format!("spark-{prefix}");
+
+ format!(
+ r#""#,
+ w = w,
+ h = h,
+ gid = gradient_id,
+ color = color,
+ area = area,
+ path = path,
+ lx = lx,
+ ly = ly,
+ )
+}
+
+fn sparkline_svg_u32(values: &[u32], color: &str, w: f64, h: f64, prefix: &str) -> String {
+ let floats: Vec = values.iter().map(|&v| v as f64).collect();
+ sparkline_svg(&floats, color, w, h, prefix)
+}
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs
index cd13d4cb..7e687d9d 100644
--- a/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/views/deployments.rs
@@ -1,31 +1,285 @@
-use maud::{Markup, html};
+use maud::{Markup, PreEscaped, html};
-use crate::service::{DeploymentStatus, DeploymentSummary};
+use crate::frontend::views::badges;
+use crate::service::{DeploymentDetail, DeviceDetail, TaskGraph, TaskNode, TaskStatus};
-pub fn page(deployments: &[DeploymentSummary]) -> Markup {
+// ── Inline icons ────────────────────────────────────────────────────────
+const ICON_PLUS: &str = r#""#;
+const ICON_EXTERNAL: &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_LIST: &str = r#""#;
+const ICON_GRAPH: &str = r#""#;
+const ICON_DRAG: &str = r#""#;
+const ICON_MORE: &str = r#""#;
+const ICON_CHECK: &str = r#""#;
+const ICON_CLOSE: &str = r#""#;
+
+// ── Deployments list page ──────────────────────────────────────────────
+
+pub fn page(deployments: &[DeploymentDetail]) -> Markup {
html! {
- section {
- div class="flex items-baseline gap-3 mb-4" {
- h2 class="text-lg font-medium text-slate-300" { "Deployments" }
- span class="text-xs text-slate-500" { (deployments.len()) " total" }
+ div class="p-6 space-y-4" {
+ div class="flex items-center gap-2" {
+ h2 class="text-[15px] font-semibold text-slate-200" { "All deployments" }
+ span class="text-[11px] text-slate-500" { "\u{b7} " (deployments.len()) }
+ div class="flex-1" {}
+ button class="btn btn-primary" { (PreEscaped(ICON_PLUS)) " New deployment" }
}
- div class="overflow-x-auto rounded-lg border border-slate-800" {
- table class="min-w-full divide-y divide-slate-800 text-sm" {
- thead class="bg-slate-900 text-xs uppercase tracking-wide text-slate-400" {
- tr {
- th class="px-3 py-2 text-left font-medium" { "Name" }
- th class="px-3 py-2 text-left font-medium" { "Status" }
- th class="px-3 py-2 text-left font-medium" { "Health" }
+ div class="grid grid-cols-1 lg:grid-cols-2 gap-4" {
+ @for d in deployments {
+ (deployment_card(d))
+ }
+ }
+ style { (PreEscaped(r#"@keyframes roll-marquee { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }"#)) }
+ }
+ }
+}
+
+fn deployment_card(d: &DeploymentDetail) -> Markup {
+ html! {
+ a href={"/deployment/" (d.name)} class="card text-left p-5 hover:border-slate-700 transition-colors relative overflow-hidden group block" {
+ div class="flex items-start justify-between gap-4" {
+ div class="min-w-0" {
+ div class="flex items-center gap-2" {
+ h3 class="font-mono text-[15px] text-slate-100 truncate" { (&d.name) }
+ span class="font-mono text-[11px] text-slate-500 whitespace-nowrap shrink-0" { (&d.version) }
+ }
+ div class="text-[11px] text-slate-500 mt-1" {
+ "Last updated " (d.updated_at) " \u{b7} by "
+ span class="text-slate-400" { (&d.author) }
+ }
+ }
+ (badges::deployment_status(d.status))
+ }
+
+ div class="mt-4 flex items-end justify-between gap-6" {
+ div class="flex-1 min-w-0" {
+ div class="flex items-baseline gap-2" {
+ span class="text-[26px] font-semibold text-slate-100 tabular-nums leading-none" { (d.healthy) }
+ span class="text-[12px] text-slate-500" { "/ " (d.target) " healthy" }
+ }
+ div class="mt-2" {
+ (segmented_progress(d, 5))
+ }
+ div class="mt-2 flex gap-4 text-[11px] text-slate-500 font-mono" {
+ span { span style="color:var(--ok)" { "\u{25cf}" } " " (d.healthy) " healthy" }
+ @if d.pending > 0 {
+ span { span style="color:var(--warn)" { "\u{25cf}" } " " (d.pending) " pending" }
+ }
+ @if d.failing > 0 {
+ span { span style="color:var(--bad)" { "\u{25cf}" } " " (d.failing) " failing" }
}
}
- tbody class="divide-y divide-slate-800 bg-slate-950" {
- @for d in deployments {
- tr {
- td class="px-3 py-2 font-mono text-slate-200" { (d.name) }
- td class="px-3 py-2" { (status_badge(d.status)) }
- td class="px-3 py-2 text-slate-300" {
- (d.healthy_devices) " / " (d.target_devices) " healthy"
+ }
+ div class="text-right shrink-0" {
+ div class="text-[10px] text-slate-600 font-mono uppercase tracking-wider" { "Tasks" }
+ div class="font-mono text-[12px] text-slate-300 mt-1" { "8 steps \u{b7} DAG" }
+ div class="mt-2 text-(--accent-fg) text-[11px] flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity" {
+ "Open " (PreEscaped(ICON_EXTERNAL))
+ }
+ }
+ }
+
+ @if d.status == crate::service::DeploymentStatus::Rolling {
+ span class="absolute top-0 left-0 right-0 h-0.5 overflow-hidden" {
+ span class="block h-full w-1/3" style="background:var(--info); animation:roll-marquee 2.4s linear infinite" {}
+ }
+ }
+ }
+ }
+}
+
+// ── Deployment detail page ─────────────────────────────────────────────
+
+pub fn detail(
+ deployment: &DeploymentDetail,
+ devices: &[DeviceDetail],
+ task_graph: &TaskGraph,
+ task_view: &str,
+) -> Markup {
+ let pct = if deployment.target > 0 {
+ ((deployment.healthy as f64 / deployment.target as f64) * 100.0).round() as u32
+ } else {
+ 0
+ };
+
+ html! {
+ div class="p-6 space-y-4" {
+ // Header
+ div class="card p-5" {
+ div class="flex items-start justify-between gap-6" {
+ div class="min-w-0" {
+ div class="flex items-center gap-3 flex-wrap" {
+ h1 class="text-[22px] font-semibold font-mono text-slate-50 truncate whitespace-nowrap" {
+ (&deployment.name)
+ }
+ (badges::deployment_status(deployment.status))
+ span class="font-mono text-[11px] text-slate-500 whitespace-nowrap shrink-0" {
+ (&deployment.version)
+ }
+ }
+ div class="mt-2 flex items-center gap-x-5 gap-y-1 text-[12px] text-slate-500" {
+ span { span class="text-slate-600" { "Targets" } " " span class="text-slate-300 font-mono" { (deployment.target) " devices" } }
+ span { span class="text-slate-600" { "Updated" } " " span class="text-slate-300 tabular-nums" { (&deployment.updated_at) } }
+ span { span class="text-slate-600" { "By" } " " span class="text-slate-300" { (&deployment.author) } }
+ }
+ }
+ 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
+ div class="mt-5 grid grid-cols-12 gap-5" {
+ div class="col-span-12 md:col-span-8" {
+ div class="flex items-baseline justify-between gap-3 mb-2" {
+ span class="text-[11px] text-slate-500 uppercase tracking-wider whitespace-nowrap" {
+ "Rollout progress"
+ }
+ span class="text-[12px] text-slate-300 font-mono tabular-nums whitespace-nowrap" {
+ (pct) "% complete"
+ }
+ }
+ (segmented_progress(deployment, 10))
+ div class="mt-3 flex gap-6 text-[12px] text-slate-400" {
+ span class="flex items-center gap-1.5" {
+ span class="w-2 h-2 rounded-full" style="background:var(--ok)" {}
+ span class="font-mono tabular-nums" { (deployment.healthy) }
+ " healthy"
+ }
+ span class="flex items-center gap-1.5" {
+ span class="w-2 h-2 rounded-full" style="background:var(--warn)" {}
+ span class="font-mono tabular-nums" { (deployment.pending) }
+ " pending"
+ }
+ span class="flex items-center gap-1.5" {
+ span class="w-2 h-2 rounded-full" style="background:var(--bad)" {}
+ span class="font-mono tabular-nums" { (deployment.failing) }
+ " failing"
+ }
+ span class="flex items-center gap-1.5 text-slate-600" {
+ span class="w-2 h-2 rounded-full" style="background:#475569" {}
+ span class="font-mono tabular-nums" {
+ (deployment.target.saturating_sub(deployment.healthy + deployment.pending + deployment.failing))
}
+ " idle"
+ }
+ }
+ }
+ div class="col-span-12 md:col-span-4" {
+ (PreEscaped(sparkline_svg(deployment)))
+ div class="text-[10px] text-slate-600 font-mono mt-1 text-right" { "Healthy devices \u{b7} 24h" }
+ }
+ }
+ }
+
+ // 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=tasks"}
+ hx-target="#dep-tab-content"
+ hx-swap="innerHTML" {
+ "Task graph"
+ }
+ 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(task_graph, task_view, devices))
+ }
+ }
+ }
+}
+
+pub fn tab_content(
+ deployment: &DeploymentDetail,
+ devices: &[DeviceDetail],
+ task_graph: &TaskGraph,
+ task_view: &str,
+ tab: &str,
+) -> Markup {
+ match tab {
+ "devices" => devices_tab(devices),
+ "tasks" => task_graph_view(task_graph, task_view),
+ "config" => config_tab(deployment),
+ _ => overview_tab(task_graph, task_view, devices),
+ }
+}
+
+fn overview_tab(task_graph: &TaskGraph, task_view: &str, devices: &[DeviceDetail]) -> Markup {
+ html! {
+ div class="grid grid-cols-12 gap-4" {
+ div class="col-span-12 lg:col-span-7" {
+ (task_graph_view(task_graph, task_view))
+ }
+ div class="col-span-12 lg:col-span-5" {
+ (per_device_grid(devices))
+ }
+ }
+ }
+}
+
+fn devices_tab(devices: &[DeviceDetail]) -> Markup {
+ html! {
+ div class="card card-flush mt-4" {
+ table class="tbl" {
+ thead {
+ tr {
+ th { "Device" }
+ th { "Status" }
+ th { "Region" }
+ th { "IP" }
+ th { "Firmware" }
+ th { "Last seen" }
+ th class="text-right" { "Action" }
+ }
+ }
+ tbody {
+ @for d in devices {
+ tr {
+ td {
+ a href={"/device/" (d.id)} class="font-mono text-slate-100 hover:text-(--accent-fg) whitespace-nowrap" { (&d.id) }
+ }
+ td { (badges::device_status(d.status)) }
+ td { span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } }
+ td { span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" { @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" } } }
+ td { span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) } }
+ 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)) }
}
}
}
@@ -35,16 +289,305 @@ pub fn page(deployments: &[DeploymentSummary]) -> Markup {
}
}
-fn status_badge(s: DeploymentStatus) -> Markup {
- let (label, classes) = match s {
- DeploymentStatus::Active => ("active", "bg-emerald-900 text-emerald-300"),
- DeploymentStatus::Rolling => ("rolling", "bg-sky-900 text-sky-300"),
- DeploymentStatus::Failing => ("failing", "bg-rose-900 text-rose-300"),
- DeploymentStatus::Paused => ("paused", "bg-slate-800 text-slate-400"),
- };
+fn config_tab(deployment: &DeploymentDetail) -> Markup {
html! {
- span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} {
- (label)
+ 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"
+ }
}
}
}
+
+fn per_device_grid(devices: &[DeviceDetail]) -> Markup {
+ html! {
+ div class="card" {
+ div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" {
+ span class="section-title" { "Per-device rollout" }
+ span class="text-[10px] text-slate-600 font-mono" { (devices.len()) " devices" }
+ }
+ div class="p-4 grid grid-cols-10 gap-1.5" {
+ @for d in devices {
+ @let c = device_status_color(d.status);
+ a
+ href={"/device/" (&d.id)}
+ title={(&d.id) " \u{b7} " (d.status.label())}
+ class="aspect-square rounded-[3px] hover:ring-2 transition-all"
+ style={"background:" (c) "; opacity:" (if d.status == crate::service::DeviceStatus::Pending { "0.5" } else { "1" }) "; box-shadow:inset 0 0 0 1px rgba(0,0,0,0.2)"} {
+ }
+ }
+ }
+ div class="border-t px-4 py-2.5 flex items-center gap-3 text-[10px] text-slate-500 font-mono" style="border-color:var(--border)" {
+ span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--ok)" {} " healthy" }
+ span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--warn)" {} " pending" }
+ span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:var(--bad)" {} " failing" }
+ span class="flex items-center gap-1" { span class="w-2 h-2 rounded-sm" style="background:rgba(251,113,133,0.6)" {} " stale" }
+ }
+ }
+ }
+}
+
+fn device_status_color(status: crate::service::DeviceStatus) -> &'static str {
+ match status {
+ crate::service::DeviceStatus::Healthy => "var(--ok)",
+ crate::service::DeviceStatus::Pending => "var(--warn)",
+ crate::service::DeviceStatus::Failing => "var(--bad)",
+ crate::service::DeviceStatus::Stale => "rgba(251,113,133,0.6)",
+ crate::service::DeviceStatus::Blacklisted => "#475569",
+ crate::service::DeviceStatus::Unknown => "#475569",
+ }
+}
+
+// ── Task graph view ────────────────────────────────────────────────────
+
+fn task_graph_view(task_graph: &TaskGraph, view: &str) -> Markup {
+ let done_count = task_graph.nodes.iter().filter(|n| n.status == TaskStatus::Done).count();
+ html! {
+ div class="card overflow-hidden" {
+ div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" {
+ div class="flex items-center gap-2" {
+ span class="section-title" { "Task execution" }
+ span class="text-[10px] text-slate-600 font-mono" {
+ (done_count) " / " (task_graph.nodes.len()) " complete"
+ }
+ }
+ div class="flex items-center gap-1 p-0.5 rounded-md" style="background:rgba(148,163,184,0.06)" {
+ a
+ href={"?task_view=linear"}
+ class={"px-2 py-1 rounded text-[11px] flex items-center gap-1.5 "
+ (if view == "linear" { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })}
+ style={(if view == "linear" { "background:rgba(148,163,184,0.08)" } else { "background:transparent" })} {
+ (PreEscaped(ICON_LIST)) " Linear"
+ }
+ a
+ href={"?task_view=dag"}
+ class={"px-2 py-1 rounded text-[11px] flex items-center gap-1.5 "
+ (if view == "dag" { "text-slate-100" } else { "text-slate-500 hover:text-slate-300" })}
+ style={(if view == "dag" { "background:rgba(148,163,184,0.08)" } else { "background:transparent" })} {
+ (PreEscaped(ICON_GRAPH)) " DAG"
+ }
+ }
+ }
+
+ @if view == "linear" {
+ ul class="p-3 space-y-1" {
+ @for (i, n) in task_graph.nodes.iter().enumerate() {
+ (task_row(n, i))
+ }
+ }
+ } @else {
+ (dag_view(task_graph))
+ }
+ }
+ }
+}
+
+fn task_row(node: &TaskNode, index: usize) -> Markup {
+ let status = node.status;
+ let c = match status {
+ TaskStatus::Done => "var(--ok)",
+ TaskStatus::Running => "var(--accent)",
+ TaskStatus::Failed => "var(--bad)",
+ TaskStatus::Pending => "#475569",
+ };
+
+ html! {
+ li class="flex items-center gap-3 px-2 py-2 rounded hover:bg-white/2 group" {
+ button class="text-slate-700 hover:text-slate-400 cursor-grab" title="Drag to reorder" {
+ (PreEscaped(ICON_DRAG))
+ }
+ span class="font-mono text-[10px] text-slate-600 w-5 tabular-nums" {
+ (format!("{:02}", index + 1))
+ }
+ span class="relative inline-flex w-6 h-6 items-center justify-center rounded-full"
+ style={"background:" (c) "22; border:1px solid " (c) "55"} {
+ @if status == TaskStatus::Done {
+ span style={"color:" (c)} { (PreEscaped(ICON_CHECK)) }
+ } @else if status == TaskStatus::Running {
+ span class="absolute inset-0 rounded-full animate-ping opacity-50" style={"background:" (c)} {}
+ span class="relative w-2 h-2 rounded-full" style={"background:" (c)} {}
+ } @else if status == TaskStatus::Failed {
+ span style={"color:" (c)} { (PreEscaped(ICON_CLOSE)) }
+ } @else {
+ span class="w-1.5 h-1.5 rounded-full" style={"background:" (c)} {}
+ }
+ }
+ div class="flex-1 min-w-0" {
+ div class="text-[13px] text-slate-100" { (&node.label) }
+ div class="text-[10px] text-slate-600 font-mono" { (&node.id) }
+ }
+ span class="text-[11px] font-mono text-slate-500 tabular-nums" { (&node.duration) }
+ span class="text-[10px] uppercase tracking-wider font-medium tabular-nums" style={"color:" (c)} {
+ (status_label(status))
+ }
+ }
+ }
+}
+
+fn dag_view(task_graph: &TaskGraph) -> Markup {
+ let col_w = 132;
+ let row_h = 92;
+ let pad = 24;
+ let cols = task_graph.positions.values().map(|p| p.0).max().unwrap_or(0) + 1;
+ let rows = task_graph.positions.values().map(|p| p.1).max().unwrap_or(0) + 1;
+ let total_w = cols * col_w + pad * 2;
+ let total_h = rows * row_h + pad * 2;
+
+ let pos = |id: &str| -> (usize, usize) {
+ if let Some(&(c, r)) = task_graph.positions.get(id) {
+ (pad + c * col_w + col_w / 2 - 50, pad + r * row_h)
+ } else {
+ (pad, pad)
+ }
+ };
+
+ let edges_svg: String = task_graph
+ .edges
+ .iter()
+ .map(|(from, to)| {
+ let (fx, fy) = pos(from);
+ let (tx, ty) = pos(to);
+ let x1 = fx + 100;
+ let y1 = fy + 28;
+ let x2 = tx;
+ let y2 = ty + 28;
+ let cx = (x1 + x2) / 2;
+ let node = task_graph.nodes.iter().find(|n| &n.id == to);
+ let stroke = match node.map(|n| n.status) {
+ Some(TaskStatus::Done) => "rgba(52,211,153,0.45)",
+ Some(TaskStatus::Running) => "rgba(249,115,22,0.55)",
+ _ => "rgba(148,163,184,0.25)",
+ };
+ format!(
+ r#""#
+ )
+ })
+ .collect::>()
+ .join("\n");
+
+ html! {
+ div class="p-3 overflow-auto" style="min-height:300px" {
+ style { (PreEscaped(r#"@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }"#)) }
+ div class="relative" style={"width:" (total_w) "px; height:" (total_h) "px"} {
+ svg class="absolute inset-0" width=(total_w) height=(total_h) style="pointer-events:none" {
+ defs {
+ marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse" {
+ path d="M 0 0 L 10 5 L 0 10 z" fill="rgba(148,163,184,0.45)" {}
+ }
+ }
+ (PreEscaped(&edges_svg))
+ }
+ @for n in &task_graph.nodes {
+ @let (x, y) = pos(&n.id);
+ @let c = match n.status {
+ TaskStatus::Done => "var(--ok)",
+ TaskStatus::Running => "var(--accent)",
+ TaskStatus::Failed => "var(--bad)",
+ TaskStatus::Pending => "#475569",
+ };
+ div class="absolute rounded-md px-2.5 py-2 select-none"
+ style={"left:" (x) "px; top:" (y) "px; width:100px; height:56px; background:var(--bg-elev-2); border:1px solid " (c) "55; box-shadow:0 0 0 1px rgba(255,255,255,0.02) inset"} {
+ div class="flex items-center gap-1.5" {
+ span class="relative inline-flex w-2 h-2 items-center justify-center" {
+ @if n.status == TaskStatus::Running {
+ span class="absolute inset-0 rounded-full animate-ping" style={"background:" (c) "; opacity:0.5"} {}
+ }
+ span class="w-2 h-2 rounded-full" style={"background:" (c)} {}
+ }
+ span class="text-[10px] font-mono uppercase tracking-wider" style={"color:" (c)} {
+ (status_label(n.status))
+ }
+ }
+ div class="text-[11px] text-slate-100 leading-tight mt-1 line-clamp-2" { (&n.label) }
+ div class="absolute right-2 bottom-1 text-[9px] font-mono text-slate-600 tabular-nums" {
+ (&n.duration)
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────
+
+fn segmented_progress(d: &DeploymentDetail, height: u32) -> Markup {
+ html! {
+ div class="w-full rounded-full overflow-hidden progress-bg flex" style={"height:" (height) "px"} {
+ @if d.healthy > 0 {
+ div class="h-full" style={"width:" ((d.healthy as f64 / d.target as f64) * 100.0) "%; background:var(--ok)"} {}
+ }
+ @if d.pending > 0 {
+ div class="h-full" style={"width:" ((d.pending as f64 / d.target as f64) * 100.0) "%; background:var(--warn)"} {}
+ }
+ @if d.failing > 0 {
+ div class="h-full" style={"width:" ((d.failing as f64 / d.target as f64) * 100.0) "%; background:var(--bad)"} {}
+ }
+ }
+ }
+}
+
+fn sparkline_svg(deployment: &DeploymentDetail) -> String {
+ let w = 300.0;
+ let h = 48.0;
+ let end = deployment.healthy as f64;
+ let range = deployment.target as f64 * 0.4;
+ let values: Vec = (0..24)
+ .map(|i| {
+ end - range / 2.0
+ + (i as f64 / 3.0).sin() * range / 3.0
+ + ((i as f64 * 7.3).sin() * 2.0)
+ })
+ .collect();
+ super::dashboard::sparkline_svg(&values, "var(--accent)", w, h, "dep")
+}
+
+fn time_ago(minutes: i64) -> String {
+ if minutes < 1 {
+ "just now".into()
+ } else if minutes < 60 {
+ format!("{}m ago", minutes)
+ } else if minutes < 60 * 24 {
+ format!("{}h ago", minutes / 60)
+ } else {
+ format!("{}d ago", minutes / (60 * 24))
+ }
+}
+
+fn status_label(s: TaskStatus) -> &'static str {
+ match s {
+ TaskStatus::Done => "done",
+ TaskStatus::Running => "running",
+ TaskStatus::Pending => "pending",
+ TaskStatus::Failed => "failed",
+ }
+}
+
+
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs
index 6d9c6c99..3116c110 100644
--- a/fleet/harmony-fleet-operator/src/frontend/views/devices.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/views/devices.rs
@@ -1,29 +1,381 @@
-use maud::{Markup, html};
+use maud::{Markup, PreEscaped, html};
-use crate::service::{DeviceStatus, DeviceSummary};
+use crate::frontend::views::badges;
+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#""#;
+const ICON_EXPAND: &str = r#""#;
+const ICON_EXTERNAL: &str = r#""#;
+const ICON_REFRESH: &str = r#""#;
+const ICON_COPY: &str = r#""#;
+
+// ── Devices list page ──────────────────────────────────────────────────
+
+pub fn page(devices: &[DeviceDetail], regions: &[String], deployments: &[String], status_filter: Option, deployment_filter: Option<&str>, region_filter: Option<&str>, search: Option<&str>) -> Markup {
+ let total = devices.len();
+ let all_regions: Vec<&str> = regions.iter().map(|s| s.as_str()).collect();
-pub fn page(devices: &[DeviceSummary]) -> Markup {
html! {
- section {
- div class="flex items-baseline gap-3 mb-4" {
- h2 class="text-lg font-medium text-slate-300" { "Devices" }
- span class="text-xs text-slate-500" { (devices.len()) " total" }
- }
- div class="overflow-x-auto rounded-lg border border-slate-800" {
- table class="min-w-full divide-y divide-slate-800 text-sm" {
- thead class="bg-slate-900 text-xs uppercase tracking-wide text-slate-400" {
- tr {
- th class="px-3 py-2 text-left font-medium" { "ID" }
- th class="px-3 py-2 text-left font-medium" { "Status" }
- th class="px-3 py-2 text-left font-medium" { "Deployment" }
- th class="px-3 py-2 text-left font-medium" { "IP" }
- th class="px-3 py-2 text-left font-medium" { "Last seen" }
- th class="px-3 py-2 text-right font-medium" { "Actions" }
+ div class="p-6 space-y-4" {
+ div class="flex items-center gap-2 flex-wrap" {
+ @for (k, label) in [("all", "All"), ("healthy", "Healthy"), ("pending", "Pending"), ("failing", "Failing"), ("stale", "Stale"), ("blacklisted", "Blacklisted")].iter() {
+ @let active = match &status_filter {
+ None => *k == "all",
+ Some(s) => k == &s.label(),
+ };
+ a href={"/devices?status=" (k)} class={(if active { "chip active" } else { "chip" })} {
+ span { (label) }
+ }
+ }
+ div class="w-px h-5 mx-1" style="background:var(--border-strong)" {}
+ form class="relative" hx-get="/devices" hx-target="body" hx-push-url="true" {
+ @if let Some(s) = status_filter {
+ input type="hidden" name="status" value=(s.label());
+ }
+ select
+ name="deployment"
+ class="input pl-3 pr-8 appearance-none font-mono text-[12px]"
+ style="padding-left:10px"
+ onchange="this.form.requestSubmit()" {
+ 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) }
}
}
- tbody id="device-rows" class="divide-y divide-slate-800 bg-slate-950" {
- @for device in devices {
- (row(device))
+ span class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500" {
+ (PreEscaped(ICON_CHEVRON_DOWN))
+ }
+ }
+ form class="relative" hx-get="/devices" hx-target="body" hx-push-url="true" {
+ @if let Some(s) = status_filter {
+ input type="hidden" name="status" value=(s.label());
+ }
+ @if let Some(d) = deployment_filter {
+ input type="hidden" name="deployment" value=(d);
+ }
+ select
+ name="region"
+ class="input pl-3 pr-8 appearance-none font-mono text-[12px]"
+ style="padding-left:10px"
+ onchange="this.form.requestSubmit()" {
+ option value="" selected[region_filter.is_none()] { "All regions" }
+ @for r in &all_regions {
+ option value=(r) selected[region_filter == Some(r)] { (r) }
+ }
+ }
+ span class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none text-slate-500" {
+ (PreEscaped(ICON_CHEVRON_DOWN))
+ }
+ }
+ form class="relative flex-1 max-w-[280px] ml-auto" hx-get="/devices" hx-target="body" hx-push-url="true" {
+ @if let Some(s) = status_filter {
+ input type="hidden" name="status" value=(s.label());
+ }
+ span class="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" {
+ (PreEscaped(ICON_SEARCH))
+ }
+ @let search_val = search.unwrap_or("");
+ input
+ class="input w-full"
+ type="text"
+ name="search"
+ placeholder="Filter by id, ip, tag\u{2026}"
+ value=(search_val)
+ hx-get="/devices"
+ hx-trigger="keyup changed delay:300ms"
+ hx-target="body"
+ hx-push-url="true"
+ hx-include="closest form";
+ }
+ }
+
+ div class="card card-flush overflow-hidden" id="device-table-wrapper" {
+ div class="overflow-x-auto" style="max-height:calc(100vh - 240px)" {
+ 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 { "IP" }
+ th { "Firmware" }
+ 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)
+ }
+ }
+ td { (badges::device_status(d.status)) }
+ td {
+ @if let Some(dep) = &d.deployment {
+ span class="font-mono text-[12px] text-slate-300 whitespace-nowrap" { (dep) }
+ } @else {
+ span class="text-slate-700" { "\u{2014}" }
+ }
+ }
+ td {
+ span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) }
+ }
+ td {
+ span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" {
+ @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" }
+ }
+ }
+ td {
+ span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) }
+ }
+ 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="9" class="text-center py-12 text-slate-500 text-[13px]" {
+ "No devices match these filters"
+ }
+ }
+ }
+ }
+ }
+ }
+ div class="flex items-center justify-between px-4 py-2.5 border-t text-[12px] text-slate-500" style="border-color:var(--border)" {
+ span {
+ "Showing " span class="text-slate-300 tabular-nums" { (devices.len()) }
+ " of " span class="text-slate-300 tabular-nums" { (total) } " devices"
+ }
+ }
+ }
+ }
+ }
+}
+
+// ── Device detail page ─────────────────────────────────────────────────
+
+pub fn detail(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup {
+ html! {
+ div class="p-6 space-y-4" {
+ // Header
+ div class="card p-5" {
+ div class="flex items-start justify-between gap-6" {
+ div class="min-w-0" {
+ div class="flex items-center gap-3 flex-wrap" {
+ h1 class="text-[22px] font-semibold font-mono text-slate-50 truncate whitespace-nowrap" { (&device.id) }
+ (badges::device_status(device.status))
+ @for t in &device.tags {
+ span class="text-[10px] font-mono text-slate-400 px-1.5 py-0.5 rounded" style="background:rgba(148,163,184,0.06); border:1px solid var(--border)" {
+ "#" (t)
+ }
+ }
+ }
+ div class="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1 text-[12px] text-slate-500" {
+ span { span class="text-slate-600" { "Model" } " " span class="text-slate-300 font-mono" { (&device.model) } }
+ span { span class="text-slate-600" { "Region" } " " span class="text-slate-300 font-mono" { (&device.region) } }
+ span {
+ span class="text-slate-600" { "IP" } " "
+ span class="text-slate-300 font-mono" { @if let Some(ip) = &device.ip { (ip) } @else { "\u{2014}" } }
+ }
+ span { span class="text-slate-600" { "Firmware" } " " span class="text-slate-300 font-mono" { (&device.fw) } }
+ span { span class="text-slate-600" { "Last seen" } " " span class="text-slate-300 tabular-nums" { (time_ago(device.minutes_ago)) } }
+ }
+ }
+ div class="flex items-center gap-2 shrink-0" {
+ button class="btn btn-ghost" { (PreEscaped(ICON_REFRESH)) " Reconcile" }
+ button class="btn btn-ghost" { (PreEscaped(ICON_POWER)) " Restart" }
+ button class="btn btn-ghost" { (PreEscaped(ICON_PAUSE)) " Suspend" }
+ @if device.status != DeviceStatus::Blacklisted {
+ button
+ class="btn btn-danger"
+ hx-post={"/devices/" (device.id) "/blacklist"}
+ hx-confirm={"Blacklist " (device.id) "?"}
+ hx-target="body" {
+ (PreEscaped(ICON_BAN)) " Blacklist"
+ }
+ }
+ }
+ }
+ }
+
+ // 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=history"}
+ hx-target="#device-tab-content"
+ hx-swap="innerHTML" {
+ "Deployment history"
+ }
+ button
+ class="px-3 py-2 text-[13px] font-medium relative text-slate-500 hover:text-slate-300"
+ hx-get={"/device/" (device.id) "?tab=config"}
+ hx-target="#device-tab-content"
+ hx-swap="innerHTML" {
+ "Config"
+ }
+ 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))
+ }
+ }
+ }
+}
+
+pub fn tab_content(device: &DeviceDetail, tab: &str, deployment_version: Option<&str>) -> Markup {
+ match tab {
+ "logs" => logs_tab(device),
+ "history" => history_tab(device, deployment_version),
+ "config" => config_tab(device, deployment_version),
+ _ => overview_tab(device, deployment_version),
+ }
+}
+
+fn overview_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup {
+ html! {
+ div class="grid grid-cols-12 gap-4" {
+ div class="col-span-12 lg:col-span-8 space-y-4" {
+ // Metrics
+ div class="card p-5" {
+ div class="section-title mb-3" { "Metrics (last hour)" }
+ div class="grid grid-cols-3 gap-5" {
+ (metric_card("CPU", &format!("{}%", device.cpu), "var(--accent)", device.cpu))
+ (metric_card("Memory", &format!("{}%", device.mem), "var(--info)", device.mem))
+ (metric_card("Uptime", &format!("{}d {}h", device.uptime_h / 24, device.uptime_h % 24), "var(--ok)", 78))
+ }
+ }
+
+ // Recent logs preview
+ div class="card" {
+ div class="flex items-center justify-between px-4 py-3 border-b" style="border-color:var(--border)" {
+ span class="section-title" { "Recent logs" }
+ button
+ class="text-[11px] text-slate-400 hover:text-slate-100 flex items-center gap-1"
+ hx-get={"/devices/" (device.id) "/logs"}
+ hx-target="#modal-root"
+ hx-swap="innerHTML" {
+ "Open full " (PreEscaped(ICON_EXTERNAL))
+ }
+ }
+ div class="font-mono text-[11.5px] leading-6 px-4 py-2 max-h-[200px] overflow-auto" style="background:#050608" {
+ @for i in 0..7 {
+ (log_line("info", &format!("07:{}:0{}", 28 + i, (i * 3) % 10), &device.id, &format!("mock log entry #{}", i + 1)))
+ }
+ }
+ }
+ }
+
+ // Sidebar
+ div class="col-span-12 lg:col-span-4 space-y-4" {
+ // Current deployment
+ div class="card p-5" {
+ div class="section-title mb-3" { "Current deployment" }
+ @if let Some(dep_name) = &device.deployment {
+ a href={"/deployment/" (dep_name)} class="w-full text-left block" {
+ div class="font-mono text-slate-100 text-[14px] whitespace-nowrap" { (dep_name) }
+ div class="text-[11px] text-slate-500 mt-0.5" {
+ @if let Some(v) = deployment_version { (v) }
+ }
+ div class="mt-3 text-[11px] text-(--accent-fg) flex items-center gap-1" {
+ "Open deployment " (PreEscaped(ICON_EXTERNAL))
+ }
+ }
+ } @else {
+ div class="text-[12px] text-slate-500" { "No deployment assigned" }
+ }
+ }
+
+ // Identity
+ div class="card p-5" {
+ div class="section-title mb-3" { "Identity" }
+ (definition("Device ID", &device.id, true, true))
+ (definition("MAC", "b8:27:eb:42:0a:1f", true, false))
+ (definition("Agent", &format!("harmony-agent {}", device.fw), true, false))
+ (definition("Enrolled", "2025-11-14 09:22 UTC", false, false))
+ (definition("Region", &device.region, true, false))
+ }
+
+ // Activity
+ div class="card p-5" {
+ div class="section-title mb-3" { "Activity" }
+ ul class="space-y-2.5 text-[12px]" {
+ li class="flex gap-3" {
+ span class="font-mono text-slate-600 tabular-nums shrink-0" { "07:24" }
+ span class="text-slate-400" { span class="text-slate-200" { "r.tarzalt" } " triggered reconcile" }
+ }
+ li class="flex gap-3" {
+ span class="font-mono text-slate-600 tabular-nums shrink-0" { "06:51" }
+ span class="text-slate-400" {
+ "deployment "
+ span class="font-mono text-slate-300" { @if let Some(dep) = &device.deployment { (dep) } @else { "edge-gateway" } }
+ " applied"
+ }
+ }
+ li class="flex gap-3" {
+ span class="font-mono text-slate-600 tabular-nums shrink-0" { "04:12" }
+ span class="text-slate-400" { "agent restarted (reason: signal=15)" }
}
}
}
@@ -32,52 +384,284 @@ pub fn page(devices: &[DeviceSummary]) -> Markup {
}
}
-/// Single row — also the response shape for `POST /devices/:id/blacklist`,
-/// so HTMX can swap a row in place after a mutation.
-pub fn row(d: &DeviceSummary) -> Markup {
+fn logs_tab(device: &DeviceDetail) -> Markup {
html! {
- tr id={"device-" (d.id)} {
- td class="px-3 py-2 font-mono text-slate-200" { (d.id) }
- td class="px-3 py-2" { (status_badge(d.status)) }
- td class="px-3 py-2 text-slate-300" {
- @if let Some(deployment) = &d.deployment { (deployment) }
- @else { span class="text-slate-600" { "—" } }
+ div class="card overflow-hidden mt-4" {
+ div class="flex items-center gap-2 px-4 py-2.5 border-b" style="border-color:var(--border)" {
+ span class="relative flex w-1.5 h-1.5" {
+ span class="absolute inline-flex h-full w-full animate-ping rounded-full opacity-60" style="background:var(--accent)" {}
+ span class="relative inline-flex w-1.5 h-1.5 rounded-full" style="background:var(--accent)" {}
+ }
+ span class="text-[11px] font-mono text-slate-400" { "streaming" }
+ span class="text-[11px] text-slate-600 font-mono" { "\u{b7} live" }
+ div class="flex-1" {}
+ div class="flex items-center gap-1" {
+ span class="chip active" { "all" }
+ span class="chip" { "info" }
+ span class="chip" { "warn" }
+ span class="chip" { "error" }
+ span class="chip" { "debug" }
+ }
+ button class="btn btn-ghost py-1" { (PreEscaped(ICON_PAUSE)) " Pause" }
}
- td class="px-3 py-2 font-mono text-slate-400" {
- @if let Some(ip) = &d.ip { (ip) }
- @else { span class="text-slate-600" { "—" } }
+ div
+ class="font-mono text-[11.5px] leading-6 px-4 py-2 overflow-auto"
+ style="background:#050608; height:520px"
+ hx-ext="sse"
+ sse-connect={"/devices/" (device.id) "/logs/stream"}
+ sse-swap="log"
+ hx-swap="beforeend" {
+ div class="px-0 py-px italic text-slate-700" { "\u{2014} connecting \u{2014}" }
}
- td class="px-3 py-2 text-slate-400" {
- (d.last_seen.format("%Y-%m-%d %H:%M:%S").to_string()) " UTC"
+ }
+ }
+}
+
+fn history_tab(device: &DeviceDetail, _deployment_version: Option<&str>) -> Markup {
+ let dep_name = device.deployment.as_deref().unwrap_or("edge-gateway");
+ html! {
+ div class="card mt-4" {
+ table class="tbl" {
+ thead {
+ tr {
+ th { "Deployment" }
+ th { "Version" }
+ th { "Outcome" }
+ th { "Applied" }
+ th { "Duration" }
+ }
+ }
+ tbody {
+ tr {
+ td class="font-mono text-slate-200 whitespace-nowrap" { (dep_name) }
+ td class="font-mono text-[12px] whitespace-nowrap" { "v2.14.1" }
+ td { (badges::device_status(DeviceStatus::Healthy)) }
+ td class="text-slate-400 text-[12px]" { "2026-05-19 04:12" }
+ td class="font-mono text-[12px] text-slate-500" { "42s" }
+ }
+ tr {
+ td class="font-mono text-slate-200 whitespace-nowrap" { (dep_name) }
+ td class="font-mono text-[12px] whitespace-nowrap" { "v2.13.4" }
+ td { (badges::device_status(DeviceStatus::Healthy)) }
+ td class="text-slate-400 text-[12px]" { "2026-05-12 11:00" }
+ td class="font-mono text-[12px] text-slate-500" { "38s" }
+ }
+ tr {
+ td class="font-mono text-slate-200 whitespace-nowrap" { "telemetry-collector" }
+ td class="font-mono text-[12px] whitespace-nowrap" { "v0.4.11" }
+ td { (badges::device_status(DeviceStatus::Failing)) }
+ td class="text-slate-400 text-[12px]" { "2026-05-04 14:30" }
+ td class="font-mono text-[12px] text-slate-500" { "1m 08s" }
+ }
+ tr {
+ td class="font-mono text-slate-200 whitespace-nowrap" { "telemetry-collector" }
+ td class="font-mono text-[12px] whitespace-nowrap" { "v0.4.10" }
+ td { (badges::device_status(DeviceStatus::Healthy)) }
+ td class="text-slate-400 text-[12px]" { "2026-04-30 09:15" }
+ td class="font-mono text-[12px] text-slate-500" { "29s" }
+ }
+ }
}
- td class="px-3 py-2 text-right" {
- @if d.status != DeviceStatus::Blacklisted {
+ }
+ }
+}
+
+fn config_tab(device: &DeviceDetail, deployment_version: Option<&str>) -> Markup {
+ let tags_str = device.tags.join(", ");
+ let dep_ver = deployment_version.unwrap_or("\u{2014}");
+ html! {
+ div class="card p-5 mt-4" {
+ div class="section-title mb-2" { "Effective config" }
+ pre class="font-mono text-[12px] text-slate-300 leading-6 p-4 rounded" style="background:#050608; border:1px solid var(--border)" {
+ "# generated by harmony-controller @ 2026-05-19 04:12\n"
+ "device:\n"
+ " id: " (device.id) "\n"
+ " region: " (device.region) "\n"
+ " tags: [" (tags_str) "]\n"
+ "agent:\n"
+ " version: " (device.fw) "\n"
+ " heartbeat_interval: 30s\n"
+ "deployment:\n"
+ " name: " @if let Some(dep) = &device.deployment { (dep) } @else { "none" } "\n"
+ " version: " (dep_ver) "\n"
+ " tasks:\n"
+ " - fetch_artifact\n"
+ " - verify_signature\n"
+ " - install_deps\n"
+ " - launch_services\n"
+ }
+ }
+ }
+}
+
+// ── Logs modal (SSE streaming) ─────────────────────────────────────────
+
+pub fn logs_modal(device_id: &str) -> Markup {
+ html! {
+ dialog
+ 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" {
+ span class="relative flex h-1.5 w-1.5 shrink-0" {
+ span class="absolute inline-flex h-full w-full animate-ping rounded-full bg-orange-400 opacity-60" {}
+ span class="relative inline-flex h-1.5 w-1.5 rounded-full" style="background:var(--accent)" {}
+ }
+ code class="text-sm font-medium text-slate-100" { (device_id) }
+ span class="text-[10px] font-semibold uppercase tracking-[0.15em] text-orange-500/60" { "\u{b7} logs" }
+ }
+ form method="dialog" {
button
- class="rounded bg-rose-700 hover:bg-rose-600 px-2 py-1 text-xs font-medium"
- hx-post={"/devices/" (d.id) "/blacklist"}
- hx-target={"#device-" (d.id)}
- hx-swap="outerHTML"
- hx-confirm={"Blacklist " (d.id) "?"}
- { "Blacklist" }
- } @else {
- span class="text-xs text-slate-500" { "blacklisted" }
+ type="submit"
+ class="flex items-center gap-1.5 text-slate-500 transition-colors hover:text-slate-200"
+ aria-label="Close"
+ {
+ kbd class="rounded border bg-slate-800/60 px-1.5 py-0.5 font-mono text-[10px] text-slate-400" style="border-color:var(--border-strong)" { "esc" }
+ span class="text-xs" { "close" }
+ }
+ }
+ }
+
+ div
+ class="overflow-y-auto py-3 font-mono text-[11.5px] leading-6 px-5"
+ style="background:#050608"
+ hx-ext="sse"
+ sse-connect={"/devices/" (device_id) "/logs/stream"}
+ sse-swap="log"
+ hx-swap="beforeend" {
+ 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 });
+})();
+"#))
+ }
+ }
+}
+
+// ── Row (for blacklist response) ───────────────────────────────────────
+
+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)
+ }
+ }
+ td { (badges::device_status(d.status)) }
+ td {
+ @if let Some(dep) = &d.deployment { span class="font-mono text-[12px] text-slate-300 whitespace-nowrap" { (dep) } }
+ @else { span class="text-slate-700" { "\u{2014}" } }
+ }
+ td { span class="text-[12px] text-slate-400 font-mono whitespace-nowrap" { (&d.region) } }
+ td { span class="font-mono text-[12px] text-slate-500 whitespace-nowrap" { @if let Some(ip) = &d.ip { (ip) } @else { "\u{2014}" } } }
+ td { span class="font-mono text-[11px] text-slate-500 whitespace-nowrap" { (&d.fw) } }
+ 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)) }
}
}
}
}
}
-fn status_badge(s: DeviceStatus) -> Markup {
- let (label, classes) = match s {
- DeviceStatus::Healthy => ("healthy", "bg-emerald-900 text-emerald-300"),
- DeviceStatus::Pending => ("pending", "bg-amber-900 text-amber-300"),
- DeviceStatus::Stale => ("stale", "bg-rose-900 text-rose-300"),
- DeviceStatus::Blacklisted => ("blacklisted", "bg-slate-800 text-slate-400"),
- DeviceStatus::Unknown => ("unknown", "bg-slate-800 text-slate-500"),
+// ── Helpers ────────────────────────────────────────────────────────────
+
+fn log_line(severity: &str, ts: &str, device_id: &str, message: &str) -> Markup {
+ let sev_color = match severity {
+ "info" => "text-cyan-500",
+ "warn" => "text-amber-400",
+ "error" => "text-rose-400",
+ "debug" => "text-slate-600",
+ _ => "text-slate-500",
};
+ let sev_label = format!("{:5}", severity.to_uppercase());
html! {
- span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} {
- (label)
+ div class={"log-line grid grid-cols-[5rem_3rem_1fr] gap-3 px-0 py-px hover:bg-white/2.5 " (sev_color)} {
+ span class="tabular-nums text-slate-600" { (ts) }
+ span class={"font-semibold " (sev_color)} { (sev_label) }
+ span class="text-slate-300" {
+ span class="text-cyan-500" { (device_id) }
+ " " (message)
+ }
+ }
+ }
+}
+
+fn time_ago(minutes: i64) -> String {
+ if minutes < 1 {
+ "just now".into()
+ } else if minutes < 60 {
+ format!("{}m ago", minutes)
+ } else if minutes < 60 * 24 {
+ format!("{}h ago", minutes / 60)
+ } else {
+ format!("{}d ago", minutes / (60 * 24))
+ }
+}
+
+fn metric_card(label: &str, value: &str, color: &str, seed: u8) -> Markup {
+ let spark = mini_sparkline(seed, color);
+ html! {
+ div {
+ div class="flex items-baseline gap-2" {
+ span class="text-[11px] text-slate-500 uppercase tracking-wider" { (label) }
+ }
+ div class="text-[22px] font-semibold text-slate-100 mt-1 tabular-nums leading-none" { (value) }
+ div class="mt-2" {
+ (PreEscaped(spark))
+ }
+ }
+ }
+}
+
+fn mini_sparkline(seed: u8, color: &str) -> String {
+ let w = 210.0;
+ let h = 36.0;
+ let values: Vec = (0..24)
+ .map(|i| {
+ seed as f64 - 15.0
+ + (i as f64 / 3.0).sin() * 10.0
+ + ((i as f64 * 7.3 + seed as f64 * 1.7).sin() * 4.0)
+ })
+ .collect();
+ crate::frontend::views::dashboard::sparkline_svg(&values, color, w, h, &format!("ms{}", seed))
+}
+
+fn definition(label: &str, value: &str, mono: bool, copyable: bool) -> 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={(if mono { "font-mono whitespace-nowrap" } else { "" }) " text-slate-200 flex items-center gap-1.5"} {
+ (value)
+ @if copyable {
+ button class="text-slate-600 hover:text-slate-300" title="Copy" { (PreEscaped(ICON_COPY)) }
+ }
+ }
}
}
}
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs
index ec2901b5..7cabd8a0 100644
--- a/fleet/harmony-fleet-operator/src/frontend/views/mod.rs
+++ b/fleet/harmony-fleet-operator/src/frontend/views/mod.rs
@@ -1,3 +1,6 @@
+pub mod alerts;
+pub mod badges;
pub mod dashboard;
pub mod deployments;
pub mod devices;
+pub mod settings;
diff --git a/fleet/harmony-fleet-operator/src/frontend/views/settings.rs b/fleet/harmony-fleet-operator/src/frontend/views/settings.rs
new file mode 100644
index 00000000..66a702c8
--- /dev/null
+++ b/fleet/harmony-fleet-operator/src/frontend/views/settings.rs
@@ -0,0 +1,70 @@
+use maud::{Markup, PreEscaped, html};
+
+pub fn page() -> Markup {
+ html! {
+ div class="p-6 max-w-3xl space-y-4" {
+ div {
+ h2 class="text-[15px] font-semibold text-slate-200" { "Notification channels" }
+ p class="text-[12px] text-slate-500 mt-1" { "Where alerts get delivered when something needs your attention." }
+ }
+ (channel_row("Email", "email", "alerts@example.com", true))
+ (channel_row("Slack", "slack", "#fleet-alerts", true))
+ (channel_row("Discord", "discord", "https://discord.com/api/webhooks/\u{2026}", false))
+ (channel_row("SMS", "sms", "+1 555 010 0001", true))
+ }
+ }
+}
+
+fn channel_row(name: &str, key: &str, placeholder: &str, enabled: bool) -> Markup {
+ let enabled_val = if enabled { "var(--ok)" } else { "rgba(148,163,184,0.2)" };
+ let translate = if enabled { "18px" } else { "2px" };
+ let display_val = if enabled { placeholder } else { "disabled" };
+
+ html! {
+ div class="card p-5" {
+ div class="flex items-center justify-between" {
+ div class="flex items-center gap-3" {
+ span class="inline-flex items-center justify-center w-9 h-9 rounded-md" style="background:var(--bg-elev-2); color:var(--accent-fg)" {
+ (PreEscaped(channel_icon(key)))
+ }
+ div {
+ div class="text-[14px] text-slate-100 font-medium" { (name) }
+ div class="text-[11px] text-slate-500" { (display_val) }
+ }
+ }
+ button
+ class="relative w-9 h-5 rounded-full transition-colors"
+ style={"background:" (enabled_val)}
+ hx-post={"/settings/toggle/" (key)}
+ hx-target="closest .card"
+ hx-swap="outerHTML" {
+ span class="absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform" style={"transform:translateX(" (translate) ")"} {}
+ }
+ }
+ div class={(if enabled { "mt-3 grid grid-cols-1 md:grid-cols-2 gap-3" } else { "mt-3 grid grid-cols-1 md:grid-cols-2 gap-3 max-h-0 opacity-0 overflow-hidden" })} {
+ div {
+ label class="text-[11px] text-slate-500 uppercase tracking-wider" { "Destination" }
+ input class="input mt-1 w-full" style="padding-left:10px" type="text" placeholder=(placeholder) value=(placeholder) {}
+ }
+ div {
+ label class="text-[11px] text-slate-500 uppercase tracking-wider" { "Notify on" }
+ div class="mt-1 flex gap-1.5" {
+ span class="chip active" { "critical" }
+ span class="chip active" { "warning" }
+ span class="chip" { "info" }
+ }
+ }
+ }
+ }
+ }
+}
+
+fn channel_icon(key: &str) -> String {
+ match key {
+ "email" => r#""#.to_string(),
+ "slack" => r#""#.to_string(),
+ "discord" => r#""#.to_string(),
+ "sms" => r#""#.to_string(),
+ _ => r#""#.to_string(),
+ }
+}
diff --git a/fleet/harmony-fleet-operator/src/main.rs b/fleet/harmony-fleet-operator/src/main.rs
index 46300ee9..fed7bf34 100644
--- a/fleet/harmony-fleet-operator/src/main.rs
+++ b/fleet/harmony-fleet-operator/src/main.rs
@@ -106,6 +106,8 @@ enum Command {
#[tokio::main]
async fn main() -> Result<()> {
+ dotenvy::dotenv().ok();
+
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
@@ -157,6 +159,7 @@ async fn serve_web(
live_reload: bool,
) -> Result<()> {
use std::sync::Arc;
+ use std::time::Duration;
use frontend::server::{AppState, Config};
use service::{FleetService, mock::MockFleetService};
@@ -170,11 +173,24 @@ async fn serve_web(
);
};
+ let cookie_key = harmony_zitadel_auth::cookie_key_from_env();
+ let config = harmony_zitadel_auth::config_from_env();
+ let http_client = reqwest::Client::new();
+
+ let jwks = harmony_zitadel_auth::JwksCache::new(&config.zitadel_base, http_client.clone())
+ .await
+ .context("initializing JWKS cache")?;
+ jwks.spawn_background_refresh(Duration::from_secs(900));
+
frontend::server::run(
Config::new(AppState {
fleet,
+ cookie_key,
css_override: css_from,
live_reload,
+ config,
+ http_client,
+ jwks,
})
.with_addr(addr),
)
diff --git a/fleet/harmony-fleet-operator/src/service/mock.rs b/fleet/harmony-fleet-operator/src/service/mock.rs
index 27da4675..1e220584 100644
--- a/fleet/harmony-fleet-operator/src/service/mock.rs
+++ b/fleet/harmony-fleet-operator/src/service/mock.rs
@@ -1,23 +1,17 @@
-//! In-memory `FleetService` with seeded fake data.
-//!
-//! Used by `serve-web --mock` for local development without a NATS
-//! server or a Kubernetes cluster, and by tests that exercise the
-//! presentation layer.
-
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use async_trait::async_trait;
-use chrono::{Duration, Utc};
use super::{
- DashboardSummary, DeploymentStatus, DeploymentSummary, DeviceStatus, DeviceSummary,
- FleetService,
+ Activity, Alert, AlertSeverity, DashboardDetail, DeploymentDetail, DeploymentStatus,
+ DeviceDetail, DeviceStatus, FleetService, TaskGraph, TaskNode, TaskStatus,
};
pub struct MockFleetService {
- devices: Mutex>,
- deployments: Mutex>,
+ devices: Mutex>,
+ deployments: Mutex>,
+ alerts: Mutex>,
}
impl Default for MockFleetService {
@@ -28,169 +22,639 @@ impl Default for MockFleetService {
impl MockFleetService {
pub fn with_seeded_data() -> Self {
- let now = Utc::now();
- let devices = [
- (
- "pi-001",
- DeviceStatus::Healthy,
- 30,
- Some("kiosk-v3"),
- Some("10.0.1.21"),
- ),
- (
- "pi-002",
- DeviceStatus::Healthy,
- 45,
- Some("kiosk-v3"),
- Some("10.0.1.22"),
- ),
- (
- "pi-003",
- DeviceStatus::Healthy,
- 12,
- Some("kiosk-v3"),
- Some("10.0.1.23"),
- ),
- (
- "pi-004",
- DeviceStatus::Pending,
- 5,
- Some("kiosk-v3"),
- Some("10.0.1.24"),
- ),
- (
- "pi-005",
- DeviceStatus::Stale,
- 1820,
- Some("kiosk-v2"),
- Some("10.0.1.25"),
- ),
- ("pi-006", DeviceStatus::Stale, 2400, Some("kiosk-v2"), None),
- (
- "pi-007",
- DeviceStatus::Blacklisted,
- 600,
- None,
- Some("10.0.1.27"),
- ),
- ("pi-008", DeviceStatus::Unknown, 9999, None, None),
- (
- "pi-009",
- DeviceStatus::Healthy,
- 88,
- Some("sensor-edge"),
- Some("10.0.2.10"),
- ),
- (
- "pi-010",
- DeviceStatus::Pending,
- 3,
- Some("sensor-edge"),
- Some("10.0.2.11"),
- ),
- ];
- let devices: HashMap = devices
- .into_iter()
- .map(|(id, status, seconds_ago, deployment, ip)| {
- (
- id.to_string(),
- DeviceSummary {
- id: id.to_string(),
- status,
- last_seen: now - Duration::seconds(seconds_ago),
- deployment: deployment.map(str::to_string),
- ip: ip.map(str::to_string),
- },
- )
- })
- .collect();
-
- let deployments = vec![
- DeploymentSummary {
- name: "kiosk-v3".into(),
- status: DeploymentStatus::Rolling,
- target_devices: 4,
- healthy_devices: 3,
- },
- DeploymentSummary {
- name: "kiosk-v2".into(),
- status: DeploymentStatus::Paused,
- target_devices: 2,
- healthy_devices: 0,
- },
- DeploymentSummary {
- name: "sensor-edge".into(),
- status: DeploymentStatus::Active,
- target_devices: 2,
- healthy_devices: 1,
- },
- DeploymentSummary {
- name: "ota-canary".into(),
- status: DeploymentStatus::Failing,
- target_devices: 1,
- healthy_devices: 0,
- },
- ];
+ let devices = seed_devices();
+ let deployments = seed_deployments();
+ let alerts = seed_alerts();
Self {
devices: Mutex::new(devices),
deployments: Mutex::new(deployments),
+ alerts: Mutex::new(alerts),
}
}
}
+// ── Seeded PRNG ────────────────────────────────────────────────────────
+
+struct Rng(u32);
+impl Iterator for Rng {
+ type Item = f64;
+ fn next(&mut self) -> Option {
+ self.0 = (self.0.wrapping_mul(1103515245).wrapping_add(12345)) & 0x7fffffff;
+ Some(self.0 as f64 / 0x7fffffff as f64)
+ }
+}
+
+fn pick<'a, T>(rng: &mut Rng, arr: &'a [T]) -> &'a T {
+ let i = (rng.next().unwrap() * arr.len() as f64) as usize;
+ &arr[i.min(arr.len() - 1)]
+}
+
+// ── Device seed ────────────────────────────────────────────────────────
+
+fn seed_devices() -> Vec {
+ let mut rng = Rng(1337);
+ let hosts = ["edge", "sensor", "gw", "cam", "relay", "meter", "hub"];
+ let regions = [
+ "eu-paris-1",
+ "eu-paris-2",
+ "us-east-1",
+ "us-west-2",
+ "apac-tokyo-1",
+ ];
+ let models = [
+ "HF-Edge-2",
+ "HF-Edge-3",
+ "HF-Sensor-S1",
+ "HF-Gateway-G2",
+ "HF-Cam-V1",
+ ];
+ let tags_pool = [
+ "prod", "staging", "lab", "eu", "us", "apac", "gpu", "lowpower", "thermal", "pilot",
+ ];
+
+ let status_weights: [(DeviceStatus, f64); 5] = [
+ (DeviceStatus::Healthy, 0.72),
+ (DeviceStatus::Pending, 0.10),
+ (DeviceStatus::Stale, 0.10),
+ (DeviceStatus::Blacklisted, 0.04),
+ (DeviceStatus::Unknown, 0.04),
+ ];
+
+ let device_deployment: [Option<&str>; 100] = {
+ let mut arr = [None; 100];
+ let dist: [(&str, usize); 7] = [
+ ("edge-gateway", 32),
+ ("sensor-firmware", 41),
+ ("ingest-pipeline", 8),
+ ("control-plane", 6),
+ ("telemetry-collector", 12),
+ ("gateway-proxy", 4),
+ ("media-relay", 9),
+ ];
+ let mut idx = 0;
+ for (name, count) in dist {
+ for _ in 0..count {
+ if idx < 100 {
+ arr[idx] = Some(name);
+ idx += 1;
+ }
+ }
+ }
+ arr
+ };
+
+ let now = chrono::Utc::now();
+
+ let mut devices = Vec::with_capacity(100);
+ for i in 0..100 {
+ let host = pick(&mut rng, &hosts);
+ let id = format!("hf-{}-{:03}", host, i + 1);
+ let status = if i < 4 {
+ DeviceStatus::Healthy
+ } else if i == 4 {
+ DeviceStatus::Failing
+ } else if i == 5 {
+ DeviceStatus::Pending
+ } else {
+ let r = rng.next().unwrap();
+ let mut acc = 0.0;
+ let mut s = DeviceStatus::Healthy;
+ for &(st, w) in &status_weights {
+ acc += w;
+ if r < acc {
+ s = st;
+ break;
+ }
+ }
+ s
+ };
+
+ let minutes_ago = match status {
+ DeviceStatus::Stale => 60 + (rng.next().unwrap() * 4000.0) as i64,
+ DeviceStatus::Pending => (rng.next().unwrap() * 5.0) as i64,
+ DeviceStatus::Blacklisted => 600 + (rng.next().unwrap() * 8000.0) as i64,
+ _ => (rng.next().unwrap() * 12.0) as i64,
+ };
+ let last_seen = now - chrono::Duration::minutes(minutes_ago);
+
+ let ip = format!(
+ "10.{}.{}.{}",
+ 20 + (rng.next().unwrap() * 4.0) as u8,
+ (rng.next().unwrap() * 256.0) as u8,
+ (rng.next().unwrap() * 256.0) as u8
+ );
+
+ let mut tags = Vec::new();
+ let num_tags = 1 + (rng.next().unwrap() * 3.0) as usize;
+ let mut seen = HashSet::new();
+ for _ in 0..num_tags {
+ let t = pick(&mut rng, &tags_pool).to_string();
+ if seen.insert(t.clone()) {
+ tags.push(t);
+ }
+ }
+
+ let model = pick(&mut rng, &models).to_string();
+ let region = pick(&mut rng, ®ions).to_string();
+ let deployment = device_deployment[i].map(str::to_string);
+ let fw = format!(
+ "v{}.{}.{}",
+ 1 + (rng.next().unwrap() * 3.0) as u8,
+ (rng.next().unwrap() * 20.0) as u8,
+ (rng.next().unwrap() * 10.0) as u8
+ );
+ let uptime_h = if status == DeviceStatus::Stale {
+ 0
+ } else {
+ (rng.next().unwrap() * 4200.0) as u32
+ };
+ let cpu = 5 + (rng.next().unwrap() * 70.0) as u8;
+ let mem = 15 + (rng.next().unwrap() * 70.0) as u8;
+
+ devices.push(DeviceDetail {
+ id,
+ status,
+ last_seen,
+ minutes_ago,
+ deployment,
+ ip: Some(ip),
+ region,
+ model,
+ fw,
+ tags,
+ uptime_h,
+ cpu,
+ mem,
+ });
+ }
+
+ // Force some control-plane devices to failing
+ let mut cp_failed = 0;
+ for d in &mut devices {
+ if d.deployment.as_deref() == Some("control-plane") && cp_failed < 2 {
+ d.status = DeviceStatus::Failing;
+ cp_failed += 1;
+ }
+ }
+
+ devices
+}
+
+// ── Deployment seed ────────────────────────────────────────────────────
+
+fn seed_deployments() -> Vec {
+ vec![
+ DeploymentDetail {
+ name: "edge-gateway".into(),
+ version: "v2.14.1".into(),
+ status: DeploymentStatus::Active,
+ target: 32,
+ healthy: 31,
+ failing: 0,
+ pending: 1,
+ updated_at: "2026-05-19 04:12".into(),
+ author: "r.tarzalt".into(),
+ },
+ DeploymentDetail {
+ name: "sensor-firmware".into(),
+ version: "v0.9.3".into(),
+ status: DeploymentStatus::Rolling,
+ target: 41,
+ healthy: 28,
+ failing: 1,
+ pending: 12,
+ updated_at: "2026-05-19 06:48".into(),
+ author: "m.lavoie".into(),
+ },
+ DeploymentDetail {
+ name: "ingest-pipeline".into(),
+ version: "v1.7.0".into(),
+ status: DeploymentStatus::Active,
+ target: 8,
+ healthy: 8,
+ failing: 0,
+ pending: 0,
+ updated_at: "2026-05-15 11:30".into(),
+ author: "r.tarzalt".into(),
+ },
+ DeploymentDetail {
+ name: "control-plane".into(),
+ version: "v3.2.0".into(),
+ status: DeploymentStatus::Failing,
+ target: 6,
+ healthy: 3,
+ failing: 2,
+ pending: 1,
+ updated_at: "2026-05-19 07:01".into(),
+ author: "a.singh".into(),
+ },
+ DeploymentDetail {
+ name: "telemetry-collector".into(),
+ version: "v0.4.12".into(),
+ status: DeploymentStatus::Active,
+ target: 12,
+ healthy: 12,
+ failing: 0,
+ pending: 0,
+ updated_at: "2026-05-12 09:22".into(),
+ author: "m.lavoie".into(),
+ },
+ DeploymentDetail {
+ name: "gateway-proxy".into(),
+ version: "v1.0.5".into(),
+ status: DeploymentStatus::Paused,
+ target: 4,
+ healthy: 0,
+ failing: 0,
+ pending: 4,
+ updated_at: "2026-05-18 18:14".into(),
+ author: "r.tarzalt".into(),
+ },
+ DeploymentDetail {
+ name: "media-relay".into(),
+ version: "v2.0.0-rc.3".into(),
+ status: DeploymentStatus::Rolling,
+ target: 9,
+ healthy: 5,
+ failing: 0,
+ pending: 4,
+ updated_at: "2026-05-19 06:55".into(),
+ author: "a.singh".into(),
+ },
+ ]
+}
+
+// ── Alerts seed ────────────────────────────────────────────────────────
+
+fn seed_alerts() -> Vec {
+ vec![
+ Alert {
+ id: "al-1".into(),
+ severity: AlertSeverity::Critical,
+ title: "control-plane rollout failing on 2 devices".into(),
+ deployment: Some("control-plane".into()),
+ device: Some("hf-gw-018".into()),
+ at: "2 min ago".into(),
+ acked: false,
+ },
+ Alert {
+ id: "al-2".into(),
+ severity: AlertSeverity::Critical,
+ title: "hf-sensor-042 unreachable for 14 minutes".into(),
+ deployment: Some("sensor-firmware".into()),
+ device: Some("hf-sensor-042".into()),
+ at: "14 min ago".into(),
+ acked: false,
+ },
+ Alert {
+ id: "al-3".into(),
+ severity: AlertSeverity::Warning,
+ title: "sensor-firmware rollout stalled at 68%".into(),
+ deployment: Some("sensor-firmware".into()),
+ device: None,
+ at: "22 min ago".into(),
+ acked: false,
+ },
+ Alert {
+ id: "al-4".into(),
+ severity: AlertSeverity::Warning,
+ title: "hf-cam-011 reporting elevated thermal (78°C)".into(),
+ deployment: None,
+ device: Some("hf-cam-011".into()),
+ at: "1h ago".into(),
+ acked: false,
+ },
+ Alert {
+ id: "al-5".into(),
+ severity: AlertSeverity::Info,
+ title: "edge-gateway v2.14.1 deployed to 31 devices".into(),
+ deployment: Some("edge-gateway".into()),
+ device: None,
+ at: "3h ago".into(),
+ acked: true,
+ },
+ ]
+}
+
+// ── Activity feed ─────────────────────────────────────────────────────
+
+fn activity_feed() -> Vec {
+ vec![
+ Activity {
+ who: "r.tarzalt".into(),
+ verb: "started rollout".into(),
+ target: "sensor-firmware v0.9.3".into(),
+ at: "07:24".into(),
+ },
+ Activity {
+ who: "system".into(),
+ verb: "auto-blacklisted".into(),
+ target: "hf-sensor-091".into(),
+ at: "07:18".into(),
+ },
+ Activity {
+ who: "a.singh".into(),
+ verb: "paused deployment".into(),
+ target: "gateway-proxy".into(),
+ at: "07:02".into(),
+ },
+ Activity {
+ who: "system".into(),
+ verb: "detected failure".into(),
+ target: "hf-gw-018 (control-plane)".into(),
+ at: "06:51".into(),
+ },
+ Activity {
+ who: "m.lavoie".into(),
+ verb: "updated task graph".into(),
+ target: "telemetry-collector".into(),
+ at: "06:14".into(),
+ },
+ Activity {
+ who: "r.tarzalt".into(),
+ verb: "logged in".into(),
+ target: String::new(),
+ at: "06:02".into(),
+ },
+ ]
+}
+
+// ── Trend generation ──────────────────────────────────────────────────
+
+fn ingest_trend() -> Vec {
+ let mut rng = Rng(1337);
+ (0..48)
+ .map(|i| {
+ let base = 38.0 + (i as f64 / 4.0).sin() * 8.0 + (rng.next().unwrap() * 6.0);
+ (base.max(2.0)).round() as u32
+ })
+ .collect()
+}
+
+fn health_trend() -> Vec {
+ let mut rng = Rng(1337);
+ (0..48)
+ .map(|i| {
+ let v = 96.0
+ + (i as f64 / 6.0).sin() * 1.4
+ - if i > 38 && i < 44 { 6.0 } else { 0.0 }
+ + (rng.next().unwrap() * 0.4);
+ (v * 10.0).round() / 10.0
+ })
+ .collect()
+}
+
+// ── Task graph ─────────────────────────────────────────────────────────
+
+fn task_graph() -> TaskGraph {
+ let mut positions = HashMap::new();
+ positions.insert("t1".into(), (0, 1));
+ positions.insert("t2".into(), (1, 1));
+ positions.insert("t3".into(), (2, 1));
+ positions.insert("t4".into(), (3, 1));
+ positions.insert("t5".into(), (4, 1));
+ positions.insert("t6".into(), (5, 0));
+ positions.insert("t7".into(), (5, 2));
+ positions.insert("t8".into(), (6, 1));
+
+ TaskGraph {
+ nodes: vec![
+ TaskNode {
+ id: "t1".into(),
+ label: "fetch artifact".into(),
+ status: TaskStatus::Done,
+ duration: "2s".into(),
+ },
+ TaskNode {
+ id: "t2".into(),
+ label: "verify signature".into(),
+ status: TaskStatus::Done,
+ duration: "0.4s".into(),
+ },
+ TaskNode {
+ id: "t3".into(),
+ label: "stop services".into(),
+ status: TaskStatus::Done,
+ duration: "1.1s".into(),
+ },
+ TaskNode {
+ id: "t4".into(),
+ label: "install deps".into(),
+ status: TaskStatus::Running,
+ duration: "12s".into(),
+ },
+ TaskNode {
+ id: "t5".into(),
+ label: "mount volumes".into(),
+ status: TaskStatus::Pending,
+ duration: "—".into(),
+ },
+ TaskNode {
+ id: "t6".into(),
+ label: "launch sensord".into(),
+ status: TaskStatus::Pending,
+ duration: "—".into(),
+ },
+ TaskNode {
+ id: "t7".into(),
+ label: "launch relayd".into(),
+ status: TaskStatus::Pending,
+ duration: "—".into(),
+ },
+ TaskNode {
+ id: "t8".into(),
+ label: "health probe".into(),
+ status: TaskStatus::Pending,
+ duration: "—".into(),
+ },
+ ],
+ edges: vec![
+ ("t1".into(), "t2".into()),
+ ("t2".into(), "t3".into()),
+ ("t3".into(), "t4".into()),
+ ("t4".into(), "t5".into()),
+ ("t5".into(), "t6".into()),
+ ("t5".into(), "t7".into()),
+ ("t6".into(), "t8".into()),
+ ("t7".into(), "t8".into()),
+ ],
+ positions,
+ }
+}
+
+// ── FleetService impl ─────────────────────────────────────────────────
+
#[async_trait]
impl FleetService for MockFleetService {
- async fn dashboard_summary(&self) -> anyhow::Result {
+ async fn dashboard_detail(&self) -> anyhow::Result {
let devices = self.devices.lock().unwrap();
let deployments = self.deployments.lock().unwrap();
- let mut s = DashboardSummary {
+ let alerts = self.alerts.lock().unwrap();
+
+ let mut d = DashboardDetail {
devices_total: devices.len() as u32,
- deployments_total: deployments.len() as u32,
- ..Default::default()
+ devices_healthy: 0,
+ devices_pending: 0,
+ devices_failing: 0,
+ devices_stale: 0,
+ devices_blacklisted: 0,
+ devices_unknown: 0,
+ deployments_total: deployments.len(),
+ health_pct: 0,
+ health_trend: health_trend(),
+ ingest_rate: *ingest_trend().last().unwrap_or(&0),
+ ingest_trend: ingest_trend(),
+ attention_devices: vec![],
+ activity_feed: activity_feed(),
+ top_deployments: deployments.clone(),
+ active_alerts: alerts
+ .iter()
+ .filter(|a| !a.acked)
+ .take(10)
+ .cloned()
+ .collect(),
+ rolling_count: 0,
+ failing_count: 0,
};
- for d in devices.values() {
- match d.status {
- DeviceStatus::Healthy => s.devices_healthy += 1,
- DeviceStatus::Pending => s.devices_pending += 1,
- DeviceStatus::Stale => s.devices_stale += 1,
- DeviceStatus::Blacklisted => s.devices_blacklisted += 1,
- DeviceStatus::Unknown => {}
+
+ for dev in devices.iter() {
+ match dev.status {
+ DeviceStatus::Healthy => d.devices_healthy += 1,
+ DeviceStatus::Pending => d.devices_pending += 1,
+ DeviceStatus::Stale => d.devices_stale += 1,
+ DeviceStatus::Failing => d.devices_failing += 1,
+ DeviceStatus::Blacklisted => d.devices_blacklisted += 1,
+ DeviceStatus::Unknown => d.devices_unknown += 1,
}
}
- for d in deployments.iter() {
- match d.status {
- DeploymentStatus::Active | DeploymentStatus::Rolling => s.deployments_active += 1,
- DeploymentStatus::Failing => s.deployments_failing += 1,
- DeploymentStatus::Paused => {}
+ d.health_pct =
+ ((d.devices_healthy as f64 / d.devices_total as f64) * 100.0).round() as u32;
+
+ d.attention_devices = devices
+ .iter()
+ .filter(|d| {
+ d.status == DeviceStatus::Failing
+ || d.status == DeviceStatus::Stale
+ || d.status == DeviceStatus::Pending
+ })
+ .take(12)
+ .cloned()
+ .collect();
+
+ for dep in deployments.iter() {
+ match dep.status {
+ DeploymentStatus::Rolling => d.rolling_count += 1,
+ DeploymentStatus::Failing => d.failing_count += 1,
+ _ => {}
}
}
- Ok(s)
+
+ d.top_deployments.truncate(4);
+ Ok(d)
}
- async fn list_devices(&self) -> anyhow::Result> {
- let mut out: Vec<_> = self.devices.lock().unwrap().values().cloned().collect();
- out.sort_by(|a, b| a.id.cmp(&b.id));
- Ok(out)
+ async fn list_devices(&self) -> anyhow::Result> {
+ Ok(self.devices.lock().unwrap().clone())
}
- async fn get_device(&self, id: &str) -> anyhow::Result