diff --git a/Cargo.lock b/Cargo.lock index caaa5581..42caa6fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "generic-array", ] @@ -76,6 +76,7 @@ dependencies = [ "agentflare-gateway-registry", "agentflare-skill-registry", "agentflare-store", + "axum", "base64", "built", "chrono", @@ -93,17 +94,21 @@ dependencies = [ "pretty_assertions", "rand 0.8.6", "regex", + "reqwest 0.12.28", "rmcp", "rpassword", "rusqlite", + "rust-embed", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sysinfo 0.34.2", "tar", "tempfile", "thiserror", "tokio", + "tokio-stream", + "tower-http", "ureq 2.12.1", "zip", ] @@ -140,7 +145,7 @@ dependencies = [ "rusqlite_migration", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror", "ureq 2.12.1", @@ -197,7 +202,7 @@ dependencies = [ "schemars", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror", "tokio", @@ -239,7 +244,7 @@ dependencies = [ "rusqlite_migration", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", "thiserror", "tracing", @@ -372,6 +377,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", "bytes", + "form_urlencoded", "futures-util", "http", "http-body", @@ -385,11 +391,15 @@ dependencies = [ "percent-encoding", "pin-project-lite", "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", "sync_wrapper", "tokio", "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -408,6 +418,7 @@ dependencies = [ "sync_wrapper", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -466,6 +477,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "built" version = "0.8.1" @@ -566,7 +586,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -682,6 +702,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -767,6 +793,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -843,11 +878,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1187,7 +1233,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -1250,6 +1296,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.10.1" @@ -1606,6 +1661,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1878,10 +1943,10 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", + "digest 0.10.7", "hmac", "password-hash", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2146,6 +2211,38 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -2212,7 +2309,7 @@ dependencies = [ "pin-project-lite", "process-wrap", "rand 0.10.2", - "reqwest", + "reqwest 0.13.4", "rmcp-macros", "schemars", "serde", @@ -2296,6 +2393,41 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -2375,6 +2507,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2493,6 +2634,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2502,6 +2654,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -2523,7 +2687,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -2813,6 +2988,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -2882,6 +3058,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -2899,6 +3076,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", "url", ] @@ -2920,6 +3098,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2979,6 +3158,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3009,7 +3194,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "subtle", ] @@ -3132,6 +3317,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -3290,6 +3485,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/Cargo.toml b/Cargo.toml index 61fffe2f..cb4a429d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,7 +43,7 @@ serde_json = "1" dirs = "6" chrono = "0.4" rmcp = { version = "1.8.0", features = ["server", "transport-io"] } -tokio = { version = "1", features = ["rt", "macros", "io-util", "io-std", "sync"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "time"] } ureq = { version = "2", features = ["json"] } sha2 = "0.10" hex = "0.4" @@ -72,6 +72,10 @@ agentflare-backend = { package = "agentflare-backend", path = "crates/agentflare db_kit = { package = "agentflare-db-kit", path = "crates/agentflare-db-kit" } agent-detector = "0.2.1" flare-search-kit = { path = "crates/flare-search-kit" } +axum = "0.8" +tower-http = { version = "0.6", features = ["trace"] } +rust-embed = "8" +tokio-stream = { version = "0.1", features = ["sync"] } agentflare-store = { path = "crates/agentflare-store" } [target.'cfg(unix)'.dependencies] @@ -93,6 +97,7 @@ built = { version = "0.8", features = ["chrono"] } insta = { version = "1", features = ["json"] } tempfile = "3" pretty_assertions = "1" +reqwest = { version = "0.12", default-features = false } [lints.rust] unsafe_code = "warn" diff --git a/crates/agentflare-backend/src/webhook.rs b/crates/agentflare-backend/src/webhook.rs index 9f1de27c..e470e843 100644 --- a/crates/agentflare-backend/src/webhook.rs +++ b/crates/agentflare-backend/src/webhook.rs @@ -113,6 +113,23 @@ fn row_to_webhook(row: &rusqlite::Row) -> rusqlite::Result { }) } +fn row_to_webhook_log(row: &rusqlite::Row) -> rusqlite::Result { + Ok(WebhookLog { + id: row.get(0)?, + workspace_id: row.get(1)?, + webhook_id: row.get(2)?, + event_type: row.get(3)?, + request_method: row.get(4)?, + request_headers: row.get(5)?, + request_body: row.get(6)?, + response_status: row.get(7)?, + response_headers: row.get(8)?, + response_body: row.get(9)?, + retry_count: row.get(10)?, + created_at: row.get(11)?, + }) +} + /// Blocks loopback, unspecified, multicast, and private/link-local ranges /// (RFC1918, 169.254.0.0/16, IPv6 link-local fe80::/10, IPv6 ULA fc00::/7) — /// the ranges an SSRF'd webhook could use to reach internal services. @@ -244,6 +261,17 @@ pub fn list_active_matching( Ok(rows.collect::>()?) } +/// Delivery log entries for a workspace, most recent first — the audit +/// trail the dashboard's `/api/webhooks` view reads. +pub fn list_logs_by_workspace(conn: &Connection, workspace_id: &str) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, workspace_id, webhook_id, event_type, request_method, request_headers, request_body, response_status, response_headers, response_body, retry_count, created_at + FROM webhook_logs WHERE workspace_id = ?1 ORDER BY created_at DESC", + )?; + let rows = stmt.query_map(params![workspace_id], row_to_webhook_log)?; + Ok(rows.collect::>()?) +} + pub fn update(conn: &Connection, id: &str, input: UpdateWebhook) -> Result { if let Some(ref url) = input.url { validate_webhook_url(url)?; @@ -606,6 +634,38 @@ mod tests { assert_eq!(log_count, 1, "a log row must be created even on failure"); } + #[test] + fn list_logs_by_workspace_returns_delivery_history() { + let conn = db::open_in_memory().unwrap(); + let wid = seed_workspace(&conn); + let wh = create( + &conn, + CreateWebhook { + workspace_id: wid.clone(), + url: "https://example.invalid/hook".into(), + secret_key: "s3cret".into(), + on_item: Some(true), + on_state: None, + on_project: None, + }, + ) + .unwrap(); + deliver( + &conn, + &wh, + "item", + "create", + serde_json::json!({"id": "123"}), + ) + .unwrap(); + let logs = list_logs_by_workspace(&conn, &wid).unwrap(); + assert_eq!(logs.len(), 1); + assert_eq!(logs[0].webhook_id, wh.id); + assert_eq!(logs[0].event_type.as_deref(), Some("item")); + let empty = list_logs_by_workspace(&conn, "nonexistent-workspace").unwrap(); + assert!(empty.is_empty()); + } + #[test] fn delete_soft() { let conn = db::open_in_memory().unwrap(); diff --git a/dashboard/web/board.html b/dashboard/web/board.html new file mode 100644 index 00000000..4d043248 --- /dev/null +++ b/dashboard/web/board.html @@ -0,0 +1,145 @@ + + + + + + + agentflare dashboard — board + + + + + +
+ + +
+
+

Board

+
+
+ + +
+ +
+
+
+
+ + + + diff --git a/dashboard/web/claims.html b/dashboard/web/claims.html new file mode 100644 index 00000000..a4a12826 --- /dev/null +++ b/dashboard/web/claims.html @@ -0,0 +1,137 @@ + + + + + + + agentflare dashboard — claims + + + + + +
+ + +
+
+

Claims

+ + + + +
+
+ +
+ + + + + + + + + + + + + + +
RepoTargetOwnerStatusClaimedLast heartbeat
+
+
+
+
+ + + + diff --git a/dashboard/web/cost.html b/dashboard/web/cost.html new file mode 100644 index 00000000..49ba1a62 --- /dev/null +++ b/dashboard/web/cost.html @@ -0,0 +1,215 @@ + + + + + + + agentflare dashboard — cost + + + + + +
+ + +
+
+

Cost

+ + + + +
+
+
+
+ + +
+
+ + +
+
+
Total
+
+
+
+ + + +
+ + + + + + + + + + + + + + +
InputOutputCache readCache writeCost
+
+ +

+ Usage from unrecognized models is excluded from the cost total. +

+
+
+
+ + + + diff --git a/dashboard/web/index.html b/dashboard/web/index.html new file mode 100644 index 00000000..9161655a --- /dev/null +++ b/dashboard/web/index.html @@ -0,0 +1,20 @@ + + + + + + agentflare dashboard + + + + +

+ Redirecting to the agentflare dashboard… +

+ + diff --git a/dashboard/web/item.html b/dashboard/web/item.html new file mode 100644 index 00000000..eeff1555 --- /dev/null +++ b/dashboard/web/item.html @@ -0,0 +1,164 @@ + + + + + + + agentflare dashboard — item + + + + + +
+ + +
+
+

Item

+
+
+ + + + +
+
+
+ + + + diff --git a/dashboard/web/list.html b/dashboard/web/list.html new file mode 100644 index 00000000..9ee29b3a --- /dev/null +++ b/dashboard/web/list.html @@ -0,0 +1,163 @@ + + + + + + + agentflare dashboard — list + + + + + +
+ + +
+
+

List

+
+
+ + +
+ + + + + + + + + + + + + +
NamePriorityStateAssigneeCreated
+
+
+
+
+ + + + diff --git a/dashboard/web/shell.html b/dashboard/web/shell.html new file mode 100644 index 00000000..d0e16ebc --- /dev/null +++ b/dashboard/web/shell.html @@ -0,0 +1,82 @@ + + + + + + + agentflare dashboard — shell + + + + + + +
+ + +
+
+

Shell

+
+
+

+ This is the layout skeleton only — pick Board, List, or Webhooks from the + sidebar to see live data. Design tokens live in tokens.css, + shared nav behavior lives in shell.js. +

+
+
+
+ + diff --git a/dashboard/web/shell.js b/dashboard/web/shell.js new file mode 100644 index 00000000..07e75bc8 --- /dev/null +++ b/dashboard/web/shell.js @@ -0,0 +1,121 @@ +/* + * Shared chrome logic for the agentflare dashboard (Alpine.js, no build + * step). Every view loads this before /vendor/alpine.js and reuses: + * - afGetJson / afParams / afLink — small fetch + query-string helpers + * - afResolveScope — default workspace/project pick + * - Alpine.data('shellNav', ...) — the sidebar's workspace/project + * selectors and nav links + * + * The sidebar markup itself is copy-pasted into each page (see shell.html + * for the reference copy) — there's no template include mechanism without + * a build step, so the *behavior* lives here and only the HTML structure + * is duplicated. + */ + +/** GET `url` as JSON; returns `fallback` on any network/parse/HTTP error. */ +async function afGetJson(url, fallback) { + try { + const res = await fetch(url); + if (!res.ok) return fallback; + return await res.json(); + } catch (_e) { + return fallback; + } +} + +function afParams() { + return new URLSearchParams(window.location.search); +} + +/** Build an href for `path`, merging current query params with `overrides`. */ +function afLink(path, overrides) { + const params = afParams(); + for (const [k, v] of Object.entries(overrides || {})) { + if (v === null || v === undefined || v === '') params.delete(k); + else params.set(k, v); + } + const qs = params.toString(); + return qs ? `${path}?${qs}` : path; +} + +/** + * Resolve a workspace/project scope for a page load: use explicit + * `?workspace_id=`/`?project_id=` if present, otherwise fall back to the + * first workspace and first project in it. Every view does this + * independently (rather than reading Alpine state from the sidebar + * component) since navigation here is a plain full-page GET, not an SPA. + */ +async function afResolveScope(explicitWorkspaceId, explicitProjectId) { + let workspaceId = explicitWorkspaceId || ''; + let projectId = explicitProjectId || ''; + if (!workspaceId) { + const workspaces = await afGetJson('/api/pm/workspaces', []); + if (workspaces.length) workspaceId = workspaces[0].id; + } + if (workspaceId && !projectId) { + const projects = await afGetJson( + `/api/pm/projects?workspace_id=${encodeURIComponent(workspaceId)}`, + [], + ); + if (projects.length) projectId = projects[0].id; + } + return { workspaceId, projectId }; +} + +function afFormatTime(unixSeconds) { + if (!unixSeconds && unixSeconds !== 0) return ''; + return new Date(unixSeconds * 1000).toLocaleString(); +} + +document.addEventListener('alpine:init', () => { + Alpine.data('shellNav', () => ({ + workspaces: [], + projects: [], + workspaceId: afParams().get('workspace_id') || '', + projectId: afParams().get('project_id') || '', + loading: true, + + async init() { + this.workspaces = await afGetJson('/api/pm/workspaces', []); + if (!this.workspaceId && this.workspaces.length) { + this.workspaceId = this.workspaces[0].id; + } + if (this.workspaceId) { + await this.loadProjects(); + } + this.loading = false; + }, + + async loadProjects() { + this.projects = await afGetJson( + `/api/pm/projects?workspace_id=${encodeURIComponent(this.workspaceId)}`, + [], + ); + if (!this.projectId && this.projects.length) { + this.projectId = this.projects[0].id; + } + }, + + navHref(path) { + return afLink(path, { workspace_id: this.workspaceId, project_id: this.projectId }); + }, + + isActive(path) { + return window.location.pathname === path; + }, + + onWorkspaceChange() { + window.location.href = afLink(window.location.pathname, { + workspace_id: this.workspaceId, + project_id: null, + }); + }, + + onProjectChange() { + window.location.href = afLink(window.location.pathname, { + workspace_id: this.workspaceId, + project_id: this.projectId, + }); + }, + })); +}); diff --git a/dashboard/web/tokens.css b/dashboard/web/tokens.css new file mode 100644 index 00000000..61377184 --- /dev/null +++ b/dashboard/web/tokens.css @@ -0,0 +1,654 @@ +/* + * Design tokens for the agentflare dashboard. + * Plane/Linear-grade: neutral surfaces (light + dark), a single indigo + * accent, semantic state colors, one sans stack, a 4/8px spacing scale, + * subtle borders over heavy shadows. Variable NAMES are stable — views + * bind to them — only the values here are tuned. + */ + +:root { + color-scheme: light dark; + + /* Neutral surfaces — cool, low-chroma */ + --af-color-bg: #fbfbfc; + --af-color-surface: #ffffff; + --af-color-surface-sunken: #f4f5f7; + --af-color-surface-hover: #f0f1f4; + --af-color-border: #eceef1; + --af-color-border-strong: #dfe1e6; + + /* Text */ + --af-color-text: #1a1b1f; + --af-color-text-muted: #61636b; + --af-color-text-faint: #9a9da5; + + /* Accent — a single indigo, plus soft tint + hover */ + --af-color-primary: #4b62f0; + --af-color-primary-hover: #3a51e0; + --af-color-primary-soft: #eef0fe; + --af-color-primary-contrast: #ffffff; + + /* Semantic — shared lightness/chroma, hue varies */ + --af-color-danger: #e0484d; + --af-color-danger-soft: #fceced; + --af-color-success: #2f9e6b; + --af-color-success-soft: #e8f6ee; + --af-color-warning: #d98324; + --af-color-warning-soft: #fbf0e2; + + /* Priority accents (quiet dots) */ + --af-prio-urgent: #e0484d; + --af-prio-high: #d98324; + --af-prio-medium: #4b62f0; + --af-prio-low: #7c8ca0; + --af-prio-none: #b6bac2; + + /* Spacing — 4 / 8 scale */ + --af-space-1: 4px; + --af-space-2: 8px; + --af-space-3: 12px; + --af-space-4: 16px; + --af-space-5: 24px; + --af-space-6: 32px; + + /* Radius */ + --af-radius-sm: 5px; + --af-radius-md: 8px; + --af-radius-lg: 12px; + + /* Elevation — whisper-soft */ + --af-shadow-sm: 0 1px 2px rgba(20, 22, 34, 0.05); + --af-shadow-md: 0 2px 8px rgba(20, 22, 34, 0.08), 0 1px 2px rgba(20, 22, 34, 0.05); + + /* Type */ + --af-font-sans: + "Inter var", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Helvetica, Arial, sans-serif; + --af-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --af-font-size-xs: 11px; + --af-font-size-sm: 12px; + --af-font-size-base: 13.5px; + --af-font-size-lg: 15px; + --af-font-size-xl: 21px; + + /* Layout */ + --af-sidebar-width: 244px; + --af-topbar-height: 48px; +} + +@media (prefers-color-scheme: dark) { + :root { + --af-color-bg: #0d0e11; + --af-color-surface: #17181c; + --af-color-surface-sunken: #131418; + --af-color-surface-hover: #1e2026; + --af-color-border: #26282e; + --af-color-border-strong: #33353d; + + --af-color-text: #ededf0; + --af-color-text-muted: #9b9ea6; + --af-color-text-faint: #6a6d75; + + --af-color-primary: #6c7ef7; + --af-color-primary-hover: #8493f9; + --af-color-primary-soft: #1c2140; + --af-color-primary-contrast: #ffffff; + + --af-color-danger: #f0575c; + --af-color-danger-soft: #2c1719; + --af-color-success: #43b581; + --af-color-success-soft: #14251d; + --af-color-warning: #e0912f; + --af-color-warning-soft: #271a10; + + --af-prio-urgent: #f0575c; + --af-prio-high: #e0912f; + --af-prio-medium: #6c7ef7; + --af-prio-low: #8b98a8; + --af-prio-none: #4c4f57; + + --af-shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); + --af-shadow-md: 0 2px 10px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + font-family: var(--af-font-sans); + font-size: var(--af-font-size-base); + line-height: 1.45; + color: var(--af-color-text); + background: var(--af-color-bg); + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; +} + +a { + color: var(--af-color-primary); + text-decoration: none; +} + +a:hover { + color: var(--af-color-primary-hover); +} + +/* ---------------------------------------------------------------- Shell */ +.af-app { + display: flex; + min-height: 100vh; +} + +.af-sidebar { + width: var(--af-sidebar-width); + flex: 0 0 var(--af-sidebar-width); + border-right: 1px solid var(--af-color-border); + background: var(--af-color-surface-sunken); + display: flex; + flex-direction: column; + gap: var(--af-space-2); + position: sticky; + top: 0; + height: 100vh; + overflow-y: auto; +} + +.af-sidebar-header { + display: flex; + align-items: center; + gap: var(--af-space-2); + padding: var(--af-space-4) var(--af-space-4) var(--af-space-3); + font-weight: 650; + font-size: var(--af-font-size-base); + letter-spacing: -0.01em; + color: var(--af-color-text); +} + +.af-logo-mark { + width: 22px; + height: 22px; + flex: 0 0 22px; + border-radius: 6px; + background: linear-gradient(140deg, var(--af-color-primary), var(--af-color-primary-hover)); + box-shadow: var(--af-shadow-sm); + position: relative; +} + +.af-logo-mark::after { + content: ""; + position: absolute; + inset: 6px; + border-radius: 2px; + background: var(--af-color-primary-contrast); + opacity: 0.92; + clip-path: polygon(0 0, 100% 0, 100% 45%, 45% 45%, 45% 100%, 0 100%); +} + +.af-nav-section { + padding: 0 var(--af-space-3); +} + +.af-nav-section + .af-nav-section { + margin-top: var(--af-space-1); +} + +.af-nav-section-label { + display: block; + font-size: var(--af-font-size-xs); + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--af-color-text-faint); + margin: 0 0 var(--af-space-1) var(--af-space-1); +} + +.af-select { + width: 100%; + padding: 7px var(--af-space-2); + border: 1px solid var(--af-color-border-strong); + border-radius: var(--af-radius-sm); + font-size: var(--af-font-size-sm); + font-family: inherit; + background: var(--af-color-surface); + color: var(--af-color-text); + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239a9da5' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 9px center; + padding-right: 26px; + cursor: pointer; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.af-select:hover { + border-color: var(--af-color-text-faint); +} + +.af-select:focus-visible { + outline: none; + border-color: var(--af-color-primary); + box-shadow: 0 0 0 3px var(--af-color-primary-soft); +} + +.af-nav-links { + padding: var(--af-space-2) var(--af-space-3) var(--af-space-4); + margin-top: var(--af-space-1); + display: flex; + flex-direction: column; + gap: 1px; +} + +.af-nav-link { + display: flex; + align-items: center; + gap: var(--af-space-2); + padding: 7px var(--af-space-2) 7px var(--af-space-3); + border-radius: var(--af-radius-sm); + color: var(--af-color-text-muted); + text-decoration: none; + font-size: var(--af-font-size-base); + font-weight: 500; + position: relative; + transition: background 0.12s ease, color 0.12s ease; +} + +.af-nav-link:hover { + background: var(--af-color-surface-hover); + color: var(--af-color-text); +} + +.af-nav-link.is-active { + background: var(--af-color-surface); + color: var(--af-color-text); + font-weight: 600; + box-shadow: var(--af-shadow-sm); +} + +.af-nav-link.is-active::before { + content: ""; + position: absolute; + left: -1px; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 15px; + border-radius: 2px; + background: var(--af-color-primary); +} + +.af-main-col { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.af-topbar { + height: var(--af-topbar-height); + flex: 0 0 var(--af-topbar-height); + border-bottom: 1px solid var(--af-color-border); + background: color-mix(in srgb, var(--af-color-bg) 75%, transparent); + backdrop-filter: saturate(1.4) blur(8px); + display: flex; + align-items: center; + gap: var(--af-space-3); + padding: 0 var(--af-space-5); + position: sticky; + top: 0; + z-index: 10; +} + +.af-topbar h1 { + font-size: var(--af-font-size-base); + font-weight: 600; + letter-spacing: -0.01em; + margin: 0; +} + +.af-topbar-meta { + margin-left: auto; + color: var(--af-color-text-faint); + font-size: var(--af-font-size-sm); +} + +.af-content { + flex: 1; + overflow: auto; + padding: var(--af-space-5); +} + +/* ------------------------------------------------------ Board (columns) */ +.af-board { + display: flex; + gap: var(--af-space-3); + align-items: flex-start; + overflow-x: auto; + padding-bottom: var(--af-space-4); +} + +.af-board-col { + width: 300px; + flex: 0 0 300px; + background: transparent; + border-radius: var(--af-radius-md); + display: flex; + flex-direction: column; + gap: var(--af-space-2); +} + +.af-board-col-header { + display: flex; + align-items: center; + gap: var(--af-space-2); + font-weight: 600; + font-size: var(--af-font-size-sm); + letter-spacing: 0.01em; + padding: var(--af-space-1) var(--af-space-2); + position: sticky; + top: 0; +} + +.af-board-col-count { + color: var(--af-color-text-faint); + font-weight: 500; + font-variant-numeric: tabular-nums; +} + +.af-board-col-body { + display: flex; + flex-direction: column; + gap: var(--af-space-2); +} + +.af-state-dot { + width: 9px; + height: 9px; + border-radius: 50%; + display: inline-block; + flex: 0 0 9px; + box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 12%, transparent); +} + +.af-card { + background: var(--af-color-surface); + border: 1px solid var(--af-color-border); + border-radius: var(--af-radius-md); + padding: var(--af-space-3); + font-size: var(--af-font-size-sm); + display: block; + text-decoration: none; + color: inherit; + box-shadow: var(--af-shadow-sm); + transition: border-color 0.12s ease, box-shadow 0.12s ease, transform 0.12s ease; +} + +.af-card:hover { + border-color: var(--af-color-border-strong); + box-shadow: var(--af-shadow-md); + color: inherit; +} + +.af-card-title { + font-size: var(--af-font-size-base); + font-weight: 500; + line-height: 1.4; + color: var(--af-color-text); + margin-bottom: var(--af-space-3); + text-wrap: pretty; +} + +.af-card-meta { + display: flex; + align-items: center; + gap: var(--af-space-2); + flex-wrap: wrap; +} + +/* ------------------------------------------------------------- Badges */ +.af-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: var(--af-radius-sm); + font-size: var(--af-font-size-xs); + font-weight: 600; + line-height: 1.5; + background: var(--af-color-surface-sunken); + color: var(--af-color-text-muted); + border: 1px solid var(--af-color-border); + white-space: nowrap; +} + +/* Priority badge — quiet outline + colored dot. The color class is added + alongside the existing x-text binding; the text value is untouched. */ +.af-prio { + text-transform: capitalize; + letter-spacing: 0.01em; +} + +.af-prio::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + flex: 0 0 6px; + background: var(--af-prio-none); +} + +.af-prio-urgent::before { background: var(--af-prio-urgent); } +.af-prio-high::before { background: var(--af-prio-high); } +.af-prio-medium::before { background: var(--af-prio-medium); } +.af-prio-low::before { background: var(--af-prio-low); } +.af-prio-none::before { background: var(--af-prio-none); } + +.af-prio-urgent { + color: var(--af-color-danger); + background: var(--af-color-danger-soft); + border-color: transparent; +} + +/* Assignee chip */ +.af-badge-assignee { + background: transparent; + border-color: var(--af-color-border-strong); + color: var(--af-color-text-muted); + font-weight: 500; +} + +.af-badge-assignee::before { + content: ""; + width: 14px; + height: 14px; + border-radius: 50%; + flex: 0 0 14px; + background: linear-gradient(140deg, var(--af-color-primary), var(--af-color-primary-hover)); + opacity: 0.85; +} + +/* ------------------------------------------------------------- Tables */ +.af-table-wrap { + overflow-x: auto; + border: 1px solid var(--af-color-border); + border-radius: var(--af-radius-md); + background: var(--af-color-surface); + box-shadow: var(--af-shadow-sm); +} + +.af-table { + width: 100%; + border-collapse: collapse; +} + +.af-table th { + text-align: left; + padding: var(--af-space-3) var(--af-space-4); + border-bottom: 1px solid var(--af-color-border); + background: var(--af-color-surface-sunken); + cursor: pointer; + user-select: none; + font-size: var(--af-font-size-xs); + font-weight: 600; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--af-color-text-muted); + white-space: nowrap; +} + +.af-table th:hover { + color: var(--af-color-text); +} + +.af-table td { + padding: var(--af-space-3) var(--af-space-4); + border-bottom: 1px solid var(--af-color-border); + font-size: var(--af-font-size-base); + vertical-align: middle; +} + +.af-table tr:last-child td { + border-bottom: none; +} + +.af-table tr.is-clickable { + cursor: pointer; +} + +.af-table tbody tr.is-clickable:hover { + background: var(--af-color-surface-sunken); +} + +/* --------------------------------------------------- Empty / loading */ +.af-empty { + color: var(--af-color-text-muted); + padding: var(--af-space-6); + text-align: center; + border: 1px dashed var(--af-color-border-strong); + border-radius: var(--af-radius-md); + background: var(--af-color-surface); + font-size: var(--af-font-size-sm); +} + +.af-empty-col { + color: var(--af-color-text-faint); + padding: var(--af-space-4); + text-align: center; + border: 1px dashed var(--af-color-border); + border-radius: var(--af-radius-md); + font-size: var(--af-font-size-xs); +} + +/* ---------------------------------------------------------- Item detail */ +.af-item-header { + margin-bottom: var(--af-space-4); +} + +.af-item-header h1 { + font-size: var(--af-font-size-xl); + font-weight: 650; + letter-spacing: -0.02em; + margin: 0 0 var(--af-space-2); +} + +.af-item-meta { + display: flex; + gap: var(--af-space-4); + flex-wrap: wrap; + align-items: center; + color: var(--af-color-text-muted); + font-size: var(--af-font-size-sm); + margin-bottom: var(--af-space-4); +} + +.af-item-description { + background: var(--af-color-surface); + border: 1px solid var(--af-color-border); + border-radius: var(--af-radius-md); + padding: var(--af-space-4); + margin-bottom: var(--af-space-5); + white-space: pre-wrap; + box-shadow: var(--af-shadow-sm); +} + +.af-section-title { + font-size: var(--af-font-size-xs); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--af-color-text-faint); + margin-bottom: var(--af-space-3); +} + +.af-comment { + border: 1px solid var(--af-color-border); + background: var(--af-color-surface); + border-radius: var(--af-radius-md); + padding: var(--af-space-3) var(--af-space-4); + margin-bottom: var(--af-space-2); + box-shadow: var(--af-shadow-sm); +} + +.af-comment-meta { + display: flex; + justify-content: space-between; + color: var(--af-color-text-faint); + font-size: var(--af-font-size-xs); + margin-bottom: var(--af-space-1); +} + +.af-mono { + font-family: var(--af-font-mono); + font-size: var(--af-font-size-xs); +} + +.af-truncate { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* -------------------------------------------------- Live / runtime views */ +.af-live { + display: inline-block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--af-color-text-faint); + margin-right: 5px; +} + +.af-live.is-on { + background: var(--af-color-success); + box-shadow: 0 0 0 3px var(--af-color-success-soft); +} + +.af-toolbar { + display: flex; + gap: var(--af-space-4); + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: var(--af-space-5); +} + +.af-select-inline { + width: auto; + min-width: 150px; +} + +.af-cost-total { + margin-left: auto; + text-align: right; +} + +.af-cost-total-value { + font-size: var(--af-font-size-xl); + font-weight: 650; + letter-spacing: -0.02em; + font-variant-numeric: tabular-nums; +} diff --git a/dashboard/web/vendor/alpine.js b/dashboard/web/vendor/alpine.js new file mode 100644 index 00000000..2ca48278 --- /dev/null +++ b/dashboard/web/vendor/alpine.js @@ -0,0 +1,5 @@ +(()=>{var rt=!1,nt=!1,U=[],it=-1;function qt(e){Cn(e)}function Cn(e){U.includes(e)||U.push(e),Tn()}function Ee(e){let t=U.indexOf(e);t!==-1&&t>it&&U.splice(t,1)}function Tn(){!nt&&!rt&&(rt=!0,queueMicrotask(Rn))}function Rn(){rt=!1,nt=!0;for(let e=0;ee.effect(t,{scheduler:r=>{ot?qt(r):r()}}),st=e.raw}function at(e){D=e}function Gt(e){let t=()=>{};return[n=>{let i=D(n);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),L(i))},i},()=>{t()}]}function ve(e,t){let r=!0,n,i=D(()=>{let o=e();JSON.stringify(o),r?n=o:queueMicrotask(()=>{t(o,n),n=o}),r=!1});return()=>L(i)}var Jt=[],Yt=[],Xt=[];function Zt(e){Xt.push(e)}function ee(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Yt.push(t))}function Ae(e){Jt.push(e)}function Oe(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function ct(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Qt(e){if(e._x_cleanups)for(;e._x_cleanups.length;)e._x_cleanups.pop()()}var lt=new MutationObserver(pt),ut=!1;function le(){lt.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),ut=!0}function ft(){Mn(),lt.disconnect(),ut=!1}var ce=[];function Mn(){let e=lt.takeRecords();ce.push(()=>e.length>0&&pt(e));let t=ce.length;queueMicrotask(()=>{if(ce.length===t)for(;ce.length>0;)ce.shift()()})}function _(e){if(!ut)return e();ft();let t=e();return le(),t}var dt=!1,Se=[];function er(){dt=!0}function tr(){dt=!1,pt(Se),Se=[]}function pt(e){if(dt){Se=Se.concat(e);return}let t=new Set,r=new Set,n=new Map,i=new Map;for(let o=0;os.nodeType===1&&t.add(s)),e[o].removedNodes.forEach(s=>s.nodeType===1&&r.add(s))),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},u=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(u(),l()):u()}i.forEach((o,s)=>{ct(s,o)}),n.forEach((o,s)=>{Jt.forEach(a=>a(s,o))});for(let o of r)t.has(o)||Yt.forEach(s=>s(o));t.forEach(o=>{o._x_ignoreSelf=!0,o._x_ignore=!0});for(let o of t)r.has(o)||o.isConnected&&(delete o._x_ignoreSelf,delete o._x_ignore,Xt.forEach(s=>s(o)),o._x_ignore=!0,o._x_ignoreSelf=!0);t.forEach(o=>{delete o._x_ignoreSelf,delete o._x_ignore}),t=null,r=null,n=null,i=null}function Ce(e){return F(j(e))}function P(e,t,r){return e._x_dataStack=[t,...j(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function j(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?j(e.host):e.parentNode?j(e.parentNode):[]}function F(e){return new Proxy({objects:e},Nn)}var Nn={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?Dn:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i=e.find(s=>Object.prototype.hasOwnProperty.call(s,t))||e[e.length-1],o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function Dn(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function Te(e){let t=n=>typeof n=="object"&&!Array.isArray(n)&&n!==null,r=(n,i="")=>{Object.entries(Object.getOwnPropertyDescriptors(n)).forEach(([o,{value:s,enumerable:a}])=>{if(a===!1||s===void 0||typeof s=="object"&&s!==null&&s.__v_skip)return;let c=i===""?o:`${i}.${o}`;typeof s=="object"&&s!==null&&s._x_interceptor?n[o]=s.initialize(e,c,o):t(s)&&s!==n&&!(s instanceof Element)&&r(s,c)})};return r(e)}function Re(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o){return e(this.initialValue,()=>Pn(n,i),s=>mt(n,i,s),i,o)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a)=>{let c=n.initialize(o,s,a);return r.initialValue=c,i(o,s,a)}}else r.initialValue=n;return r}}function Pn(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function mt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),mt(e[t[0]],t.slice(1),r)}}var rr={};function y(e,t){rr[e]=t}function ue(e,t){return Object.entries(rr).forEach(([r,n])=>{let i=null;function o(){if(i)return i;{let[s,a]=_t(t);return i={interceptor:Re,...s},ee(t,a),i}}Object.defineProperty(e,`$${r}`,{get(){return n(t,o())},enumerable:!1})}),e}function nr(e,t,r,...n){try{return r(...n)}catch(i){te(i,e,t)}}function te(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var Me=!0;function De(e){let t=Me;Me=!1;let r=e();return Me=t,r}function M(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return ir(...e)}var ir=gt;function or(e){ir=e}function gt(e,t){let r={};ue(r,e);let n=[r,...j(e)],i=typeof t=="function"?In(n,t):Ln(n,t,e);return nr.bind(null,e,t,i)}function In(e,t){return(r=()=>{},{scope:n={},params:i=[]}={})=>{let o=t.apply(F([n,...e]),i);Ne(r,o)}}var ht={};function kn(e,t){if(ht[e])return ht[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return te(s,t,e),Promise.resolve()}})();return ht[e]=o,o}function Ln(e,t,r){let n=kn(t,r);return(i=()=>{},{scope:o={},params:s=[]}={})=>{n.result=void 0,n.finished=!1;let a=F([o,...e]);if(typeof n=="function"){let c=n(n,a).catch(l=>te(l,r,t));n.finished?(Ne(i,n.result,a,s,r),n.result=void 0):c.then(l=>{Ne(i,l,a,s,r)}).catch(l=>te(l,r,t)).finally(()=>n.result=void 0)}}}function Ne(e,t,r,n,i){if(Me&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Ne(e,s,r,n)).catch(s=>te(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}var bt="x-";function C(e=""){return bt+e}function sr(e){bt=e}var Pe={};function d(e,t){return Pe[e]=t,{before(r){if(!Pe[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=W.indexOf(r);W.splice(n>=0?n:W.indexOf("DEFAULT"),0,e)}}}function ar(e){return Object.keys(Pe).includes(e)}function de(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=wt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(ur((o,s)=>n[o]=s)).filter(dr).map(jn(n,r)).sort(Fn).map(o=>$n(e,o))}function wt(e){return Array.from(e).map(ur()).filter(t=>!dr(t))}var xt=!1,fe=new Map,cr=Symbol();function lr(e){xt=!0;let t=Symbol();cr=t,fe.set(t,[]);let r=()=>{for(;fe.get(t).length;)fe.get(t).shift()();fe.delete(t)},n=()=>{xt=!1,r()};e(r),n()}function _t(e){let t=[],r=a=>t.push(a),[n,i]=Gt(e);return t.push(i),[{Alpine:B,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:M.bind(M,e)},()=>t.forEach(a=>a())]}function $n(e,t){let r=()=>{},n=Pe[t.type]||r,[i,o]=_t(e);Oe(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),xt?fe.get(cr).push(n):n())};return s.runCleanups=o,s}var Ie=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),ke=e=>e;function ur(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=fr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var fr=[];function re(e){fr.push(e)}function dr({name:e}){return pr().test(e)}var pr=()=>new RegExp(`^${bt}([^:^.]+)\\b`);function jn(e,t){return({name:r,value:n})=>{let i=r.match(pr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var yt="DEFAULT",W=["ignore","ref","data","id","anchor","bind","init","for","model","modelable","transition","show","if",yt,"teleport"];function Fn(e,t){let r=W.indexOf(e.type)===-1?yt:e.type,n=W.indexOf(t.type)===-1?yt:t.type;return W.indexOf(r)-W.indexOf(n)}function G(e,t,r={}){e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0}))}function T(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>T(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)T(n,t,!1),n=n.nextElementSibling}function E(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var mr=!1;function _r(){mr&&E("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),mr=!0,document.body||E("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + +
+ + +
+
+

Webhooks

+
+
+ + +
+ + + + + + + + + + + + + + +
DeliveredEventMethodStatusRetriesWebhook
+
+ + +
+
+
+ + + + diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d78d752a..beb11bce 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -17,6 +17,7 @@ mod memory; mod optimize; mod review; mod run; +mod serve; mod uninstall; mod update; @@ -65,6 +66,7 @@ pub enum Commands { Claim(claim::ClaimArgs), Review(review::ReviewArgs), Memory(memory::MemoryArgs), + Serve(serve::ServeArgs), } impl Commands { @@ -92,6 +94,7 @@ impl Commands { Self::Claim(cmd) => cmd.run(), Self::Review(cmd) => cmd.run(), Self::Memory(cmd) => cmd.run(), + Self::Serve(cmd) => cmd.run(), } } } diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 00000000..9fcfd44e --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,21 @@ +use clap::Args; + +/// Serve the read-only agentflare dashboard. +#[derive(Args)] +pub struct ServeArgs { + /// TCP port. Default 35273 ("FLARE" on a phone keypad); 0 = auto-assign. + #[arg(long, default_value = "35273")] + pub port: u16, + /// Interface to bind ("0.0.0.0" shares with your LAN). + #[arg(long, default_value = "127.0.0.1")] + pub host: String, + /// Open the dashboard in your browser once the server is up. + #[arg(long)] + pub open: bool, +} + +impl ServeArgs { + pub fn run(self) { + crate::dashboard::serve(&self.host, self.port, self.open); + } +} diff --git a/src/cost.rs b/src/cost.rs index 51d9c1f9..7d6cd5d0 100644 --- a/src/cost.rs +++ b/src/cost.rs @@ -11,7 +11,6 @@ use crate::pricing::{calculate_cost, load_pricing}; use std::collections::HashSet; use std::path::{Path, PathBuf}; -#[cfg(test)] use std::collections::HashMap; fn claude_projects_dir() -> PathBuf { @@ -240,6 +239,19 @@ pub(crate) fn aggregate( totals } +/// Open the analytics cache, sync it against the Claude Code session +/// transcripts, and return per-group (model or project) cost totals for the +/// given inclusive date range. Shared by `run` (CLI) and the dashboard's +/// `/api/cost` endpoint so both report identical, freshly-synced numbers. +pub(crate) fn summarize( + date_range: (NaiveDate, NaiveDate), + group_by: GroupBy, +) -> HashMap { + let mut conn = crate::rollup::open_or_rebuild(); + crate::rollup::sync(&mut conn, &claude_projects_dir()); + crate::rollup::query(&conn, date_range, group_by) +} + pub fn run(days: Option, by_project: bool) { let today = Local::now().date_naive(); let window = days.unwrap_or(1).max(1); @@ -250,9 +262,7 @@ pub fn run(days: Option, by_project: bool) { GroupBy::Model }; - let mut conn = crate::rollup::open_or_rebuild(); - crate::rollup::sync(&mut conn, &claude_projects_dir()); - let totals = crate::rollup::query(&conn, (start, today), group_by); + let totals = summarize((start, today), group_by); let range_label = if window == 1 { format!("today ({today})") diff --git a/src/dashboard/data.rs b/src/dashboard/data.rs new file mode 100644 index 00000000..89656592 --- /dev/null +++ b/src/dashboard/data.rs @@ -0,0 +1,602 @@ +use rusqlite::{Connection, OpenFlags}; +use std::path::Path; + +/// The ONLY way the dashboard opens a database — read-only, so a write can't slip in. +pub fn open_readonly(path: &Path) -> rusqlite::Result { + Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY) +} + +/// Open the PM backend database (`~/.agentflare/backend.db`) read-only. +pub fn pm_db_readonly() -> rusqlite::Result { + let path = crate::paths::home().join(".agentflare").join("backend.db"); + open_readonly(&path) +} + +/// Live claims as a JSON array string; reuses `crate::claims::list`. "[]" on error. +pub fn claims_json() -> String { + let path = crate::db::agentflare_db_path(); + let result = open_readonly(&path).and_then(|conn| { + crate::claims::list( + &conn, + None, + true, + crate::claims::now(), + crate::claims::ttl_secs(), + ) + }); + match result { + Ok(claims) => serde_json::to_string(&claims).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// All PM workspaces as a JSON array string; reuses +/// `agentflare_backend::workspace::list`. "[]" on error. +pub fn workspaces_json() -> String { + match pm_db_readonly() { + Ok(conn) => workspaces_json_from(&conn), + Err(_) => "[]".into(), + } +} + +fn workspaces_json_from(conn: &Connection) -> String { + match agentflare_backend::workspace::list(conn) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Projects in a workspace as a JSON array string; reuses +/// `agentflare_backend::project::list_by_workspace`. "[]" on error. +pub fn projects_json(workspace_id: &str) -> String { + match pm_db_readonly() { + Ok(conn) => projects_json_from(&conn, workspace_id), + Err(_) => "[]".into(), + } +} + +fn projects_json_from(conn: &Connection, workspace_id: &str) -> String { + match agentflare_backend::project::list_by_workspace(conn, workspace_id) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Items in a project as a JSON array string; reuses +/// `agentflare_backend::item::list_by_project`. "[]" on error. +pub fn items_json(project_id: &str) -> String { + match pm_db_readonly() { + Ok(conn) => items_json_from(&conn, project_id), + Err(_) => "[]".into(), + } +} + +fn items_json_from(conn: &Connection, project_id: &str) -> String { + match agentflare_backend::item::list_by_project(conn, project_id) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// States in a project as a JSON array string; reuses +/// `agentflare_backend::state::list_by_project`. "[]" on error. +pub fn states_json(project_id: &str) -> String { + match pm_db_readonly() { + Ok(conn) => states_json_from(&conn, project_id), + Err(_) => "[]".into(), + } +} + +fn states_json_from(conn: &Connection, project_id: &str) -> String { + match agentflare_backend::state::list_by_project(conn, project_id) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Comments on an item as a JSON array string; reuses +/// `agentflare_backend::comment::list_by_item`. "[]" on error. +pub fn comments_json(item_id: &str) -> String { + match pm_db_readonly() { + Ok(conn) => comments_json_from(&conn, item_id), + Err(_) => "[]".into(), + } +} + +fn comments_json_from(conn: &Connection, item_id: &str) -> String { + match agentflare_backend::comment::list_by_item(conn, item_id) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Labels as a JSON array string, scoped to a project (if `project_id` is +/// given) or else a workspace (if only `workspace_id` is given); reuses +/// `agentflare_backend::label::{list_by_project, list_by_workspace}`. "[]" if +/// neither scope is given, or on error. +pub fn labels_json(workspace_id: Option<&str>, project_id: Option<&str>) -> String { + match pm_db_readonly() { + Ok(conn) => labels_json_from(&conn, workspace_id, project_id), + Err(_) => "[]".into(), + } +} + +fn labels_json_from( + conn: &Connection, + workspace_id: Option<&str>, + project_id: Option<&str>, +) -> String { + let result = match (project_id, workspace_id) { + (Some(pid), _) => agentflare_backend::label::list_by_project(conn, pid), + (None, Some(wid)) => agentflare_backend::label::list_by_workspace(conn, wid), + (None, None) => return "[]".into(), + }; + match result { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Webhook delivery log entries for a workspace as a JSON array string — the +/// closest thing to a PM "events" audit trail on this backend; reuses +/// `agentflare_backend::webhook::list_logs_by_workspace`. "[]" on error. +pub fn webhooks_json(workspace_id: &str) -> String { + match pm_db_readonly() { + Ok(conn) => webhooks_json_from(&conn, workspace_id), + Err(_) => "[]".into(), + } +} + +fn webhooks_json_from(conn: &Connection, workspace_id: &str) -> String { + match agentflare_backend::webhook::list_logs_by_workspace(conn, workspace_id) { + Ok(rows) => serde_json::to_string(&rows).unwrap_or_else(|_| "[]".into()), + Err(_) => "[]".into(), + } +} + +/// Cost summary for the last `days` days (inclusive of today), grouped by +/// `by` — "project" groups by project, anything else by model — as a JSON +/// object `{ groups: [...], total_cost_usd, any_unpriced }`. Reuses +/// `crate::cost::summarize`, so it opens+syncs the same analytics cache the +/// `agentflare cost` CLI does and reports identical numbers. +pub fn cost_json(days: u32, by: &str) -> String { + let today = chrono::Local::now().date_naive(); + let window = days.max(1); + let start = today - chrono::Duration::days(window as i64 - 1); + let group_by = match by { + "project" => crate::cost::GroupBy::Project, + _ => crate::cost::GroupBy::Model, + }; + cost_totals_to_json(&crate::cost::summarize((start, today), group_by)) +} + +/// Combine an already-computed `claims` (JSON array string) and `cost_today` +/// (JSON object string) into the `/events` snapshot `{ claims, cost_today }`. +/// Re-parses each so they nest as JSON values rather than embedded strings; +/// malformed or empty input degrades to `[]` / `{}`, so this never fails. +pub fn snapshot_json(claims_json_str: &str, cost_today_json_str: &str) -> String { + let claims: serde_json::Value = + serde_json::from_str(claims_json_str).unwrap_or_else(|_| serde_json::json!([])); + let cost_today: serde_json::Value = + serde_json::from_str(cost_today_json_str).unwrap_or_else(|_| serde_json::json!({})); + serde_json::json!({ "claims": claims, "cost_today": cost_today }).to_string() +} + +/// Shape per-group cost totals into the `/api/cost` JSON. Split out from +/// `cost_json` so it can be unit-tested without opening the analytics cache +/// or touching the filesystem. Groups are sorted by key for stable output. +fn cost_totals_to_json( + totals: &std::collections::HashMap, +) -> String { + let mut rows: Vec<_> = totals.iter().collect(); + rows.sort_by(|a, b| a.0.cmp(b.0)); + + let mut total_cost_usd = 0.0_f64; + let mut any_unpriced = false; + let groups: Vec = rows + .iter() + .map(|(key, g)| { + total_cost_usd += g.cost_usd; + any_unpriced |= g.has_unpriced_usage; + serde_json::json!({ + "key": key, + "input_tokens": g.tokens.input_tokens, + "output_tokens": g.tokens.output_tokens, + "cache_read_tokens": g.tokens.cache_read_tokens, + "cache_creation_tokens": g.tokens.cache_creation_tokens, + "cost_usd": g.cost_usd, + "has_unpriced_usage": g.has_unpriced_usage, + }) + }) + .collect(); + + serde_json::json!({ + "groups": groups, + "total_cost_usd": total_cost_usd, + "any_unpriced": any_unpriced, + }) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + #[test] + fn cost_totals_to_json_shapes_groups_total_and_unpriced() { + use crate::cost::GroupTotals; + use crate::pricing::TokenUsage; + let mut totals = std::collections::HashMap::new(); + totals.insert( + "claude-opus-4-8".to_string(), + GroupTotals { + tokens: TokenUsage { + input_tokens: 100, + output_tokens: 50, + ..Default::default() + }, + cost_usd: 1.25, + has_unpriced_usage: false, + }, + ); + totals.insert( + "some-unpriced-model".to_string(), + GroupTotals { + tokens: TokenUsage { + input_tokens: 10, + ..Default::default() + }, + cost_usd: 0.0, + has_unpriced_usage: true, + }, + ); + + let json = cost_totals_to_json(&totals); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + + let groups = v["groups"].as_array().unwrap(); + assert_eq!(groups.len(), 2, "one row per group"); + // Sorted by key: opus first, unpriced second. + assert_eq!(groups[0]["key"], "claude-opus-4-8"); + assert_eq!(groups[0]["input_tokens"], 100); + assert_eq!(groups[0]["output_tokens"], 50); + assert_eq!(groups[0]["cost_usd"], 1.25); + assert_eq!(groups[1]["key"], "some-unpriced-model"); + + assert_eq!(v["total_cost_usd"], 1.25, "total is the sum of group costs"); + assert_eq!( + v["any_unpriced"], true, + "flagged when any group is unpriced" + ); + } + + #[test] + fn open_readonly_rejects_writes() { + let dir = std::env::temp_dir().join("agentflare-test-dash-ro"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("t.db"); + { + let w = Connection::open(&path).unwrap(); + w.execute_batch("CREATE TABLE t (x INTEGER);").unwrap(); + } + let ro = open_readonly(&path).unwrap(); + let err = ro.execute("INSERT INTO t (x) VALUES (1)", []).unwrap_err(); + assert!( + format!("{err}").contains("read"), + "must reject writes: {err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pm_db_readonly_rejects_writes() { + crate::paths::test_support::with_temp_home(|| { + let agentflare_dir = crate::paths::home().join(".agentflare"); + std::fs::create_dir_all(&agentflare_dir).unwrap(); + let path = agentflare_dir.join("backend.db"); + { + let w = Connection::open(&path).unwrap(); + w.execute_batch("CREATE TABLE t (x INTEGER);").unwrap(); + } + let ro = pm_db_readonly().unwrap(); + let err = ro.execute("INSERT INTO t (x) VALUES (1)", []).unwrap_err(); + assert!( + format!("{err}").contains("read"), + "must reject writes: {err}" + ); + }); + } + + #[test] + fn workspaces_json_from_serializes_backend_rows() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let json = workspaces_json_from(&conn); + assert!( + json.contains("\"slug\":\"acme\""), + "expected acme workspace in {json}" + ); + } + + #[test] + fn projects_json_from_scopes_to_workspace() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Rocket".into(), + identifier: "ROCK".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let json = projects_json_from(&conn, &ws.id); + assert!( + json.contains("\"identifier\":\"ROCK\""), + "expected ROCK project in {json}" + ); + let empty = projects_json_from(&conn, "nonexistent-workspace"); + assert_eq!(empty, "[]"); + } + + #[test] + fn states_json_from_scopes_to_project() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Rocket".into(), + identifier: "ROCK".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let json = states_json_from(&conn, &proj.id); + assert!( + json.contains("\"group_name\":\"backlog\""), + "expected default states in {json}" + ); + let empty = states_json_from(&conn, "nonexistent-project"); + assert_eq!(empty, "[]"); + } + + #[test] + fn items_json_from_scopes_to_project() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Rocket".into(), + identifier: "ROCK".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let states = agentflare_backend::state::list_by_project(&conn, &proj.id).unwrap(); + let state_id = states.iter().find(|s| s.is_default).unwrap().id.clone(); + agentflare_backend::item::create( + &conn, + agentflare_backend::item::CreateItem { + project_id: proj.id.clone(), + state_id, + name: "Fix bug".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + let json = items_json_from(&conn, &proj.id); + assert!( + json.contains("\"name\":\"Fix bug\""), + "expected item in {json}" + ); + let empty = items_json_from(&conn, "nonexistent-project"); + assert_eq!(empty, "[]"); + } + + #[test] + fn comments_json_from_scopes_to_item() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Rocket".into(), + identifier: "ROCK".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let states = agentflare_backend::state::list_by_project(&conn, &proj.id).unwrap(); + let state_id = states.iter().find(|s| s.is_default).unwrap().id.clone(); + let item = agentflare_backend::item::create( + &conn, + agentflare_backend::item::CreateItem { + project_id: proj.id.clone(), + state_id, + name: "Fix bug".into(), + description: None, + priority: None, + parent_id: None, + assignee_agent: None, + sort_order: None, + external_source: None, + external_id: None, + metadata: None, + label_ids: vec![], + assignee_ids: vec![], + dependency_ids: vec![], + }, + ) + .unwrap(); + agentflare_backend::comment::create(&conn, &item.id, "agent-1", "Looks good").unwrap(); + let json = comments_json_from(&conn, &item.id); + assert!( + json.contains("\"body\":\"Looks good\""), + "expected comment in {json}" + ); + let empty = comments_json_from(&conn, "nonexistent-item"); + assert_eq!(empty, "[]"); + } + + #[test] + fn labels_json_from_scopes_by_project_then_workspace() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let proj = agentflare_backend::project::create( + &conn, + agentflare_backend::project::CreateProject { + workspace_id: ws.id.clone(), + name: "Rocket".into(), + identifier: "ROCK".into(), + external_source: None, + external_id: None, + }, + ) + .unwrap(); + agentflare_backend::label::create( + &conn, + agentflare_backend::label::CreateLabel { + project_id: Some(proj.id.clone()), + workspace_id: ws.id.clone(), + name: "bug".into(), + color: None, + parent_id: None, + sort_order: None, + external_source: None, + external_id: None, + }, + ) + .unwrap(); + let by_project = labels_json_from(&conn, None, Some(&proj.id)); + assert!( + by_project.contains("\"name\":\"bug\""), + "expected label in {by_project}" + ); + let by_workspace = labels_json_from(&conn, Some(&ws.id), None); + assert!( + by_workspace.contains("\"name\":\"bug\""), + "expected label in {by_workspace}" + ); + let neither = labels_json_from(&conn, None, None); + assert_eq!(neither, "[]"); + } + + #[test] + fn webhooks_json_from_scopes_to_workspace() { + let conn = agentflare_backend::db::open_in_memory().unwrap(); + let ws = agentflare_backend::workspace::create( + &conn, + agentflare_backend::workspace::CreateWorkspace { + name: "Acme".into(), + slug: "acme".into(), + owner_agent: None, + item_label: None, + }, + ) + .unwrap(); + let wh = agentflare_backend::webhook::create( + &conn, + agentflare_backend::webhook::CreateWebhook { + workspace_id: ws.id.clone(), + url: "https://example.invalid/hook".into(), + secret_key: "s3cret".into(), + on_item: Some(true), + on_state: None, + on_project: None, + }, + ) + .unwrap(); + agentflare_backend::webhook::deliver( + &conn, + &wh, + "item", + "create", + serde_json::json!({"id": "1"}), + ) + .unwrap(); + let json = webhooks_json_from(&conn, &ws.id); + assert!( + json.contains("\"event_type\":\"item\""), + "expected event log in {json}" + ); + let empty = webhooks_json_from(&conn, "nonexistent-workspace"); + assert_eq!(empty, "[]"); + } +} diff --git a/src/dashboard/mod.rs b/src/dashboard/mod.rs new file mode 100644 index 00000000..71034098 --- /dev/null +++ b/src/dashboard/mod.rs @@ -0,0 +1,18 @@ +mod data; +mod server; + +pub fn serve(host: &str, port: u16, open: bool) { + let runtime = tokio::runtime::Runtime::new().expect("failed to build tokio runtime"); + runtime.block_on(server::run(host, port, open)); +} + +/// Best-effort: open a URL in the default browser. Never panics. +pub fn open_browser(url: &str) { + #[cfg(target_os = "linux")] + let cmd = "xdg-open"; + #[cfg(target_os = "macos")] + let cmd = "open"; + #[cfg(target_os = "windows")] + let cmd = "explorer"; + let _ = std::process::Command::new(cmd).arg(url).spawn(); +} diff --git a/src/dashboard/server.rs b/src/dashboard/server.rs new file mode 100644 index 00000000..2d9616c6 --- /dev/null +++ b/src/dashboard/server.rs @@ -0,0 +1,272 @@ +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::{ + Router, + extract::Query, + http::{StatusCode, Uri, header}, + response::{IntoResponse, Response}, + routing::get, +}; +use rust_embed::RustEmbed; +use serde::Deserialize; + +#[derive(RustEmbed)] +#[folder = "dashboard/web/"] +struct WebAssets; + +async fn claims_handler() -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::claims_json(), + ) + .into_response() +} + +#[derive(Deserialize)] +struct WorkspaceScope { + workspace_id: String, +} + +async fn pm_workspaces_handler() -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::workspaces_json(), + ) + .into_response() +} + +async fn pm_projects_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::projects_json(&q.workspace_id), + ) + .into_response() +} + +#[derive(Deserialize)] +struct ProjectScope { + project_id: String, +} + +async fn pm_items_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::items_json(&q.project_id), + ) + .into_response() +} + +async fn pm_states_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::states_json(&q.project_id), + ) + .into_response() +} + +#[derive(Deserialize)] +struct ItemScope { + item_id: String, +} + +async fn pm_comments_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::comments_json(&q.item_id), + ) + .into_response() +} + +#[derive(Deserialize)] +struct LabelScope { + workspace_id: Option, + project_id: Option, +} + +async fn pm_labels_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::labels_json(q.workspace_id.as_deref(), q.project_id.as_deref()), + ) + .into_response() +} + +async fn webhooks_handler(Query(q): Query) -> Response { + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::webhooks_json(&q.workspace_id), + ) + .into_response() +} + +#[derive(Deserialize)] +struct CostQuery { + days: Option, + by: Option, +} + +async fn cost_handler(Query(q): Query) -> Response { + let days = q.days.unwrap_or(1); + let by = q.by.as_deref().unwrap_or("model"); + ( + [(header::CONTENT_TYPE, "application/json")], + crate::dashboard::data::cost_json(days, by), + ) + .into_response() +} + +/// `/events` cadence. Claims are a cheap indexed SQLite read, so they refresh +/// every `TICK`. The cost summary is the expensive surface — `cost::summarize` +/// walks the whole `~/.claude/projects` tree and rewrites the analytics cache — +/// so it is recomputed only every `COST_REFRESH` and cached between. With the +/// "skip while nobody's watching" guard, an idle or single-tab dashboard costs +/// almost nothing, and N tabs still cost just one refresh cycle. +const TICK: std::time::Duration = std::time::Duration::from_secs(3); +const COST_REFRESH: std::time::Duration = std::time::Duration::from_secs(30); + +/// Single shared broadcast of the live `{ claims, cost_today }` snapshot. Every +/// `/events` client subscribes to this one channel, so there is no per-client +/// work — the producer below runs at most one refresh cycle per `TICK`, +/// regardless of how many browser tabs are connected. +fn snapshot_broadcaster() -> tokio::sync::broadcast::Sender { + use std::sync::OnceLock; + static TX: OnceLock> = OnceLock::new(); + TX.get_or_init(|| { + let (tx, _rx) = tokio::sync::broadcast::channel::(4); + let producer = tx.clone(); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(TICK); + // The expensive cost summary is refreshed on its own slower + // schedule and cached here between refreshes. + let mut cost_today = String::from("{}"); + let mut cost_age = COST_REFRESH; // force a refresh on the first tick + loop { + ticker.tick().await; + // Nobody's listening → do no work at all; no idle disk churn. + if producer.receiver_count() == 0 { + continue; + } + // Recompute the cost summary only once its cache has aged past + // COST_REFRESH; the walk+cache-write is kept off the async + // worker threads via spawn_blocking. + if cost_age >= COST_REFRESH { + cost_today = tokio::task::spawn_blocking(|| { + crate::dashboard::data::cost_json(1, "model") + }) + .await + .unwrap_or_else(|_| "{}".to_string()); + cost_age = std::time::Duration::ZERO; + } + cost_age += TICK; + // Claims are cheap (one indexed read) — refresh every tick, + // still off the worker threads. + let claims = tokio::task::spawn_blocking(crate::dashboard::data::claims_json) + .await + .unwrap_or_else(|_| "[]".to_string()); + // Err only means every receiver dropped mid-cycle; the next + // tick no-ops via receiver_count. + let _ = producer.send(crate::dashboard::data::snapshot_json(&claims, &cost_today)); + } + }); + tx + }) + .clone() +} + +/// Server-Sent Events stream of the volatile surfaces (claims + today's cost). +/// Subscribes to the shared broadcast, so N connected tabs cost one refresh +/// cycle rather than N. A newly connected client waits up to one `TICK` (~3s) +/// for its first frame; the view shows "connecting…" until then. +async fn events_handler() +-> Sse>> { + let rx = snapshot_broadcaster().subscribe(); + // Drop lagged/errored frames — the next snapshot supersedes them. + let stream = tokio_stream::StreamExt::filter_map( + tokio_stream::wrappers::BroadcastStream::new(rx), + |msg| match msg { + Ok(data) => Some(Ok(Event::default().data(data))), + Err(_) => None, + }, + ); + Sse::new(stream).keep_alive(KeepAlive::default()) +} + +async fn static_handler(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + match WebAssets::get(path) { + Some(c) => ( + [(header::CONTENT_TYPE, mime_for(path))], + c.data.into_owned(), + ) + .into_response(), + None => (StatusCode::NOT_FOUND, "not found").into_response(), + } +} + +fn mime_for(p: &str) -> &'static str { + if p.ends_with(".html") { + "text/html; charset=utf-8" + } else if p.ends_with(".js") { + "text/javascript; charset=utf-8" + } else if p.ends_with(".css") { + "text/css; charset=utf-8" + } else { + "application/octet-stream" + } +} + +pub fn router() -> Router { + Router::new() + .route("/api/claims", get(claims_handler)) + .route("/api/pm/workspaces", get(pm_workspaces_handler)) + .route("/api/pm/projects", get(pm_projects_handler)) + .route("/api/pm/items", get(pm_items_handler)) + .route("/api/pm/states", get(pm_states_handler)) + .route("/api/pm/comments", get(pm_comments_handler)) + .route("/api/pm/labels", get(pm_labels_handler)) + .route("/api/webhooks", get(webhooks_handler)) + .route("/api/cost", get(cost_handler)) + .route("/events", get(events_handler)) + .fallback(static_handler) +} + +pub async fn run(host: &str, port: u16, open: bool) { + let listener = tokio::net::TcpListener::bind((host, port)) + .await + .expect("failed to bind dashboard server"); + let addr = listener.local_addr().expect("no local addr"); + let url = format!("http://{addr}"); + eprintln!("agentflare dashboard listening on {url}"); + if host != "127.0.0.1" && host != "localhost" { + eprintln!(" warning: bound to {host} — anyone on your network can view this"); + } + if open { + crate::dashboard::open_browser(&url); + } + axum::serve(listener, router()) + .await + .expect("dashboard server error"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn claims_endpoint_returns_json_array() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, router()).await.unwrap(); + }); + let body = reqwest::get(format!("http://{addr}/api/claims")) + .await + .unwrap() + .text() + .await + .unwrap(); + assert!(body.starts_with('['), "expected JSON array, got: {body}"); + } +} diff --git a/src/main.rs b/src/main.rs index 2300ce8c..15b8b176 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,6 +18,7 @@ mod coaching; mod compact; mod components; mod cost; +mod dashboard; mod db; mod dev_install; mod dev_vars;