add auth to frontend through lib #284
@@ -6,3 +6,6 @@ rustflags = ["-C", "link-arg=-Wl,--stack,8000000"]
|
|||||||
|
|
||||||
[target.aarch64-unknown-linux-gnu]
|
[target.aarch64-unknown-linux-gnu]
|
||||||
linker = "aarch64-linux-gnu-gcc"
|
linker = "aarch64-linux-gnu-gcc"
|
||||||
|
|
||||||
|
[profile.test]
|
||||||
|
debug = 0
|
||||||
|
|||||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -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=
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
|||||||
### General ###
|
### General ###
|
||||||
private_repos/
|
private_repos/
|
||||||
|
.env
|
||||||
|
|
||||||
### Harmony ###
|
### Harmony ###
|
||||||
harmony.log
|
harmony.log
|
||||||
|
|||||||
238
Cargo.lock
generated
238
Cargo.lock
generated
@@ -1010,6 +1010,81 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "backon"
|
name = "backon"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -1739,6 +1814,21 @@ dependencies = [
|
|||||||
"version_check",
|
"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]]
|
[[package]]
|
||||||
name = "cookie_store"
|
name = "cookie_store"
|
||||||
version = "0.20.0"
|
version = "0.20.0"
|
||||||
@@ -3992,22 +4082,32 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-nats",
|
"async-nats",
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
|
"dotenvy",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"harmony",
|
"harmony",
|
||||||
"harmony-fleet-auth",
|
"harmony-fleet-auth",
|
||||||
"harmony-reconciler-contracts",
|
"harmony-reconciler-contracts",
|
||||||
|
"harmony_zitadel_auth",
|
||||||
"k8s-openapi",
|
"k8s-openapi",
|
||||||
"kube",
|
"kube",
|
||||||
|
"maud",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"schemars 0.8.22",
|
"schemars 0.8.22",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-stream",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"url",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4352,6 +4452,29 @@ dependencies = [
|
|||||||
"url",
|
"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]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -5111,6 +5234,15 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itertools"
|
||||||
|
version = "0.10.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||||
|
dependencies = [
|
||||||
|
"either",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -5582,6 +5714,36 @@ dependencies = [
|
|||||||
"regex-automata",
|
"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]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -5870,6 +6032,26 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "objc2"
|
name = "objc2"
|
||||||
version = "0.6.4"
|
version = "0.6.4"
|
||||||
@@ -5962,6 +6144,37 @@ version = "0.3.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
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]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
@@ -6615,6 +6828,18 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "psl-types"
|
name = "psl-types"
|
||||||
version = "2.0.11"
|
version = "2.0.11"
|
||||||
@@ -6817,7 +7042,7 @@ dependencies = [
|
|||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"indoc",
|
"indoc",
|
||||||
"instability",
|
"instability",
|
||||||
"itertools",
|
"itertools 0.13.0",
|
||||||
"lru",
|
"lru",
|
||||||
"paste",
|
"paste",
|
||||||
"strum 0.26.3",
|
"strum 0.26.3",
|
||||||
@@ -7718,6 +7943,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -9070,7 +9304,7 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itertools",
|
"itertools 0.13.0",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-width 0.1.14",
|
"unicode-width 0.1.14",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ members = [
|
|||||||
"examples/*",
|
"examples/*",
|
||||||
"private_repos/*",
|
"private_repos/*",
|
||||||
"harmony",
|
"harmony",
|
||||||
|
"harmony_zitadel_auth",
|
||||||
"harmony_types",
|
"harmony_types",
|
||||||
"harmony_macros",
|
"harmony_macros",
|
||||||
"harmony_tui",
|
"harmony_tui",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
- [Writing a Score](./guides/writing-a-score.md)
|
- [Writing a Score](./guides/writing-a-score.md)
|
||||||
- [Writing a Topology](./guides/writing-a-topology.md)
|
- [Writing a Topology](./guides/writing-a-topology.md)
|
||||||
- [Adding Capabilities](./guides/adding-capabilities.md)
|
- [Adding Capabilities](./guides/adding-capabilities.md)
|
||||||
|
- [Web Authentication and CSRF Security](./guides/web-auth-security.md)
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
217
docs/guides/web-auth-security.md
Normal file
217
docs/guides/web-auth-security.md
Normal file
@@ -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 `<base>` 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.
|
||||||
@@ -59,6 +59,7 @@ async fn connect_with_role(stack: &StackHandles, key_json: &str) -> Result<async
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[ignore = "requires k3d + docker environment"]
|
||||||
async fn admin_can_read_any_device_subject() -> Result<()> {
|
async fn admin_can_read_any_device_subject() -> Result<()> {
|
||||||
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
||||||
let stack = shared_stack().await?;
|
let stack = shared_stack().await?;
|
||||||
@@ -84,6 +85,7 @@ async fn admin_can_read_any_device_subject() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[ignore = "requires k3d + docker environment"]
|
||||||
async fn device_can_only_access_own_subjects() -> Result<()> {
|
async fn device_can_only_access_own_subjects() -> Result<()> {
|
||||||
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
||||||
let stack = shared_stack().await?;
|
let stack = shared_stack().await?;
|
||||||
@@ -114,6 +116,7 @@ async fn device_can_only_access_own_subjects() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
#[ignore = "requires k3d + docker environment"]
|
||||||
async fn unknown_role_is_rejected() -> Result<()> {
|
async fn unknown_role_is_rejected() -> Result<()> {
|
||||||
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
let _ = tracing_subscriber::fmt().with_env_filter("info").try_init();
|
||||||
let stack = shared_stack().await?;
|
let stack = shared_stack().await?;
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ default = []
|
|||||||
# build time when the standalone `tailwindcss` CLI is on PATH; otherwise
|
# build time when the standalone `tailwindcss` CLI is on PATH; otherwise
|
||||||
# the bundled CSS is empty and `--css-from <path>` must be used at runtime
|
# the bundled CSS is empty and `--css-from <path>` must be used at runtime
|
||||||
# (the sidecar-watch dev workflow does this).
|
# (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]
|
[dependencies]
|
||||||
harmony = { path = "../../harmony", features = ["podman"] }
|
harmony = { path = "../../harmony", features = ["podman"] }
|
||||||
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
|
harmony-fleet-auth = { path = "../harmony-fleet-auth" }
|
||||||
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
|
harmony-reconciler-contracts = { path = "../../harmony-reconciler-contracts" }
|
||||||
|
harmony_zitadel_auth = { path = "../../harmony_zitadel_auth" }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
chrono = { workspace = true, features = ["serde"] }
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
kube = { workspace = true, features = ["runtime", "derive"] }
|
kube = { workspace = true, features = ["runtime", "derive"] }
|
||||||
@@ -33,7 +34,12 @@ clap.workspace = true
|
|||||||
futures-util = { workspace = true }
|
futures-util = { workspace = true }
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
async-trait.workspace = true
|
async-trait.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
|
||||||
axum = { version = "0.8", optional = 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 }
|
maud = { version = "0.27", features = ["axum"], optional = true }
|
||||||
tokio-stream = { version = "0.1", optional = true }
|
tokio-stream = { version = "0.1", optional = true }
|
||||||
|
dotenvy = "0.15"
|
||||||
|
|||||||
@@ -8,3 +8,4 @@
|
|||||||
pub const TAILWIND_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/tailwind.css"));
|
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_JS: &[u8] = include_bytes!("../../vendor/htmx.min.js");
|
||||||
pub const HTMX_SSE_JS: &[u8] = include_bytes!("../../vendor/htmx-ext-sse.js");
|
pub const HTMX_SSE_JS: &[u8] = include_bytes!("../../vendor/htmx-ext-sse.js");
|
||||||
|
pub const APP_JS: &[u8] = include_bytes!("../../vendor/app.js");
|
||||||
|
|||||||
6
fleet/harmony-fleet-operator/src/frontend/auth.rs
Normal file
6
fleet/harmony-fleet-operator/src/frontend/auth.rs
Normal file
@@ -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,
|
||||||
|
};
|
||||||
@@ -1,8 +1,26 @@
|
|||||||
//! Page shell — `<html>`, `<head>`, top nav, body slot.
|
|
||||||
|
|
||||||
use maud::{DOCTYPE, Markup, PreEscaped, html};
|
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#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>"#;
|
||||||
|
const ICON_DEVICES: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>"#;
|
||||||
|
const ICON_DEPLOY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>"#;
|
||||||
|
const ICON_BELL: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>"#;
|
||||||
|
const ICON_COG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>"#;
|
||||||
|
const ICON_LOGOUT: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>"#;
|
||||||
|
const ICON_BRAND: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4v16M20 4v16M4 12h16"/></svg>"#;
|
||||||
|
|
||||||
|
/// 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! {
|
html! {
|
||||||
(DOCTYPE)
|
(DOCTYPE)
|
||||||
html lang="en" {
|
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";
|
link rel="stylesheet" href="/static/tailwind.css";
|
||||||
script src="/static/htmx.min.js" defer {}
|
script src="/static/htmx.min.js" defer {}
|
||||||
script src="/static/htmx-ext-sse.js" defer {}
|
script src="/static/htmx-ext-sse.js" defer {}
|
||||||
|
script src="/static/app.js" defer {}
|
||||||
@if live_reload {
|
@if live_reload {
|
||||||
script { (PreEscaped(LIVE_RELOAD_JS)) }
|
script { (PreEscaped(LIVE_RELOAD_JS)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
body class="min-h-screen bg-slate-950 text-slate-100" hx-ext="sse" {
|
body class="min-h-screen" hx-ext="sse" style="background:var(--bg); color:#e2e8f0; font-family:'Inter',sans-serif" {
|
||||||
header class="border-b border-slate-800 px-6 py-4 flex items-baseline gap-6" {
|
div class="flex h-screen overflow-hidden" style="background:var(--bg)" {
|
||||||
h1 class="text-xl font-semibold" { "Harmony Fleet Operator" }
|
(sidebar(current_path, session, unacked_alerts))
|
||||||
nav class="flex gap-4 text-sm text-slate-400" {
|
main class="flex-1 min-w-0 flex flex-col overflow-hidden" {
|
||||||
a href="/" class="hover:text-slate-100" { "Dashboard" }
|
(topbar(title, unacked_alerts))
|
||||||
a href="/devices" class="hover:text-slate-100" { "Devices" }
|
div class="flex-1 overflow-y-auto grid-bg" { (content) }
|
||||||
a href="/deployments" class="hover:text-slate-100" { "Deployments" }
|
|
||||||
}
|
|
||||||
@if live_reload {
|
|
||||||
span class="ml-auto text-xs text-amber-400" { "dev · live reload" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
main class="p-6 space-y-8" { (content) }
|
div id="modal-root" {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tiny inline script: reconnects an EventSource to `/__dev/reload`;
|
fn sidebar(
|
||||||
/// when the server comes back up after a restart, reload the page.
|
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::<String>()
|
||||||
|
.to_uppercase();
|
||||||
|
if s.is_empty() { None } else { Some(s) }
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
session
|
||||||
|
.subject
|
||||||
|
.chars()
|
||||||
|
.take(2)
|
||||||
|
.collect::<String>()
|
||||||
|
.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#"
|
const LIVE_RELOAD_JS: &str = r#"
|
||||||
(function(){
|
(function(){
|
||||||
let connected = false;
|
let connected = false;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
//! future CLI.
|
//! future CLI.
|
||||||
|
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
|
pub mod auth;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod views;
|
pub mod views;
|
||||||
|
|||||||
@@ -7,33 +7,64 @@ use std::time::Duration;
|
|||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use axum::body::Body;
|
use axum::body::Body;
|
||||||
use axum::extract::{Path, State};
|
use axum::extract::{Extension, FromRef, Path, Query, State};
|
||||||
use axum::http::{StatusCode, header};
|
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::sse::{Event, KeepAlive, Sse};
|
||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, Key, PrivateCookieJar};
|
||||||
use maud::Markup;
|
use maud::Markup;
|
||||||
|
use serde::Deserialize;
|
||||||
use tokio_stream::StreamExt;
|
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::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 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;
|
pub const DEFAULT_PORT: u16 = 18080;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub fleet: Arc<dyn FleetService>,
|
pub fleet: Arc<dyn FleetService>,
|
||||||
/// Read Tailwind CSS from this path on every request when set.
|
pub cookie_key: Key,
|
||||||
/// Lets a sidecar `tailwindcss --watch` drive iteration without
|
|
||||||
/// recompiling the binary.
|
|
||||||
pub css_override: Option<PathBuf>,
|
pub css_override: Option<PathBuf>,
|
||||||
/// When true, inject the live-reload script into pages and expose
|
|
||||||
/// `/__dev/reload`.
|
|
||||||
pub live_reload: bool,
|
pub live_reload: bool,
|
||||||
|
pub config: ZitadelAuthConfig,
|
||||||
|
pub http_client: reqwest::Client,
|
||||||
|
pub jwks: JwksCache,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for Key {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.cookie_key.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for ZitadelAuthConfig {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.config.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for reqwest::Client {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.http_client.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRef<AppState> for JwksCache {
|
||||||
|
fn from_ref(state: &AppState) -> Self {
|
||||||
|
state.jwks.clone()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
@@ -56,20 +87,197 @@ impl Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router {
|
pub fn router(state: AppState) -> Router {
|
||||||
let mut r = Router::new()
|
let public_routes = Router::new()
|
||||||
.route("/", get(dashboard_handler))
|
.route("/login", get(auth::login_handler))
|
||||||
.route("/devices", get(devices_handler))
|
.route("/auth/callback", get(auth::callback_handler))
|
||||||
.route("/devices/{id}/blacklist", post(blacklist_handler))
|
|
||||||
.route("/deployments", get(deployments_handler))
|
|
||||||
.route("/static/tailwind.css", get(tailwind_css))
|
.route("/static/tailwind.css", get(tailwind_css))
|
||||||
.route("/static/htmx.min.js", get(htmx_js))
|
.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 {
|
if state.live_reload {
|
||||||
r = r.route("/__dev/reload", get(dev_reload_sse));
|
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<AppState>,
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
mut req: Request<Body>,
|
||||||
|
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<AppState>, req: Request<Body>, 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<Body>, 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<AppState>,
|
||||||
|
req: Request<Body>,
|
||||||
|
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<Body>) -> 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<Body>) -> 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<Body>) -> 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<()> {
|
pub async fn run(cfg: Config) -> Result<()> {
|
||||||
@@ -80,27 +288,356 @@ pub async fn run(cfg: Config) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- handlers: each is a 3-liner: extract state, call service, render. ----
|
// ── Dashboard ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn dashboard_handler(State(s): State<AppState>) -> Result<Markup, AppError> {
|
async fn dashboard_handler(
|
||||||
let summary = s.fleet.dashboard_summary().await?;
|
State(s): State<AppState>,
|
||||||
Ok(page("Dashboard", s.live_reload, dashboard::page(&summary)))
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<AppState>) -> Result<Markup, AppError> {
|
// ── Devices ────────────────────────────────────────────────────────────
|
||||||
let devices = s.fleet.list_devices().await?;
|
|
||||||
Ok(page("Devices", s.live_reload, devices_view::page(&devices)))
|
#[derive(Deserialize, Default)]
|
||||||
|
struct DevicesQuery {
|
||||||
|
status: Option<String>,
|
||||||
|
deployment: Option<String>,
|
||||||
|
region: Option<String>,
|
||||||
|
search: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn deployments_handler(State(s): State<AppState>) -> Result<Markup, AppError> {
|
async fn devices_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Query(q): Query<DevicesQuery>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<String> = {
|
||||||
|
let mut r: Vec<String> = all_devices.iter().map(|d| d.region.clone()).collect();
|
||||||
|
r.sort();
|
||||||
|
r.dedup();
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let all_deployments: Vec<String> = {
|
||||||
|
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<AppState>,
|
||||||
|
Query(q): Query<DevicesQuery>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_detail_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<DeviceDetailQuery>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
let deployments = s.fleet.list_deployments().await?;
|
let deployments = s.fleet.list_deployments().await?;
|
||||||
|
let unacked = s
|
||||||
|
.fleet
|
||||||
|
.list_alerts()
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.filter(|a| !a.acked)
|
||||||
|
.count();
|
||||||
|
|
||||||
Ok(page(
|
Ok(page(
|
||||||
"Deployments",
|
"Deployments",
|
||||||
s.live_reload,
|
s.live_reload,
|
||||||
|
"/deployments",
|
||||||
|
session.as_ref().map(|e| &e.0),
|
||||||
|
unacked,
|
||||||
deployments_view::page(&deployments),
|
deployments_view::page(&deployments),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Deployment detail ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Deserialize, Default)]
|
||||||
|
struct DeploymentQuery {
|
||||||
|
tab: Option<String>,
|
||||||
|
task_view: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn deployment_handler(
|
||||||
|
State(s): State<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
Query(q): Query<DeploymentQuery>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<AppState>,
|
||||||
|
session: Option<Extension<DashboardSession>>,
|
||||||
|
) -> Result<Markup, AppError> {
|
||||||
|
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<String>) -> Result<Markup, AppError> {
|
||||||
|
// 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<String>) -> Result<Markup, AppError> {
|
||||||
|
Ok(devices_view::logs_modal(&id))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn device_logs_stream_handler(
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
|
let mut line_no = 0usize;
|
||||||
|
let stream = IntervalStream::new(tokio::time::interval(Duration::from_secs(1))).map(
|
||||||
|
move |_| {
|
||||||
|
line_no += 1;
|
||||||
|
let now = chrono::Utc::now().format("%H:%M:%S");
|
||||||
|
let sevs = ["debug", "info", "info", "info", "info", "warn", "error"];
|
||||||
|
let _sev = sevs[line_no % sevs.len()];
|
||||||
|
let msgs = [
|
||||||
|
"agent heartbeat ok (latency 12ms)",
|
||||||
|
"mqtt connection established to broker.harmony.local",
|
||||||
|
"reporting metrics batch (48 samples)",
|
||||||
|
"config reload requested by control-plane",
|
||||||
|
"task t4 (install deps) progress 73%",
|
||||||
|
"unexpected schema version v3, falling back",
|
||||||
|
"sensord pid=2841 started",
|
||||||
|
"gpu temp 54\u{b0}C \u{2014} within range",
|
||||||
|
"apt-get: package libsensor-7 not found",
|
||||||
|
"flushed 19 pending events to ingest",
|
||||||
|
"network jitter 84ms \u{2014} degrading to backup link",
|
||||||
|
"gc cycle complete in 47ms",
|
||||||
|
];
|
||||||
|
let msg = msgs[line_no % msgs.len()];
|
||||||
|
let html = format!(
|
||||||
|
r#"<div class="grid grid-cols-[5.5rem_1fr] gap-4 px-0 py-px hover:bg-white/2"><span class="tabular-nums text-slate-600">{now}</span><span class="text-slate-300"><span class="text-cyan-500">{id}</span> {msg}</span></div>"#,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok::<_, Infallible>(Event::default().event("log").data(html))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Blacklist ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn blacklist_handler(
|
async fn blacklist_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(id): Path<String>,
|
Path(id): Path<String>,
|
||||||
@@ -109,7 +646,21 @@ async fn blacklist_handler(
|
|||||||
Ok(devices_view::row(&updated))
|
Ok(devices_view::row(&updated))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- static assets ----
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn parse_device_status(s: &str) -> Option<crate::service::DeviceStatus> {
|
||||||
|
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<AppState>) -> Response {
|
async fn tailwind_css(State(s): State<AppState>) -> Response {
|
||||||
let css: Vec<u8> = match &s.css_override {
|
let css: Vec<u8> = 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<u8>, content_type: &'static str) -> Response {
|
fn static_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::OK)
|
.status(StatusCode::OK)
|
||||||
@@ -144,19 +699,15 @@ fn static_response(bytes: Vec<u8>, content_type: &'static str) -> Response {
|
|||||||
.expect("well-formed static response")
|
.expect("well-formed static response")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- dev live-reload SSE ----
|
// ── Dev live-reload SSE ────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn dev_reload_sse() -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
async fn dev_reload_sse() -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||||
// 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"))])
|
let stream = tokio_stream::iter([Ok::<_, Infallible>(Event::default().data("ready"))])
|
||||||
.chain(tokio_stream::pending());
|
.chain(tokio_stream::pending());
|
||||||
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- error type ----
|
// ── Error type ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pub struct AppError(anyhow::Error);
|
pub struct AppError(anyhow::Error);
|
||||||
|
|
||||||
|
|||||||
125
fleet/harmony-fleet-operator/src/frontend/views/alerts.rs
Normal file
125
fleet/harmony-fleet-operator/src/frontend/views/alerts.rs
Normal file
@@ -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)",
|
||||||
|
}
|
||||||
|
}
|
||||||
91
fleet/harmony-fleet-operator/src/frontend/views/badges.rs
Normal file
91
fleet/harmony-fleet-operator/src/frontend/views/badges.rs
Normal file
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>"#;
|
||||||
|
const ICON_WARNING: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>"#;
|
||||||
|
const ICON_INFO: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>"#;
|
||||||
|
|
||||||
|
use maud::PreEscaped;
|
||||||
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>"#;
|
||||||
|
const ICON_CHEVRON: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>"#;
|
||||||
|
const ICON_LIST: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>"#;
|
||||||
|
const ICON_ERROR: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>"#;
|
||||||
|
const ICON_WARNING: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>"#;
|
||||||
|
|
||||||
|
pub fn page(d: &DashboardDetail) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
section {
|
div class="p-6 space-y-5" {
|
||||||
h2 class="text-lg font-medium mb-4 text-slate-300" { "Devices" }
|
// Alert strip (if there are unacked alerts)
|
||||||
div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4" {
|
@if !d.active_alerts.is_empty() {
|
||||||
(card("Total", &summary.devices_total.to_string(), "text-slate-50"))
|
@let top = &d.active_alerts[0];
|
||||||
(card("Healthy", &summary.devices_healthy.to_string(), "text-emerald-400"))
|
@let more = d.active_alerts.len().saturating_sub(1);
|
||||||
(card("Pending", &summary.devices_pending.to_string(), "text-amber-400"))
|
(alert_strip(top, more))
|
||||||
(card("Stale", &summary.devices_stale.to_string(), "text-rose-400"))
|
}
|
||||||
(card("Blacklisted", &summary.devices_blacklisted.to_string(), "text-slate-500"))
|
|
||||||
|
(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" {
|
fn alert_strip(alert: &crate::service::Alert, more: usize) -> Markup {
|
||||||
(card("Total", &summary.deployments_total.to_string(), "text-slate-50"))
|
let is_crit = matches!(alert.severity, crate::service::AlertSeverity::Critical);
|
||||||
(card("Active / Rolling", &summary.deployments_active.to_string(), "text-emerald-400"))
|
let border = if is_crit { "rgba(244,63,94,0.3)" } else { "rgba(251,191,36,0.3)" };
|
||||||
(card("Failing", &summary.deployments_failing.to_string(), "text-rose-400"))
|
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn card(title: &str, value: &str, value_class: &str) -> Markup {
|
// 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 lower_row(d: &DashboardDetail) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
div class="rounded-lg border border-slate-800 bg-slate-900 p-4" {
|
div class="grid grid-cols-12 gap-4" {
|
||||||
div class="text-xs uppercase tracking-wide text-slate-400" { (title) }
|
// Needs attention
|
||||||
div class={"mt-2 text-3xl font-semibold " (value_class)} { (value) }
|
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::<Vec<_>>()
|
||||||
|
.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#"<svg width="{w}" height="{h}" viewBox="0 0 {w} {h}" class="block">
|
||||||
|
<defs><linearGradient id="{gid}" x1="0" x2="0" y1="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="{color}" stop-opacity="0.35"/>
|
||||||
|
<stop offset="100%" stop-color="{color}" stop-opacity="0"/>
|
||||||
|
</linearGradient></defs>
|
||||||
|
<path d="{area}" fill="url(#{gid})"/>
|
||||||
|
<path d="{path}" fill="none" stroke="{color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="spark-path" pathLength="1"/>
|
||||||
|
<circle cx="{lx:.1}" cy="{ly:.1}" r="2.5" fill="{color}"/>
|
||||||
|
<circle cx="{lx:.1}" cy="{ly:.1}" r="5" fill="{color}" opacity="0.25"/>
|
||||||
|
</svg>"#,
|
||||||
|
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<f64> = values.iter().map(|&v| v as f64).collect();
|
||||||
|
sparkline_svg(&floats, color, w, h, prefix)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,32 +1,286 @@
|
|||||||
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#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>"#;
|
||||||
|
const ICON_EXTERNAL: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>"#;
|
||||||
|
const ICON_REFRESH: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg>"#;
|
||||||
|
const ICON_PLAY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>"#;
|
||||||
|
const ICON_PAUSE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>"#;
|
||||||
|
const ICON_ROLLBACK: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>"#;
|
||||||
|
const ICON_DEPLOY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>"#;
|
||||||
|
const ICON_LIST: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>"#;
|
||||||
|
const ICON_GRAPH: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="3"/><circle cx="18" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><circle cx="6" cy="18" r="3"/><line x1="9" y1="6" x2="15" y2="6"/><line x1="18" y1="9" x2="18" y2="15"/><line x1="9" y1="18" x2="15" y2="18"/></svg>"#;
|
||||||
|
const ICON_DRAG: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="6" r="1"/><circle cx="9" cy="12" r="1"/><circle cx="9" cy="18" r="1"/><circle cx="15" cy="6" r="1"/><circle cx="15" cy="12" r="1"/><circle cx="15" cy="18" r="1"/></svg>"#;
|
||||||
|
const ICON_MORE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>"#;
|
||||||
|
const ICON_CHECK: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>"#;
|
||||||
|
const ICON_CLOSE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>"#;
|
||||||
|
|
||||||
|
// ── Deployments list page ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn page(deployments: &[DeploymentDetail]) -> Markup {
|
||||||
html! {
|
html! {
|
||||||
section {
|
div class="p-6 space-y-4" {
|
||||||
div class="flex items-baseline gap-3 mb-4" {
|
div class="flex items-center gap-2" {
|
||||||
h2 class="text-lg font-medium text-slate-300" { "Deployments" }
|
h2 class="text-[15px] font-semibold text-slate-200" { "All deployments" }
|
||||||
span class="text-xs text-slate-500" { (deployments.len()) " total" }
|
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" {
|
div class="grid grid-cols-1 lg:grid-cols-2 gap-4" {
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody class="divide-y divide-slate-800 bg-slate-950" {
|
|
||||||
@for d in deployments {
|
@for d in deployments {
|
||||||
tr {
|
(deployment_card(d))
|
||||||
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"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
fn config_tab(deployment: &DeploymentDetail) -> 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"),
|
|
||||||
};
|
|
||||||
html! {
|
html! {
|
||||||
span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} {
|
div class="card p-5 mt-4" {
|
||||||
(label)
|
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#"<path d="M {x1} {y1} C {cx} {y1} {cx} {y2} {x2} {y2}" fill="none" stroke="{stroke}" stroke-width="1.5" marker-end="url(#arrow)"/>"#
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.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<f64> = (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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,667 @@
|
|||||||
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#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>"#;
|
||||||
|
const ICON_CHEVRON_DOWN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>"#;
|
||||||
|
const ICON_LIST: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>"#;
|
||||||
|
const ICON_MORE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></svg>"#;
|
||||||
|
const ICON_POWER: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.36 6.64a9 9 0 1 1-12.73 0"/><line x1="12" y1="2" x2="12" y2="12"/></svg>"#;
|
||||||
|
const ICON_PAUSE: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>"#;
|
||||||
|
const ICON_BAN: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>"#;
|
||||||
|
const ICON_EXPAND: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg>"#;
|
||||||
|
const ICON_EXTERNAL: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>"#;
|
||||||
|
const ICON_REFRESH: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg>"#;
|
||||||
|
const ICON_COPY: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>"#;
|
||||||
|
|
||||||
|
// ── Devices list page ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub fn page(devices: &[DeviceDetail], regions: &[String], deployments: &[String], status_filter: Option<DeviceStatus>, 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! {
|
html! {
|
||||||
section {
|
div class="p-6 space-y-4" {
|
||||||
div class="flex items-baseline gap-3 mb-4" {
|
div class="flex items-center gap-2 flex-wrap" {
|
||||||
h2 class="text-lg font-medium text-slate-300" { "Devices" }
|
@for (k, label) in [("all", "All"), ("healthy", "Healthy"), ("pending", "Pending"), ("failing", "Failing"), ("stale", "Stale"), ("blacklisted", "Blacklisted")].iter() {
|
||||||
span class="text-xs text-slate-500" { (devices.len()) " total" }
|
@let active = match &status_filter {
|
||||||
}
|
None => *k == "all",
|
||||||
div class="overflow-x-auto rounded-lg border border-slate-800" {
|
Some(s) => k == &s.label(),
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody id="device-rows" class="divide-y divide-slate-800 bg-slate-950" {
|
|
||||||
@for device in devices {
|
|
||||||
(row(device))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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 {
|
|
||||||
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" { "—" } }
|
|
||||||
}
|
|
||||||
td class="px-3 py-2 font-mono text-slate-400" {
|
|
||||||
@if let Some(ip) = &d.ip { (ip) }
|
|
||||||
@else { span class="text-slate-600" { "—" } }
|
|
||||||
}
|
|
||||||
td class="px-3 py-2 text-slate-400" {
|
|
||||||
(d.last_seen.format("%Y-%m-%d %H:%M:%S").to_string()) " UTC"
|
|
||||||
}
|
|
||||||
td class="px-3 py-2 text-right" {
|
|
||||||
@if d.status != DeviceStatus::Blacklisted {
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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"),
|
|
||||||
};
|
};
|
||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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! {
|
html! {
|
||||||
span class={"inline-block rounded px-2 py-0.5 text-xs font-medium " (classes)} {
|
div class="p-6 space-y-4" {
|
||||||
(label)
|
// 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)" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn logs_tab(device: &DeviceDetail) -> Markup {
|
||||||
|
html! {
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
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}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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! {
|
||||||
|
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<f64> = (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)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
pub mod alerts;
|
||||||
|
pub mod badges;
|
||||||
pub mod dashboard;
|
pub mod dashboard;
|
||||||
pub mod deployments;
|
pub mod deployments;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
|
pub mod settings;
|
||||||
|
|||||||
70
fleet/harmony-fleet-operator/src/frontend/views/settings.rs
Normal file
70
fleet/harmony-fleet-operator/src/frontend/views/settings.rs
Normal file
@@ -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#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>"#.to_string(),
|
||||||
|
"slack" => r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>"#.to_string(),
|
||||||
|
"discord" => r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="none"><path d="M20.317 4.37a19.79 19.79 0 0 0-4.885-1.515.07.07 0 0 0-.07.035c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.07-.035 19.74 19.74 0 0 0-4.885 1.515.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.001.01.008.02.018.027a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.873-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.84 19.84 0 0 0 6.002-3.03.077.077 0 0 0 .018-.026c.5-5.177-.838-9.674-3.548-13.66a.061.061 0 0 0-.031-.029zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>"#.to_string(),
|
||||||
|
"sms" => r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>"#.to_string(),
|
||||||
|
_ => r#"<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/></svg>"#.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,8 @@ enum Command {
|
|||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<()> {
|
async fn main() -> Result<()> {
|
||||||
|
dotenvy::dotenv().ok();
|
||||||
|
|
||||||
tracing_subscriber::fmt()
|
tracing_subscriber::fmt()
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
||||||
.init();
|
.init();
|
||||||
@@ -157,6 +159,7 @@ async fn serve_web(
|
|||||||
live_reload: bool,
|
live_reload: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use frontend::server::{AppState, Config};
|
use frontend::server::{AppState, Config};
|
||||||
use service::{FleetService, mock::MockFleetService};
|
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(
|
frontend::server::run(
|
||||||
Config::new(AppState {
|
Config::new(AppState {
|
||||||
fleet,
|
fleet,
|
||||||
|
cookie_key,
|
||||||
css_override: css_from,
|
css_override: css_from,
|
||||||
live_reload,
|
live_reload,
|
||||||
|
config,
|
||||||
|
http_client,
|
||||||
|
jwks,
|
||||||
})
|
})
|
||||||
.with_addr(addr),
|
.with_addr(addr),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,23 +1,17 @@
|
|||||||
//! In-memory `FleetService` with seeded fake data.
|
use std::collections::{HashMap, HashSet};
|
||||||
//!
|
|
||||||
//! 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::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{Duration, Utc};
|
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
DashboardSummary, DeploymentStatus, DeploymentSummary, DeviceStatus, DeviceSummary,
|
Activity, Alert, AlertSeverity, DashboardDetail, DeploymentDetail, DeploymentStatus,
|
||||||
FleetService,
|
DeviceDetail, DeviceStatus, FleetService, TaskGraph, TaskNode, TaskStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct MockFleetService {
|
pub struct MockFleetService {
|
||||||
devices: Mutex<HashMap<String, DeviceSummary>>,
|
devices: Mutex<Vec<DeviceDetail>>,
|
||||||
deployments: Mutex<Vec<DeploymentSummary>>,
|
deployments: Mutex<Vec<DeploymentDetail>>,
|
||||||
|
alerts: Mutex<Vec<Alert>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for MockFleetService {
|
impl Default for MockFleetService {
|
||||||
@@ -28,169 +22,639 @@ impl Default for MockFleetService {
|
|||||||
|
|
||||||
impl MockFleetService {
|
impl MockFleetService {
|
||||||
pub fn with_seeded_data() -> Self {
|
pub fn with_seeded_data() -> Self {
|
||||||
let now = Utc::now();
|
let devices = seed_devices();
|
||||||
let devices = [
|
let deployments = seed_deployments();
|
||||||
(
|
let alerts = seed_alerts();
|
||||||
"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<String, DeviceSummary> = 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,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
devices: Mutex::new(devices),
|
devices: Mutex::new(devices),
|
||||||
deployments: Mutex::new(deployments),
|
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<f64> {
|
||||||
|
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<DeviceDetail> {
|
||||||
|
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<DeploymentDetail> {
|
||||||
|
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<Alert> {
|
||||||
|
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<Activity> {
|
||||||
|
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<u32> {
|
||||||
|
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<f64> {
|
||||||
|
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]
|
#[async_trait]
|
||||||
impl FleetService for MockFleetService {
|
impl FleetService for MockFleetService {
|
||||||
async fn dashboard_summary(&self) -> anyhow::Result<DashboardSummary> {
|
async fn dashboard_detail(&self) -> anyhow::Result<DashboardDetail> {
|
||||||
let devices = self.devices.lock().unwrap();
|
let devices = self.devices.lock().unwrap();
|
||||||
let deployments = self.deployments.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,
|
devices_total: devices.len() as u32,
|
||||||
deployments_total: deployments.len() as u32,
|
devices_healthy: 0,
|
||||||
..Default::default()
|
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 {
|
for dev in devices.iter() {
|
||||||
DeviceStatus::Healthy => s.devices_healthy += 1,
|
match dev.status {
|
||||||
DeviceStatus::Pending => s.devices_pending += 1,
|
DeviceStatus::Healthy => d.devices_healthy += 1,
|
||||||
DeviceStatus::Stale => s.devices_stale += 1,
|
DeviceStatus::Pending => d.devices_pending += 1,
|
||||||
DeviceStatus::Blacklisted => s.devices_blacklisted += 1,
|
DeviceStatus::Stale => d.devices_stale += 1,
|
||||||
DeviceStatus::Unknown => {}
|
DeviceStatus::Failing => d.devices_failing += 1,
|
||||||
|
DeviceStatus::Blacklisted => d.devices_blacklisted += 1,
|
||||||
|
DeviceStatus::Unknown => d.devices_unknown += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for d in deployments.iter() {
|
d.health_pct =
|
||||||
match d.status {
|
((d.devices_healthy as f64 / d.devices_total as f64) * 100.0).round() as u32;
|
||||||
DeploymentStatus::Active | DeploymentStatus::Rolling => s.deployments_active += 1,
|
|
||||||
DeploymentStatus::Failing => s.deployments_failing += 1,
|
d.attention_devices = devices
|
||||||
DeploymentStatus::Paused => {}
|
.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)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_devices(&self) -> anyhow::Result<Vec<DeviceSummary>> {
|
d.top_deployments.truncate(4);
|
||||||
let mut out: Vec<_> = self.devices.lock().unwrap().values().cloned().collect();
|
Ok(d)
|
||||||
out.sort_by(|a, b| a.id.cmp(&b.id));
|
|
||||||
Ok(out)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_device(&self, id: &str) -> anyhow::Result<Option<DeviceSummary>> {
|
async fn list_devices(&self) -> anyhow::Result<Vec<DeviceDetail>> {
|
||||||
Ok(self.devices.lock().unwrap().get(id).cloned())
|
Ok(self.devices.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_deployments(&self) -> anyhow::Result<Vec<DeploymentSummary>> {
|
async fn get_device(&self, id: &str) -> anyhow::Result<Option<DeviceDetail>> {
|
||||||
|
Ok(self
|
||||||
|
.devices
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.id == id)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_deployments(&self) -> anyhow::Result<Vec<DeploymentDetail>> {
|
||||||
Ok(self.deployments.lock().unwrap().clone())
|
Ok(self.deployments.lock().unwrap().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn blacklist_device(&self, id: &str) -> anyhow::Result<DeviceSummary> {
|
async fn get_deployment(&self, name: &str) -> anyhow::Result<Option<DeploymentDetail>> {
|
||||||
|
Ok(self
|
||||||
|
.deployments
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.name == name)
|
||||||
|
.cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_deployment_devices(&self, name: &str) -> anyhow::Result<Vec<DeviceDetail>> {
|
||||||
|
Ok(self
|
||||||
|
.devices
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|d| d.deployment.as_deref() == Some(name))
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn blacklist_device(&self, id: &str) -> anyhow::Result<DeviceDetail> {
|
||||||
let mut devices = self.devices.lock().unwrap();
|
let mut devices = self.devices.lock().unwrap();
|
||||||
let dev = devices
|
let dev = devices
|
||||||
.get_mut(id)
|
.iter_mut()
|
||||||
|
.find(|d| d.id == id)
|
||||||
.ok_or_else(|| anyhow::anyhow!("device {id} not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("device {id} not found"))?;
|
||||||
dev.status = DeviceStatus::Blacklisted;
|
dev.status = DeviceStatus::Blacklisted;
|
||||||
dev.deployment = None;
|
dev.deployment = None;
|
||||||
Ok(dev.clone())
|
Ok(dev.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_alerts(&self) -> anyhow::Result<Vec<Alert>> {
|
||||||
|
Ok(self.alerts.lock().unwrap().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ack_alert(&self, id: &str) -> anyhow::Result<bool> {
|
||||||
|
let mut alerts = self.alerts.lock().unwrap();
|
||||||
|
if let Some(a) = alerts.iter_mut().find(|a| a.id == id) {
|
||||||
|
a.acked = true;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_task_graph(&self, _deployment: &str) -> anyhow::Result<TaskGraph> {
|
||||||
|
Ok(task_graph())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn filtered_devices(
|
||||||
|
&self,
|
||||||
|
status: Option<DeviceStatus>,
|
||||||
|
deployment: Option<String>,
|
||||||
|
region: Option<String>,
|
||||||
|
search: Option<String>,
|
||||||
|
) -> anyhow::Result<Vec<DeviceDetail>> {
|
||||||
|
let devices = self.devices.lock().unwrap();
|
||||||
|
let mut out: Vec<DeviceDetail> = devices.iter().cloned().filter(|d| {
|
||||||
|
if let Some(s) = status {
|
||||||
|
if d.status != s { return false; }
|
||||||
|
}
|
||||||
|
if let Some(ref dep) = deployment {
|
||||||
|
if d.deployment.as_deref() != Some(dep.as_str()) { return false; }
|
||||||
|
}
|
||||||
|
if let Some(ref reg) = region {
|
||||||
|
if d.region != *reg { return false; }
|
||||||
|
}
|
||||||
|
if let Some(ref q) = search {
|
||||||
|
let q = q.to_lowercase();
|
||||||
|
if !d.id.to_lowercase().contains(&q)
|
||||||
|
&& !d.deployment.as_deref().unwrap_or("").to_lowercase().contains(&q)
|
||||||
|
&& !d.ip.as_deref().unwrap_or("").contains(&q)
|
||||||
|
&& !d.tags.iter().any(|t| t.to_lowercase().contains(&q))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}).collect();
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -198,24 +662,33 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn dashboard_summary_counts_by_status() {
|
async fn dashboard_detail_counts() {
|
||||||
let svc = MockFleetService::default();
|
let svc = MockFleetService::default();
|
||||||
let s = svc.dashboard_summary().await.unwrap();
|
let d = svc.dashboard_detail().await.unwrap();
|
||||||
assert_eq!(s.devices_total, 10);
|
assert_eq!(d.devices_total, 100);
|
||||||
assert_eq!(s.devices_healthy, 4);
|
assert!(d.devices_healthy > 0);
|
||||||
assert_eq!(s.devices_pending, 2);
|
assert!(d.health_pct > 0);
|
||||||
assert_eq!(s.devices_stale, 2);
|
assert!(!d.activity_feed.is_empty());
|
||||||
assert_eq!(s.devices_blacklisted, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn blacklist_flips_status() {
|
async fn blacklist_flips_status() {
|
||||||
let svc = MockFleetService::default();
|
let svc = MockFleetService::default();
|
||||||
let before = svc.get_device("pi-001").await.unwrap().unwrap();
|
let dev = svc.get_device("hf-edge-001").await.unwrap().unwrap();
|
||||||
assert_eq!(before.status, DeviceStatus::Healthy);
|
assert_eq!(dev.status, DeviceStatus::Healthy);
|
||||||
svc.blacklist_device("pi-001").await.unwrap();
|
svc.blacklist_device("hf-edge-001").await.unwrap();
|
||||||
let after = svc.get_device("pi-001").await.unwrap().unwrap();
|
let after = svc.get_device("hf-edge-001").await.unwrap().unwrap();
|
||||||
assert_eq!(after.status, DeviceStatus::Blacklisted);
|
assert_eq!(after.status, DeviceStatus::Blacklisted);
|
||||||
assert!(after.deployment.is_none());
|
assert!(after.deployment.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn filtered_devices_by_status() {
|
||||||
|
let svc = MockFleetService::default();
|
||||||
|
let failing = svc
|
||||||
|
.filtered_devices(Some(DeviceStatus::Failing), None, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(failing.iter().all(|d| d.status == DeviceStatus::Failing));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,3 @@
|
|||||||
//! Domain-level fleet query/command surface.
|
|
||||||
//!
|
|
||||||
//! Presentation (the `frontend` module) and any future CLI both call
|
|
||||||
//! into this trait. Implementations:
|
|
||||||
//!
|
|
||||||
//! - [`mock::MockFleetService`] — in-memory fake data, for `serve-web --mock`
|
|
||||||
//! and tests. Reachable without NATS or a Kubernetes cluster.
|
|
||||||
//! - `real::KubeNatsFleetService` (TODO) — wraps the operator's real
|
|
||||||
//! data sources (kube client + NATS JetStream KV).
|
|
||||||
|
|
||||||
// The whole module is dead code when neither the web frontend nor any
|
|
||||||
// future CLI is compiled in — it's intentionally a library surface.
|
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
pub mod mock;
|
pub mod mock;
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -20,28 +6,51 @@ use serde::Serialize;
|
|||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
pub trait FleetService: Send + Sync + 'static {
|
pub trait FleetService: Send + Sync + 'static {
|
||||||
async fn dashboard_summary(&self) -> anyhow::Result<DashboardSummary>;
|
async fn dashboard_detail(&self) -> anyhow::Result<DashboardDetail>;
|
||||||
async fn list_devices(&self) -> anyhow::Result<Vec<DeviceSummary>>;
|
async fn list_devices(&self) -> anyhow::Result<Vec<DeviceDetail>>;
|
||||||
async fn get_device(&self, id: &str) -> anyhow::Result<Option<DeviceSummary>>;
|
async fn get_device(&self, id: &str) -> anyhow::Result<Option<DeviceDetail>>;
|
||||||
async fn list_deployments(&self) -> anyhow::Result<Vec<DeploymentSummary>>;
|
async fn list_deployments(&self) -> anyhow::Result<Vec<DeploymentDetail>>;
|
||||||
async fn blacklist_device(&self, id: &str) -> anyhow::Result<DeviceSummary>;
|
async fn get_deployment(&self, name: &str) -> anyhow::Result<Option<DeploymentDetail>>;
|
||||||
|
async fn get_deployment_devices(&self, name: &str) -> anyhow::Result<Vec<DeviceDetail>>;
|
||||||
|
async fn blacklist_device(&self, id: &str) -> anyhow::Result<DeviceDetail>;
|
||||||
|
async fn list_alerts(&self) -> anyhow::Result<Vec<Alert>>;
|
||||||
|
async fn ack_alert(&self, id: &str) -> anyhow::Result<bool>;
|
||||||
|
async fn get_task_graph(&self, deployment: &str) -> anyhow::Result<TaskGraph>;
|
||||||
|
async fn filtered_devices(
|
||||||
|
&self,
|
||||||
|
status: Option<DeviceStatus>,
|
||||||
|
deployment: Option<String>,
|
||||||
|
region: Option<String>,
|
||||||
|
search: Option<String>,
|
||||||
|
) -> anyhow::Result<Vec<DeviceDetail>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Device ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct DeviceSummary {
|
pub struct DeviceDetail {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub status: DeviceStatus,
|
pub status: DeviceStatus,
|
||||||
pub last_seen: DateTime<Utc>,
|
pub last_seen: DateTime<Utc>,
|
||||||
|
pub minutes_ago: i64,
|
||||||
pub deployment: Option<String>,
|
pub deployment: Option<String>,
|
||||||
pub ip: Option<String>,
|
pub ip: Option<String>,
|
||||||
|
pub region: String,
|
||||||
|
pub model: String,
|
||||||
|
pub fw: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub uptime_h: u32,
|
||||||
|
pub cpu: u8,
|
||||||
|
pub mem: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum DeviceStatus {
|
pub enum DeviceStatus {
|
||||||
Healthy,
|
Healthy,
|
||||||
Pending,
|
Pending,
|
||||||
Stale,
|
Stale,
|
||||||
|
Failing,
|
||||||
Blacklisted,
|
Blacklisted,
|
||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
@@ -49,24 +58,32 @@ pub enum DeviceStatus {
|
|||||||
impl DeviceStatus {
|
impl DeviceStatus {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
DeviceStatus::Healthy => "healthy",
|
Self::Healthy => "healthy",
|
||||||
DeviceStatus::Pending => "pending",
|
Self::Pending => "pending",
|
||||||
DeviceStatus::Stale => "stale",
|
Self::Stale => "stale",
|
||||||
DeviceStatus::Blacklisted => "blacklisted",
|
Self::Failing => "failing",
|
||||||
DeviceStatus::Unknown => "unknown",
|
Self::Blacklisted => "blacklisted",
|
||||||
|
Self::Unknown => "unknown",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Deployment ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
pub struct DeploymentSummary {
|
pub struct DeploymentDetail {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
pub version: String,
|
||||||
pub status: DeploymentStatus,
|
pub status: DeploymentStatus,
|
||||||
pub target_devices: u32,
|
pub target: u32,
|
||||||
pub healthy_devices: u32,
|
pub healthy: u32,
|
||||||
|
pub failing: u32,
|
||||||
|
pub pending: u32,
|
||||||
|
pub updated_at: String,
|
||||||
|
pub author: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
|
||||||
#[serde(rename_all = "kebab-case")]
|
#[serde(rename_all = "kebab-case")]
|
||||||
pub enum DeploymentStatus {
|
pub enum DeploymentStatus {
|
||||||
Active,
|
Active,
|
||||||
@@ -78,22 +95,101 @@ pub enum DeploymentStatus {
|
|||||||
impl DeploymentStatus {
|
impl DeploymentStatus {
|
||||||
pub fn label(self) -> &'static str {
|
pub fn label(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
DeploymentStatus::Active => "active",
|
Self::Active => "active",
|
||||||
DeploymentStatus::Rolling => "rolling",
|
Self::Rolling => "rolling",
|
||||||
DeploymentStatus::Failing => "failing",
|
Self::Failing => "failing",
|
||||||
DeploymentStatus::Paused => "paused",
|
Self::Paused => "paused",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize)]
|
// ── Dashboard ──────────────────────────────────────────────────────────
|
||||||
pub struct DashboardSummary {
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct DashboardDetail {
|
||||||
pub devices_total: u32,
|
pub devices_total: u32,
|
||||||
pub devices_healthy: u32,
|
pub devices_healthy: u32,
|
||||||
pub devices_pending: u32,
|
pub devices_pending: u32,
|
||||||
|
pub devices_failing: u32,
|
||||||
pub devices_stale: u32,
|
pub devices_stale: u32,
|
||||||
pub devices_blacklisted: u32,
|
pub devices_blacklisted: u32,
|
||||||
pub deployments_total: u32,
|
pub devices_unknown: u32,
|
||||||
pub deployments_active: u32,
|
pub deployments_total: usize,
|
||||||
pub deployments_failing: u32,
|
pub health_pct: u32,
|
||||||
|
pub health_trend: Vec<f64>,
|
||||||
|
pub ingest_rate: u32,
|
||||||
|
pub ingest_trend: Vec<u32>,
|
||||||
|
pub attention_devices: Vec<DeviceDetail>,
|
||||||
|
pub activity_feed: Vec<Activity>,
|
||||||
|
pub top_deployments: Vec<DeploymentDetail>,
|
||||||
|
pub active_alerts: Vec<Alert>,
|
||||||
|
pub rolling_count: usize,
|
||||||
|
pub failing_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alert ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Alert {
|
||||||
|
pub id: String,
|
||||||
|
pub severity: AlertSeverity,
|
||||||
|
pub title: String,
|
||||||
|
pub deployment: Option<String>,
|
||||||
|
pub device: Option<String>,
|
||||||
|
pub at: String,
|
||||||
|
pub acked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum AlertSeverity {
|
||||||
|
Critical,
|
||||||
|
Warning,
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AlertSeverity {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Critical => "critical",
|
||||||
|
Self::Warning => "warning",
|
||||||
|
Self::Info => "info",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct Activity {
|
||||||
|
pub who: String,
|
||||||
|
pub verb: String,
|
||||||
|
pub target: String,
|
||||||
|
pub at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task Graph ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TaskGraph {
|
||||||
|
pub nodes: Vec<TaskNode>,
|
||||||
|
pub edges: Vec<(String, String)>,
|
||||||
|
pub positions: std::collections::HashMap<String, (usize, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct TaskNode {
|
||||||
|
pub id: String,
|
||||||
|
pub label: String,
|
||||||
|
pub status: TaskStatus,
|
||||||
|
pub duration: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum TaskStatus {
|
||||||
|
Done,
|
||||||
|
Running,
|
||||||
|
Pending,
|
||||||
|
Failed,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,105 @@
|
|||||||
|
|
||||||
@source "../src";
|
@source "../src";
|
||||||
@source "../style";
|
@source "../style";
|
||||||
|
|
||||||
|
/* ── CSS Custom Properties (default theme) ─────────────────────────── */
|
||||||
|
:root {
|
||||||
|
--bg: #07090c;
|
||||||
|
--bg-elev: #0c1018;
|
||||||
|
--bg-elev-2: #11151f;
|
||||||
|
--border: rgba(148, 163, 184, 0.08);
|
||||||
|
--border-strong: rgba(148, 163, 184, 0.16);
|
||||||
|
--accent: #f97316; /* orange-500 */
|
||||||
|
--accent-soft: rgba(249, 115, 22, 0.14);
|
||||||
|
--accent-fg: #fdba74;
|
||||||
|
--ok: #34d399;
|
||||||
|
--ok-soft: rgba(52, 211, 153, 0.13);
|
||||||
|
--warn: #fbbf24;
|
||||||
|
--warn-soft: rgba(251, 191, 36, 0.13);
|
||||||
|
--bad: #fb7185;
|
||||||
|
--bad-soft: rgba(251, 113, 133, 0.13);
|
||||||
|
--info: #60a5fa;
|
||||||
|
--info-soft: rgba(96, 165, 250, 0.13);
|
||||||
|
--row-h: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { background: var(--bg); }
|
||||||
|
body { font-family: 'Inter', sans-serif; color: #e2e8f0; -webkit-font-smoothing: antialiased; }
|
||||||
|
|
||||||
|
::selection { background: var(--accent-soft); color: #fff; }
|
||||||
|
|
||||||
|
/* ── Scrollbar ──────────────────────────────────────────────────────── */
|
||||||
|
*::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: rgba(148,163,184,0.14); border-radius: 999px; border: 2px solid transparent; background-clip: content-box; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: rgba(148,163,184,0.28); background-clip: content-box; border: 2px solid transparent; }
|
||||||
|
|
||||||
|
/* ── Animations ─────────────────────────────────────────────────────── */
|
||||||
|
@keyframes ping-soft { 0% { transform: scale(1); opacity: .55; } 75%, 100% { transform: scale(2.4); opacity: 0; } }
|
||||||
|
.pulse-dot::after { content:''; position:absolute; inset:0; border-radius:9999px; background:currentColor; animation: ping-soft 1.8s cubic-bezier(0,0,.2,1) infinite; }
|
||||||
|
.pulse-dot { position: relative; }
|
||||||
|
|
||||||
|
@keyframes log-in { from { opacity:0; transform: translateY(2px); } to { opacity:1; transform: translateY(0); } }
|
||||||
|
.log-line { animation: log-in .22s ease-out both; }
|
||||||
|
|
||||||
|
@keyframes draw { from { stroke-dashoffset: 1; } to { stroke-dashoffset: 0; } }
|
||||||
|
.spark-path { stroke-dasharray: 1; stroke-dashoffset: 1; animation: draw 1.4s ease-out forwards; }
|
||||||
|
|
||||||
|
@keyframes toast-in { from { opacity:0; transform: translateY(8px) scale(.98); } to { opacity:1; transform: translateY(0) scale(1); } }
|
||||||
|
.toast-in { animation: toast-in .25s cubic-bezier(.2,.7,.3,1) both; }
|
||||||
|
@keyframes toast-out { to { opacity:0; transform: translateY(-6px) scale(.98); } }
|
||||||
|
.toast-out { animation: toast-out .22s ease-in both; }
|
||||||
|
|
||||||
|
@keyframes roll-marquee { 0% { transform: translateX(-100%); } 100% { transform: translateX(400%); } }
|
||||||
|
|
||||||
|
/* ── Grid background ────────────────────────────────────────────────── */
|
||||||
|
.grid-bg {
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(148,163,184,0.04) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(148,163,184,0.04) 1px, transparent 1px);
|
||||||
|
background-size: 32px 32px;
|
||||||
|
background-position: -1px -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Density ────────────────────────────────────────────────────────── */
|
||||||
|
.density-compact { --row-h: 32px; }
|
||||||
|
.density-comfort { --row-h: 44px; }
|
||||||
|
|
||||||
|
/* ── Buttons ────────────────────────────────────────────────────────── */
|
||||||
|
.btn { display:inline-flex; align-items:center; gap:6px; padding:6px 10px; border-radius:7px; font-size:12px; font-weight:500; transition: all .15s; cursor: pointer; border: 1px solid transparent; }
|
||||||
|
.btn-primary { background: var(--accent); color: #0c0c0c; }
|
||||||
|
.btn-primary:hover { filter: brightness(1.1); }
|
||||||
|
.btn-ghost { background: transparent; color: #cbd5e1; border-color: var(--border-strong); }
|
||||||
|
.btn-ghost:hover { background: rgba(148,163,184,0.06); color:#f1f5f9; }
|
||||||
|
.btn-danger { background: rgba(244, 63, 94, 0.12); color: #fb7185; border-color: rgba(244,63,94,0.25); }
|
||||||
|
.btn-danger:hover { background: rgba(244, 63, 94, 0.2); color:#fda4af; }
|
||||||
|
|
||||||
|
/* ── Cards ──────────────────────────────────────────────────────────── */
|
||||||
|
.card { background: var(--bg-elev); border: 1px solid var(--border); border-radius: 10px; }
|
||||||
|
.card-flush { border-radius: 10px; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ── Tables ─────────────────────────────────────────────────────────── */
|
||||||
|
.tbl { width: 100%; font-size: 13px; }
|
||||||
|
.tbl thead th { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.08em; color: #64748b; font-weight: 600; padding: 10px 14px; text-align: left; background: rgba(148,163,184,0.02); border-bottom: 1px solid var(--border); }
|
||||||
|
.tbl tbody td { padding: 0 14px; height: var(--row-h); border-bottom: 1px solid var(--border); color: #cbd5e1; }
|
||||||
|
.tbl tbody tr:hover { background: rgba(148,163,184,0.025); }
|
||||||
|
.tbl tbody tr.selected { background: var(--accent-soft); }
|
||||||
|
.tbl tbody tr.selected td { color: #f1f5f9; }
|
||||||
|
|
||||||
|
/* ── Inputs ─────────────────────────────────────────────────────────── */
|
||||||
|
.input { background: var(--bg-elev-2); border: 1px solid var(--border-strong); border-radius: 7px; padding: 6px 10px 6px 30px; font-size: 13px; color: #e2e8f0; outline: none; transition: border .15s; }
|
||||||
|
.input:focus { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ── Chips ──────────────────────────────────────────────────────────── */
|
||||||
|
.chip { display:inline-flex; align-items:center; gap:6px; padding: 4px 9px; border-radius: 999px; font-size: 11px; font-weight: 500; border: 1px solid var(--border-strong); background: rgba(148,163,184,0.03); color:#cbd5e1; cursor:pointer; }
|
||||||
|
.chip.active { background: var(--accent-soft); color: var(--accent-fg); border-color: rgba(249,115,22,0.35); }
|
||||||
|
.chip:hover:not(.active) { color:#f1f5f9; background: rgba(148,163,184,0.07); }
|
||||||
|
|
||||||
|
/* ── Section title ──────────────────────────────────────────────────── */
|
||||||
|
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; color: #64748b; }
|
||||||
|
|
||||||
|
/* ── Progress bar ───────────────────────────────────────────────────── */
|
||||||
|
.progress-bg { background: rgba(148,163,184,0.1); }
|
||||||
|
|
||||||
|
/* ── Font helpers ───────────────────────────────────────────────────── */
|
||||||
|
.id-mono { font-family: 'JetBrains Mono', ui-monospace, monospace; white-space: nowrap; }
|
||||||
|
|||||||
3
fleet/harmony-fleet-operator/vendor/app.js
vendored
Normal file
3
fleet/harmony-fleet-operator/vendor/app.js
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
document.body.addEventListener('htmx:configRequest', (event) => {
|
||||||
|
event.detail.headers['x-csrf-token'] = '1';
|
||||||
|
});
|
||||||
@@ -174,6 +174,7 @@ mod tests {
|
|||||||
#[cfg(feature = "reqwest")]
|
#[cfg(feature = "reqwest")]
|
||||||
mod download_tests {
|
mod download_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::ChecksumAlgo;
|
||||||
use httptest::{Expectation, Server, matchers::request, responders::*};
|
use httptest::{Expectation, Server, matchers::request, responders::*};
|
||||||
|
|
||||||
fn test_asset_with_url(url: &str, checksum: &str) -> Asset {
|
fn test_asset_with_url(url: &str, checksum: &str) -> Asset {
|
||||||
|
|||||||
30
harmony_zitadel_auth/Cargo.toml
Normal file
30
harmony_zitadel_auth/Cargo.toml
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
[package]
|
||||||
|
name = "harmony_zitadel_auth"
|
||||||
|
edition = "2024"
|
||||||
|
version.workspace = true
|
||||||
|
readme.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
axum = ["dep:axum", "dep:axum-extra"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow.workspace = true
|
||||||
|
base64.workspace = true
|
||||||
|
chrono = { workspace = true, features = ["serde"] }
|
||||||
|
rand.workspace = true
|
||||||
|
reqwest.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
sha2 = "0.10"
|
||||||
|
url.workspace = true
|
||||||
|
tokio = { workspace = true, features = ["time"] }
|
||||||
|
arc-swap = "1"
|
||||||
|
time = "0.3"
|
||||||
|
tracing = { workspace = true }
|
||||||
|
|
||||||
|
jsonwebtoken = "9"
|
||||||
|
openidconnect = { version = "4", default-features = false, features = ["reqwest", "rustls-tls"] }
|
||||||
|
axum = { version = "0.8", optional = true }
|
||||||
|
axum-extra = { version = "0.10", features = ["cookie", "cookie-private"], optional = true }
|
||||||
164
harmony_zitadel_auth/src/axum_login_flow.rs
Normal file
164
harmony_zitadel_auth/src/axum_login_flow.rs
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use axum::extract::{Query, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::{IntoResponse, Redirect, Response};
|
||||||
|
use axum_extra::extract::cookie::{Cookie, PrivateCookieJar, SameSite};
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
|
||||||
|
use crate::config::ZitadelAuthConfig;
|
||||||
|
use crate::jwks::JwksCache;
|
||||||
|
use crate::login::{
|
||||||
|
AuthCallbackQuery, RawAuthCallbackQuery, TokenResponse, build_login_attempt, build_logout_url,
|
||||||
|
exchange_code_for_token, jwt_exp, validate_callback_state,
|
||||||
|
};
|
||||||
|
use crate::session::LoginAttemptCookie;
|
||||||
|
|
||||||
|
pub const LOGIN_ATTEMPT_COOKIE: &str = "harmony_fleet_login_attempt";
|
||||||
|
pub const HARMONY_SESSION_COOKIE: &str = "harmony_fleet_session";
|
||||||
|
|
||||||
|
/// Session cookie holds the raw Zitadel JWT. The `PrivateCookieJar` (AES-GCM)
|
||||||
|
/// encrypts both the login-attempt cookie (PKCE verifier) and the session cookie
|
||||||
|
/// (id_token), so the JWT is never exposed in plaintext on the wire.
|
||||||
|
pub async fn login_handler(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
State(config): State<ZitadelAuthConfig>,
|
||||||
|
) -> Response {
|
||||||
|
match build_login_response(jar, &config) {
|
||||||
|
Ok(r) => r.into_response(),
|
||||||
|
Err(e) => auth_error_response(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_login_response(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
let attempt = build_login_attempt(config)?;
|
||||||
|
let cookie_payload = LoginAttemptCookie::from(&attempt);
|
||||||
|
let cookie_value = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&cookie_payload)?);
|
||||||
|
|
||||||
|
let mut builder = Cookie::build((LOGIN_ATTEMPT_COOKIE, cookie_value))
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.path("/")
|
||||||
|
.max_age(time::Duration::minutes(10));
|
||||||
|
if config.use_secure_cookies() {
|
||||||
|
builder = builder.secure(true);
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
jar.add(builder.build()),
|
||||||
|
Redirect::temporary(&attempt.authorize_url),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn logout_handler(
|
||||||
|
session_jar: PrivateCookieJar,
|
||||||
|
State(config): State<ZitadelAuthConfig>,
|
||||||
|
) -> Response {
|
||||||
|
match build_logout_response(session_jar, &config) {
|
||||||
|
Ok(r) => r.into_response(),
|
||||||
|
Err(e) => auth_error_response(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_logout_response(
|
||||||
|
session_jar: PrivateCookieJar,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
) -> Result<impl IntoResponse> {
|
||||||
|
// The session cookie value IS the raw JWT (id_token), used as the Zitadel logout hint.
|
||||||
|
let id_token = session_jar
|
||||||
|
.get(HARMONY_SESSION_COOKIE)
|
||||||
|
.map(|c| c.value().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let session_jar = session_jar.remove(Cookie::build(HARMONY_SESSION_COOKIE).path("/").build());
|
||||||
|
let logout_url = build_logout_url(config, &id_token)?;
|
||||||
|
Ok((session_jar, Redirect::to(logout_url.as_str())))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn callback_handler(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
session_jar: PrivateCookieJar,
|
||||||
|
State(config): State<ZitadelAuthConfig>,
|
||||||
|
State(http_client): State<reqwest::Client>,
|
||||||
|
State(jwks): State<JwksCache>,
|
||||||
|
Query(raw): Query<RawAuthCallbackQuery>,
|
||||||
|
) -> Response {
|
||||||
|
match build_callback_response(jar, session_jar, raw, &config, &http_client, &jwks).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => auth_error_response(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_callback_response(
|
||||||
|
jar: PrivateCookieJar,
|
||||||
|
session_jar: PrivateCookieJar,
|
||||||
|
raw: RawAuthCallbackQuery,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
jwks: &JwksCache,
|
||||||
|
) -> Result<Response> {
|
||||||
|
match AuthCallbackQuery::try_from(raw)? {
|
||||||
|
AuthCallbackQuery::Success { code, state } => {
|
||||||
|
let attempt = read_login_attempt_cookie(&jar)?;
|
||||||
|
let jar = jar.remove(Cookie::from(LOGIN_ATTEMPT_COOKIE));
|
||||||
|
validate_callback_state(&attempt, &state)?;
|
||||||
|
|
||||||
|
let tokens =
|
||||||
|
exchange_code_for_token(http_client, config, &attempt.pkce_code_verifier, &code)
|
||||||
|
.await?;
|
||||||
|
let verified = jwks.verify(&tokens.id_token, config).await?;
|
||||||
|
if verified.nonce.as_deref() != Some(attempt.nonce.as_str()) {
|
||||||
|
anyhow::bail!("auth callback nonce mismatch; start again at /login");
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_jar = session_jar.add(session_cookie(&tokens, config));
|
||||||
|
Ok((jar, session_jar, Redirect::to("/")).into_response())
|
||||||
|
}
|
||||||
|
AuthCallbackQuery::Failure {
|
||||||
|
error,
|
||||||
|
error_description,
|
||||||
|
} => {
|
||||||
|
anyhow::bail!(
|
||||||
|
"SSO callback returned an error: {error} {}",
|
||||||
|
error_description.unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_cookie(tokens: &TokenResponse, config: &ZitadelAuthConfig) -> Cookie<'static> {
|
||||||
|
let max_age_secs =
|
||||||
|
jwt_exp(&tokens.id_token).map(|exp| (exp - chrono::Utc::now().timestamp()).max(0));
|
||||||
|
|
||||||
|
let mut builder = Cookie::build((HARMONY_SESSION_COOKIE, tokens.id_token.clone()))
|
||||||
|
.http_only(true)
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.path("/");
|
||||||
|
if config.use_secure_cookies() {
|
||||||
|
builder = builder.secure(true);
|
||||||
|
}
|
||||||
|
if let Some(secs) = max_age_secs {
|
||||||
|
builder = builder.max_age(time::Duration::seconds(secs));
|
||||||
|
}
|
||||||
|
builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn read_login_attempt_cookie(jar: &PrivateCookieJar) -> Result<LoginAttemptCookie> {
|
||||||
|
let cookie = jar
|
||||||
|
.get(LOGIN_ATTEMPT_COOKIE)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing login attempt cookie; start again at /login"))?;
|
||||||
|
let bytes = URL_SAFE_NO_PAD
|
||||||
|
.decode(cookie.value())
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid login attempt cookie encoding: {e}"))?;
|
||||||
|
serde_json::from_slice::<LoginAttemptCookie>(&bytes)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid login attempt cookie payload: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn auth_error_response(e: anyhow::Error) -> Response {
|
||||||
|
(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
format!("SSO login failed\nError: {e}\n"),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
75
harmony_zitadel_auth/src/config.rs
Normal file
75
harmony_zitadel_auth/src/config.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ZitadelAuthConfig {
|
||||||
|
pub zitadel_base: String,
|
||||||
|
pub base_url: String,
|
||||||
|
pub client_id: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub trusted_audiences: Vec<String>,
|
||||||
|
pub logout_redirect_uri: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ZitadelAuthConfig {
|
||||||
|
pub fn issuer_url(&self) -> String {
|
||||||
|
self.zitadel_base.clone()
|
||||||
|
}
|
||||||
|
pub fn authorize_url(&self) -> String {
|
||||||
|
format!("{}/oauth/v2/authorize", self.zitadel_base)
|
||||||
|
}
|
||||||
|
pub fn token_url(&self) -> String {
|
||||||
|
format!("{}/oauth/v2/token", self.zitadel_base)
|
||||||
|
}
|
||||||
|
pub fn logout_url(&self) -> String {
|
||||||
|
format!("{}/oidc/v1/end_session", self.zitadel_base)
|
||||||
|
}
|
||||||
|
pub fn redirect_uri(&self) -> String {
|
||||||
|
format!("{}/auth/callback", self.base_url)
|
||||||
|
}
|
||||||
|
pub fn logout_redirect_uri(&self) -> String {
|
||||||
|
self.logout_redirect_uri.clone()
|
||||||
|
}
|
||||||
|
/// Whether to set the `Secure` flag on cookies. True when `base_url` is HTTPS.
|
||||||
|
pub fn use_secure_cookies(&self) -> bool {
|
||||||
|
self.base_url.starts_with("https://")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ZITADEL_BASE_ENV: &str = "FLEET_AUTH_ZITADEL_BASE";
|
||||||
|
pub const BASE_URL_ENV: &str = "BASE_URL";
|
||||||
|
pub const CLIENT_ID_ENV: &str = "FLEET_AUTH_CLIENT_ID";
|
||||||
|
pub const SCOPE_ENV: &str = "FLEET_AUTH_SCOPE";
|
||||||
|
pub const TRUSTED_AUDIENCES_ENV: &str = "FLEET_AUTH_TRUSTED_AUDIENCES";
|
||||||
|
pub const LOGOUT_REDIRECT_URI_ENV: &str = "FLEET_AUTH_LOGOUT_REDIRECT_URI";
|
||||||
|
pub const COOKIE_KEY_ENV: &str = "FLEET_OPERATOR_COOKIE_KEY_B64";
|
||||||
|
|
||||||
|
pub fn config_from_env() -> ZitadelAuthConfig {
|
||||||
|
ZitadelAuthConfig {
|
||||||
|
zitadel_base: required_env(ZITADEL_BASE_ENV),
|
||||||
|
base_url: required_env(BASE_URL_ENV),
|
||||||
|
client_id: required_env(CLIENT_ID_ENV),
|
||||||
|
scope: required_env(SCOPE_ENV),
|
||||||
|
trusted_audiences: required_env(TRUSTED_AUDIENCES_ENV)
|
||||||
|
.split(',')
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
logout_redirect_uri: required_env(LOGOUT_REDIRECT_URI_ENV),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "axum")]
|
||||||
|
pub fn cookie_key_from_env() -> axum_extra::extract::cookie::Key {
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::STANDARD;
|
||||||
|
|
||||||
|
let encoded = required_env(COOKIE_KEY_ENV);
|
||||||
|
let bytes = STANDARD
|
||||||
|
.decode(encoded.trim())
|
||||||
|
.unwrap_or_else(|e| panic!("{COOKIE_KEY_ENV} must be standard base64: {e}"));
|
||||||
|
if bytes.len() < 64 {
|
||||||
|
panic!("{COOKIE_KEY_ENV} must decode to at least 64 bytes for private cookies");
|
||||||
|
}
|
||||||
|
axum_extra::extract::cookie::Key::from(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_env(name: &str) -> String {
|
||||||
|
std::env::var(name).unwrap_or_else(|_| panic!("missing required environment variable {name}"))
|
||||||
|
}
|
||||||
210
harmony_zitadel_auth/src/jwks.rs
Normal file
210
harmony_zitadel_auth/src/jwks.rs
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::config::ZitadelAuthConfig;
|
||||||
|
use crate::session::VerifiedSession;
|
||||||
|
|
||||||
|
struct JwksCacheInner {
|
||||||
|
set: jsonwebtoken::jwk::JwkSet,
|
||||||
|
last_forced_refresh: Option<Instant>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached Zitadel JWKS for per-request JWT verification.
|
||||||
|
///
|
||||||
|
/// Reads are lock-free via `ArcSwap` — only refreshes pay any coordination
|
||||||
|
/// cost. `Clone` is cheap; the inner state is `Arc`-wrapped.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct JwksCache {
|
||||||
|
inner: Arc<arc_swap::ArcSwap<JwksCacheInner>>,
|
||||||
|
jwks_uri: Arc<str>,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JwksCache {
|
||||||
|
/// Fetch the JWKS via OIDC discovery and build the cache.
|
||||||
|
pub async fn new(issuer_url: &str, http: reqwest::Client) -> Result<Self> {
|
||||||
|
let jwks_uri = discover_jwks_uri(issuer_url, &http).await?;
|
||||||
|
let set = fetch_jwks(&jwks_uri, &http).await?;
|
||||||
|
tracing::debug!(%jwks_uri, keys = set.keys.len(), "JWKS loaded");
|
||||||
|
Ok(Self {
|
||||||
|
inner: Arc::new(arc_swap::ArcSwap::from_pointee(JwksCacheInner {
|
||||||
|
set,
|
||||||
|
last_forced_refresh: None,
|
||||||
|
})),
|
||||||
|
jwks_uri: jwks_uri.into(),
|
||||||
|
http,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn a background task that refreshes the JWKS on the given `interval`.
|
||||||
|
///
|
||||||
|
/// On failure the stale keys are kept and a warning is logged — a Zitadel
|
||||||
|
/// blip must not log everyone out.
|
||||||
|
pub fn spawn_background_refresh(&self, interval: Duration) {
|
||||||
|
let cache = self.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut ticker = tokio::time::interval(interval);
|
||||||
|
ticker.tick().await; // skip the first immediate tick
|
||||||
|
loop {
|
||||||
|
ticker.tick().await;
|
||||||
|
match fetch_jwks(&cache.jwks_uri, &cache.http).await {
|
||||||
|
Ok(new_set) => {
|
||||||
|
let last_forced = cache.inner.load().last_forced_refresh;
|
||||||
|
cache.inner.store(Arc::new(JwksCacheInner {
|
||||||
|
set: new_set,
|
||||||
|
last_forced_refresh: last_forced,
|
||||||
|
}));
|
||||||
|
tracing::debug!("JWKS background refresh succeeded");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "JWKS background refresh failed; keeping stale keys")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a raw JWT string and return the validated session claims.
|
||||||
|
///
|
||||||
|
/// On unknown `kid`, performs one forced JWKS refresh (rate-limited to
|
||||||
|
/// once per 60 s) before giving up, to handle key rotation gracefully.
|
||||||
|
pub async fn verify(&self, token: &str, config: &ZitadelAuthConfig) -> Result<VerifiedSession> {
|
||||||
|
let header = jsonwebtoken::decode_header(token)
|
||||||
|
.map_err(|e| anyhow::anyhow!("invalid JWT header: {e}"))?;
|
||||||
|
let kid = header.kid.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
// Fast path: lock-free read — ArcSwap guard must not be held across awaits.
|
||||||
|
{
|
||||||
|
let inner = self.inner.load();
|
||||||
|
if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slow path: kid not found — maybe Zitadel rotated keys.
|
||||||
|
let should_refresh = self
|
||||||
|
.inner
|
||||||
|
.load()
|
||||||
|
.last_forced_refresh
|
||||||
|
.map(|t| t.elapsed() > Duration::from_secs(60))
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if should_refresh {
|
||||||
|
match fetch_jwks(&self.jwks_uri, &self.http).await {
|
||||||
|
Ok(new_set) => {
|
||||||
|
self.inner.store(Arc::new(JwksCacheInner {
|
||||||
|
set: new_set,
|
||||||
|
last_forced_refresh: Some(Instant::now()),
|
||||||
|
}));
|
||||||
|
let inner = self.inner.load();
|
||||||
|
if let Some(result) = try_verify_with_set(token, &inner.set, kid, config) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "JWKS forced refresh failed"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
anyhow::bail!("unknown JWT signing key (kid={kid:?})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_verify_with_set(
|
||||||
|
token: &str,
|
||||||
|
set: &jsonwebtoken::jwk::JwkSet,
|
||||||
|
kid: &str,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
) -> Option<Result<VerifiedSession>> {
|
||||||
|
let jwk = if kid.is_empty() {
|
||||||
|
set.keys.first()?
|
||||||
|
} else {
|
||||||
|
set.keys
|
||||||
|
.iter()
|
||||||
|
.find(|k| k.common.key_id.as_deref() == Some(kid))?
|
||||||
|
};
|
||||||
|
Some(verify_with_jwk(token, jwk, config))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn verify_with_jwk(
|
||||||
|
token: &str,
|
||||||
|
jwk: &jsonwebtoken::jwk::Jwk,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
) -> Result<VerifiedSession> {
|
||||||
|
use jsonwebtoken::jwk::AlgorithmParameters;
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
|
||||||
|
|
||||||
|
let decoding_key =
|
||||||
|
DecodingKey::from_jwk(jwk).map_err(|e| anyhow::anyhow!("invalid JWK: {e}"))?;
|
||||||
|
|
||||||
|
// Algorithm is determined from the JWK (server-controlled), not the token header,
|
||||||
|
// to avoid algorithm-confusion attacks.
|
||||||
|
let alg = match &jwk.algorithm {
|
||||||
|
AlgorithmParameters::RSA(_) => Algorithm::RS256,
|
||||||
|
AlgorithmParameters::EllipticCurve(ec) => {
|
||||||
|
use jsonwebtoken::jwk::EllipticCurve;
|
||||||
|
match ec.curve {
|
||||||
|
EllipticCurve::P256 => Algorithm::ES256,
|
||||||
|
EllipticCurve::P384 => Algorithm::ES384,
|
||||||
|
ref c => anyhow::bail!("unsupported elliptic curve: {c:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => anyhow::bail!("unsupported JWK key type: {other:?}"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut validation = Validation::new(alg);
|
||||||
|
validation.set_audience(&config.trusted_audiences);
|
||||||
|
validation.set_issuer(&[&config.zitadel_base]);
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Claims {
|
||||||
|
sub: String,
|
||||||
|
exp: i64,
|
||||||
|
email: Option<String>,
|
||||||
|
name: Option<String>,
|
||||||
|
nonce: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
let claims = decode::<Claims>(token, &decoding_key, &validation)
|
||||||
|
.map_err(|e| anyhow::anyhow!("JWT verification failed: {e}"))?
|
||||||
|
.claims;
|
||||||
|
|
||||||
|
Ok(VerifiedSession {
|
||||||
|
subject: claims.sub,
|
||||||
|
email: claims.email,
|
||||||
|
name: claims.name,
|
||||||
|
expires_at: claims.exp,
|
||||||
|
nonce: claims.nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discover_jwks_uri(issuer_url: &str, http: &reqwest::Client) -> Result<String> {
|
||||||
|
let url = format!(
|
||||||
|
"{}/.well-known/openid-configuration",
|
||||||
|
issuer_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct Discovery {
|
||||||
|
jwks_uri: String,
|
||||||
|
}
|
||||||
|
let disc: Discovery = http
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json()
|
||||||
|
.await?;
|
||||||
|
Ok(disc.jwks_uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_jwks(jwks_uri: &str, http: &reqwest::Client) -> Result<jsonwebtoken::jwk::JwkSet> {
|
||||||
|
Ok(http
|
||||||
|
.get(jwks_uri)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<jsonwebtoken::jwk::JwkSet>()
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
23
harmony_zitadel_auth/src/lib.rs
Normal file
23
harmony_zitadel_auth/src/lib.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
#[cfg(feature = "axum")]
|
||||||
|
pub mod axum_login_flow;
|
||||||
|
pub mod config;
|
||||||
|
pub mod jwks;
|
||||||
|
pub mod login;
|
||||||
|
pub mod session;
|
||||||
|
|
||||||
|
#[cfg(feature = "axum")]
|
||||||
|
pub use config::cookie_key_from_env;
|
||||||
|
pub use config::{
|
||||||
|
BASE_URL_ENV, CLIENT_ID_ENV, COOKIE_KEY_ENV, LOGOUT_REDIRECT_URI_ENV, SCOPE_ENV,
|
||||||
|
TRUSTED_AUDIENCES_ENV, ZITADEL_BASE_ENV, ZitadelAuthConfig, config_from_env,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use jwks::JwksCache;
|
||||||
|
|
||||||
|
pub use login::{
|
||||||
|
AuthCallbackQuery, LoginAttempt, RawAuthCallbackQuery, TokenResponse, ValidatedUser,
|
||||||
|
build_login_attempt, build_logout_url, exchange_code_for_token, jwt_exp,
|
||||||
|
validate_callback_state, validate_id_token,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub use session::{LoginAttemptCookie, VerifiedSession};
|
||||||
228
harmony_zitadel_auth/src/login.rs
Normal file
228
harmony_zitadel_auth/src/login.rs
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
|
use openidconnect::Nonce;
|
||||||
|
use openidconnect::core::{CoreClient, CoreIdToken, CoreProviderMetadata};
|
||||||
|
use openidconnect::{ClientId, IssuerUrl};
|
||||||
|
use rand::random;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::config::ZitadelAuthConfig;
|
||||||
|
use crate::session::LoginAttemptCookie;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ValidatedUser {
|
||||||
|
pub subject: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct LoginAttempt {
|
||||||
|
pub authorize_url: String,
|
||||||
|
pub state: String,
|
||||||
|
pub pkce_code_verifier: String,
|
||||||
|
pub nonce: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct TokenResponse {
|
||||||
|
pub access_token: String,
|
||||||
|
pub id_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub expires_in: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RawAuthCallbackQuery {
|
||||||
|
pub code: Option<String>,
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
pub error_description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum AuthCallbackQuery {
|
||||||
|
Success {
|
||||||
|
code: String,
|
||||||
|
state: String,
|
||||||
|
},
|
||||||
|
Failure {
|
||||||
|
error: String,
|
||||||
|
error_description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&LoginAttempt> for LoginAttemptCookie {
|
||||||
|
fn from(attempt: &LoginAttempt) -> Self {
|
||||||
|
Self {
|
||||||
|
state: attempt.state.clone(),
|
||||||
|
pkce_code_verifier: attempt.pkce_code_verifier.clone(),
|
||||||
|
nonce: attempt.nonce.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<RawAuthCallbackQuery> for AuthCallbackQuery {
|
||||||
|
type Error = anyhow::Error;
|
||||||
|
|
||||||
|
fn try_from(raw: RawAuthCallbackQuery) -> Result<Self, Self::Error> {
|
||||||
|
match raw {
|
||||||
|
RawAuthCallbackQuery {
|
||||||
|
code: Some(code),
|
||||||
|
state: Some(state),
|
||||||
|
error: None,
|
||||||
|
error_description: None,
|
||||||
|
} => Ok(Self::Success { code, state }),
|
||||||
|
RawAuthCallbackQuery {
|
||||||
|
code: None,
|
||||||
|
state: _,
|
||||||
|
error: Some(error),
|
||||||
|
error_description,
|
||||||
|
} => Ok(Self::Failure {
|
||||||
|
error,
|
||||||
|
error_description,
|
||||||
|
}),
|
||||||
|
_ => Err(anyhow::anyhow!("invalid auth callback query shape")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full OIDC-compliant id_token validation. Used once per login callback; not
|
||||||
|
/// the per-request hot path (use `JwksCache::verify` for that).
|
||||||
|
pub async fn validate_id_token(
|
||||||
|
id_token: &str,
|
||||||
|
http_client: &reqwest::Client,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
) -> Result<ValidatedUser> {
|
||||||
|
let provider_metadata =
|
||||||
|
CoreProviderMetadata::discover_async(IssuerUrl::new(config.issuer_url())?, http_client)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let client = CoreClient::from_provider_metadata(
|
||||||
|
provider_metadata,
|
||||||
|
ClientId::new(config.client_id.clone()),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
|
||||||
|
let id_token = CoreIdToken::from_str(id_token)?;
|
||||||
|
let trusted_audiences = config.trusted_audiences.clone();
|
||||||
|
let verifier = client
|
||||||
|
.id_token_verifier()
|
||||||
|
.set_other_audience_verifier_fn(move |aud| trusted_audiences.contains(&aud.to_string()));
|
||||||
|
let claims = id_token.claims(&verifier, |_: Option<&Nonce>| Ok(()))?;
|
||||||
|
|
||||||
|
Ok(ValidatedUser {
|
||||||
|
subject: claims.subject().to_string(),
|
||||||
|
email: claims.email().map(|e| e.to_string()),
|
||||||
|
name: claims
|
||||||
|
.name()
|
||||||
|
.and_then(|l| l.get(None))
|
||||||
|
.map(|n| n.to_string()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_logout_url(config: &ZitadelAuthConfig, id_token: &str) -> Result<Url> {
|
||||||
|
let mut url = Url::parse(&config.logout_url())?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("post_logout_redirect_uri", &config.logout_redirect_uri())
|
||||||
|
.append_pair("id_token_hint", id_token);
|
||||||
|
Ok(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_login_attempt(config: &ZitadelAuthConfig) -> Result<LoginAttempt> {
|
||||||
|
let state = random_url_token(32);
|
||||||
|
let pkce_code_verifier = random_url_token(32);
|
||||||
|
let nonce = random_url_token(32);
|
||||||
|
let code_challenge = pkce_s256_challenge(&pkce_code_verifier);
|
||||||
|
|
||||||
|
let mut url = Url::parse(&config.authorize_url())?;
|
||||||
|
url.query_pairs_mut()
|
||||||
|
.append_pair("client_id", &config.client_id)
|
||||||
|
.append_pair("redirect_uri", &config.redirect_uri())
|
||||||
|
.append_pair("response_type", "code")
|
||||||
|
.append_pair("scope", &config.scope)
|
||||||
|
.append_pair("code_challenge", &code_challenge)
|
||||||
|
.append_pair("code_challenge_method", "S256")
|
||||||
|
.append_pair("state", &state)
|
||||||
|
.append_pair("nonce", &nonce);
|
||||||
|
|
||||||
|
Ok(LoginAttempt {
|
||||||
|
authorize_url: url.into(),
|
||||||
|
state,
|
||||||
|
pkce_code_verifier,
|
||||||
|
nonce,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_code_for_token(
|
||||||
|
client: &reqwest::Client,
|
||||||
|
config: &ZitadelAuthConfig,
|
||||||
|
pkce_code_verifier: &str,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<TokenResponse> {
|
||||||
|
let response = client
|
||||||
|
.post(&config.token_url())
|
||||||
|
.form(&[
|
||||||
|
("grant_type", "authorization_code"),
|
||||||
|
("code", code),
|
||||||
|
("redirect_uri", &config.redirect_uri()),
|
||||||
|
("client_id", &config.client_id),
|
||||||
|
("code_verifier", pkce_code_verifier),
|
||||||
|
])
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let body = response.text().await.unwrap_or_default();
|
||||||
|
anyhow::bail!("failed to exchange code for token: {status} {body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response.json::<TokenResponse>().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_callback_state(attempt: &LoginAttemptCookie, returned_state: &str) -> Result<()> {
|
||||||
|
if attempt.state != returned_state {
|
||||||
|
anyhow::bail!("auth callback state mismatch; start again at /login");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decode the JWT payload (without verification) to extract `exp` for cookie `Max-Age`.
|
||||||
|
pub fn jwt_exp(token: &str) -> Option<i64> {
|
||||||
|
let payload = token.split('.').nth(1)?;
|
||||||
|
let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
|
||||||
|
let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
||||||
|
value.get("exp")?.as_i64()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pkce_s256_challenge(code_verifier: &str) -> String {
|
||||||
|
let digest = Sha256::digest(code_verifier.as_bytes());
|
||||||
|
URL_SAFE_NO_PAD.encode(digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_url_token(byte_len: usize) -> String {
|
||||||
|
let mut bytes = vec![0u8; byte_len];
|
||||||
|
for chunk in bytes.chunks_mut(32) {
|
||||||
|
let random_bytes: [u8; 32] = random();
|
||||||
|
chunk.copy_from_slice(&random_bytes[..chunk.len()]);
|
||||||
|
}
|
||||||
|
URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pkce_s256_challenge_test() {
|
||||||
|
let code_verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
|
||||||
|
let challenge = pkce_s256_challenge(code_verifier);
|
||||||
|
assert_eq!(challenge, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
|
||||||
|
}
|
||||||
|
}
|
||||||
21
harmony_zitadel_auth/src/session.rs
Normal file
21
harmony_zitadel_auth/src/session.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Claims extracted from a verified session cookie JWT on each request.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct VerifiedSession {
|
||||||
|
pub subject: String,
|
||||||
|
pub email: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub expires_at: i64,
|
||||||
|
/// OIDC nonce from the ID token, used to bind callback tokens to login attempts.
|
||||||
|
pub nonce: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PKCE state persisted in the encrypted login-attempt cookie during the
|
||||||
|
/// Zitadel redirect dance.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct LoginAttemptCookie {
|
||||||
|
pub state: String,
|
||||||
|
pub pkce_code_verifier: String,
|
||||||
|
pub nonce: String,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user