diff --git a/grafana/dashboards/resource-hub.json b/grafana/dashboards/resource-hub.json index c66f82aaf1..01d4ecc5a4 100644 --- a/grafana/dashboards/resource-hub.json +++ b/grafana/dashboards/resource-hub.json @@ -66,7 +66,7 @@ "gridPos": { "h": 13, "w": 12, "x": 12, "y": 0 }, "options": { "mode": "markdown", - "content": "## ๐Ÿ“Š Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/loopover-github)** โ€” live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/loopover-maintainer)** โ€” loopover's own review activity + reviewed-PR log.\n- **[AI usage](/d/loopover-ai-usage)** โ€” durable cross-provider ai_usage_events (filterable by provider/feature/model), live Prometheus counters, and Claude Code's own OTEL session telemetry, all in one place.\n- **[LoopOver (infra)](/d/loopover-selfhost)** โ€” queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/loopover-gpu)** โ€” utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/loopover-infra-health)** โ€” host CPU/mem/disk/network (node-exporter), per-container resource usage (cAdvisor), Redis, Qdrant, and whether the observability stack itself is up.\n- **[REES (review-enrichment)](/d/loopover-rees)** โ€” request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/loopover-browserless)** โ€” queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n- **[Sentry issues](/d/loopover-sentry)** โ€” recent unresolved issues, top issues by event count, and error-volume trend, queried live from Sentry (`scripts/setup-sentry-datasource.sh`). The plain link below still opens Sentry itself for actions this read-only view can't do (resolving/assigning issues).\n\n## ๐Ÿ“ˆ Metrics & logs\n- **Prometheus** โ€” [targets](http://localhost:9090/targets) ยท [graph](http://localhost:9090)\n- **Alertmanager** โ€” [alerts](http://localhost:9093)\n- **Loki** โ€” query in [Explore](/explore) (pick the *Loki* datasource), e.g. `{compose_service=\"loopover\"}`\n- **Sentry** โ€” release/source-map enriched errors. Edit the dashboard link if your project URL differs.\n\n## ๐Ÿฉบ Quick health checks\n| What | Where |\n|---|---|\n| App serving | `GET /ready` โ†’ 200 |\n| AI wired | boot log `selfhost_ai_provider` |\n| Embeds wired | boot log `selfhost_embed_provider` |\n| Vectors wired | boot log `selfhost_vectorize` |\n| Token spend | **[AI usage](/d/loopover-ai-usage)** dashboard |\n\n## ๐Ÿ“š Docs\n- [Maintainer self-hosting](https://loopover.ai/docs/maintainer-self-hosting) โ€” setup, configuration, AI, REES, RAG, operations, and troubleshooting." + "content": "## ๐Ÿ“Š Dashboards\n- **[Upstream PRs & issues (GitHub)](/d/loopover-github)** โ€” live, accurate census + open-PR triage (GitHub API).\n- **[Reviews & PRs (maintainer)](/d/loopover-maintainer)** โ€” loopover's own review activity + reviewed-PR log.\n- **[AI usage](/d/loopover-ai-usage)** โ€” durable cross-provider ai_usage_events (filterable by provider/feature/model), live Prometheus counters, and Claude Code's own OTEL session telemetry, all in one place.\n- **[LoopOver (infra)](/d/loopover-selfhost)** โ€” queue, jobs, HTTP, GitHub API cache/rate limits.\n- **[GPU metrics](/d/loopover-gpu)** โ€” utilization/VRAM for a self-hosted Ollama GPU box.\n- **[Infra health](/d/loopover-infra-health)** โ€” host CPU/mem/disk/network (node-exporter), per-container resource usage (cAdvisor), Redis, Qdrant, and whether the observability stack itself is up.\n- **[REES (review-enrichment)](/d/loopover-rees)** โ€” request outcomes/latency and per-analyzer run/timeout/degrade rates for the optional `--profile rees` service.\n- **[Browserless (visual review)](/d/loopover-browserless)** โ€” queue depth, concurrency, and rejection/error/timeout rate for the optional `--profile visual-review` screenshot service.\n- **[Sentry issues](/d/loopover-sentry)** โ€” recent unresolved issues, top issues by event count, and error-volume trend, queried live from Sentry (`scripts/setup-sentry-datasource.sh`). The plain link below still opens Sentry itself for actions this read-only view can't do (resolving/assigning issues).\n- **[Miner usage (AMS)](/d/loopover-miner-usage)** โ€” attempt outcomes, cost/token spend, and per-provider usage from the local `loopover-miner` AMS ledger โ€” the miner-side counterpart to the ORB review-service dashboards above (see the AMS panel below for wiring its datasources up).\n\n## ๐Ÿ“ˆ Metrics & logs\n- **Prometheus** โ€” [targets](http://localhost:9090/targets) ยท [graph](http://localhost:9090)\n- **Alertmanager** โ€” [alerts](http://localhost:9093)\n- **Loki** โ€” query in [Explore](/explore) (pick the *Loki* datasource), e.g. `{compose_service=\"loopover\"}`\n- **Sentry** โ€” release/source-map enriched errors. Edit the dashboard link if your project URL differs.\n\n## ๐Ÿฉบ Quick health checks\n| What | Where |\n|---|---|\n| App serving | `GET /ready` โ†’ 200 |\n| AI wired | boot log `selfhost_ai_provider` |\n| Embeds wired | boot log `selfhost_embed_provider` |\n| Vectors wired | boot log `selfhost_vectorize` |\n| Token spend | **[AI usage](/d/loopover-ai-usage)** dashboard |\n\n## ๐Ÿ“š Docs\n- [Maintainer self-hosting](https://loopover.ai/docs/maintainer-self-hosting) โ€” setup, configuration, AI, REES, RAG, operations, and troubleshooting." } }, diff --git a/test/unit/selfhost-grafana-resource-hub-dashboard.test.ts b/test/unit/selfhost-grafana-resource-hub-dashboard.test.ts index 3d6f5a8e52..768dcb9b14 100644 --- a/test/unit/selfhost-grafana-resource-hub-dashboard.test.ts +++ b/test/unit/selfhost-grafana-resource-hub-dashboard.test.ts @@ -1,4 +1,4 @@ -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -57,3 +57,42 @@ describe("LoopOver โ€” Resource hub: AMS cross-link (#5189)", () => { expect(existsSync(join(process.cwd(), AMS_GUIDE_REPO_PATH))).toBe(true); }); }); + +// #5820: the hub is the curated landing page for every provisioned dashboard, but its list is hand-maintained, +// so miner-usage.json (uid loopover-miner-usage) shipped orphaned โ€” provisioned exactly like the other nine, yet +// reachable from the hub only by someone who already knew its uid. The AMS panel above links the *guide* for +// wiring AMS datasources up, never the dashboard itself once that wiring is done. Pinning that one uid would +// only fix today's miss, so this asserts the invariant over every provisioned dashboard: the NEXT one that +// forgets to cross-link fails CI instead of silently shipping orphaned. +const DASHBOARDS_DIR = join(process.cwd(), "grafana/dashboards"); +const HUB_UID = "loopover-hub"; + +function provisionedDashboards(): { file: string; uid: string }[] { + return readdirSync(DASHBOARDS_DIR) + .filter((file) => file.endsWith(".json")) + .map((file) => ({ file, uid: (JSON.parse(readFileSync(join(DASHBOARDS_DIR, file), "utf8")) as Dashboard).uid })); +} + +/** Every panel's markdown, joined โ€” the hub links dashboards from more than one panel. */ +function hubContent(): string { + return readDashboard() + .panels.map((panel) => panel.options?.content ?? "") + .join("\n"); +} + +describe("LoopOver โ€” Resource hub: every provisioned dashboard is linked (#5820)", () => { + it("deep-links the Miner usage (AMS) dashboard, not just its wiring guide", () => { + expect(hubContent()).toContain("/d/loopover-miner-usage"); + }); + + it("INVARIANT: every provisioned dashboard uid except the hub's own is deep-linked from the hub", () => { + const dashboards = provisionedDashboards(); + // Guard the guard: if the directory read ever matched nothing, the orphan check below would vacuously pass. + expect(dashboards.length).toBeGreaterThan(1); + expect(dashboards.every((d) => typeof d.uid === "string" && d.uid.length > 0)).toBe(true); + + const content = hubContent(); + const orphaned = dashboards.filter((d) => d.uid !== HUB_UID && !content.includes(`/d/${d.uid}`)); + expect(orphaned.map((d) => d.file)).toEqual([]); + }); +});