Full rebrand across cosmetic branding, code identifiers, and infrastructure/data-plane naming, using the supplied Cairn OBS logo package. Cosmetic: favicon/logo swap (also closes a stale license-audit finding -- the old favicon was SvelteKit's unreplaced scaffold logo), new centered welcome landing page, larger/legible sidebar logo, page titles, CLAUDE.md/README/docs prose. Code identifiers: Go module path github.com/sentry/sentry -> github.com/cairnobs/cairnobs across all 13 modules and ~91 files (protoc regenerated); Rust crates sentry-agent/sentry-parser/sentry-search -> cairnobs-*; CLI sentryctl -> cairnobsctl; Terraform provider fully renamed (sentry_dashboard etc. -> cairnobs_dashboard, provider type, env vars); every session/auth cookie name; agent config paths and Windows service identity. Deliberately preserved: the gRPC wire protocol's protobuf packages (sentry.logs.v1, sentry.agent.v1) and their Go import directory (proto/sentry/...) -- renaming the wire-level package would break every currently-deployed agent binary (confirmed two real hosts, including mail.inbuxa.com, are actively streaming through this exact contract) until rebuilt and redeployed in lockstep with an ingest cutover. Only the Go module path wrapping the generated code changes. Infrastructure: every docker-compose container name (root and three component-level compose files); the Helm chart (directory, Chart.yaml, named-template helpers, all templates, values.yaml image repos); Kubernetes Operator (CRD group sentry.io -> cairnobs.io, both CRD YAML files, Go identifiers, RBAC markers); the coupled enterprise/tenantcrd package. Caught and fixed real path-coupling bugs along the way: the Helm chart's search/ingest volume mounts and the dev-only-credential detection constant vs. docker-compose.yml's literal values had to move together or a security warning would have silently stopped firing. Data plane: Postgres database sentry_metadata -> cairnobs_metadata and role sentry -> cairnobs; ClickHouse database sentry -> cairnobs; Kafka topic sentry.logs.raw -> cairnobs.logs.raw and its consumer groups. Source-level defaults, docker-compose.yml, and every migrate.sh/ provision script default updated together; already-applied migration files left untouched per this repo's immutable-migration convention. Verified at every layer: all 13 Go modules build/vet/test clean, both Rust workspaces (agent, search) build/clippy/test clean, npm run check/ build clean, docker compose config validates on all four compose files. Live-verified against a real docker stack multiple times through this work, including a final fresh-volume run confirming the actual renamed Postgres database/role, ClickHouse database, and Kafka topic all work end to end with a real login and query, zero console errors.
289 lines
15 KiB
Markdown
289 lines
15 KiB
Markdown
# Dashboard design
|
||
|
||
> **Status:** Approved, in progress. Task 2 of Phase 3 — see `/CLAUDE.md`'s
|
||
> "What done looks like for Phase 3" section for the exit criteria this is
|
||
> built against. Task 3 (dashboard CRUD API + web UI) implements this
|
||
> doc; if implementation reveals this design is wrong somewhere, fix this
|
||
> doc in the same change, don't let them drift apart — same discipline as
|
||
> `/docs/query-language-design.md`.
|
||
|
||
## Why this design, in one paragraph
|
||
|
||
A dashboard is a named collection of panels, each wrapping one saved
|
||
Phase 2 query plus a visualization type and a grid position. That's a
|
||
straightforward data model; the one real design decision here is *where
|
||
it's stored*. Dashboards and their panels need real create/update/delete
|
||
semantics with immediate read-your-writes consistency for a UI —
|
||
ClickHouse's MergeTree family isn't built for that access pattern (see
|
||
"Why not ClickHouse" below). This doc also covers the mechanism that
|
||
makes one query language work for both ad hoc search and reusable
|
||
dashboard panels: injecting the dashboard's time range into a stored
|
||
query at execution time, rather than baking a fixed time range into the
|
||
saved query itself.
|
||
|
||
## Why not ClickHouse: PostgreSQL for control-plane config
|
||
|
||
This phase adds PostgreSQL as a new pinned-stack component — flagged and
|
||
confirmed with the project owner before implementation, per CLAUDE.md's
|
||
"ask before... making an architectural decision not already specified in
|
||
`/docs/architecture.md`." Scope is strictly control-plane config
|
||
(dashboards, panels, and — per `/docs/phase-3-alerting-design.md` —
|
||
notification targets, alert rules, alert state, delivery log). Log data
|
||
itself is untouched: ClickHouse and Tantivy remain the only stores for
|
||
`logs`.
|
||
|
||
Two concrete reasons ClickHouse doesn't fit this job, not just "it feels
|
||
risky":
|
||
|
||
1. **No row-level locking primitive.** The alerting evaluator (see the
|
||
alerting design doc) needs to atomically claim a due rule so a second
|
||
evaluation tick can't touch it concurrently — a `SELECT ... FOR UPDATE
|
||
SKIP LOCKED` operation. `ReplacingMergeTree` + `FINAL` gives
|
||
eventually-consistent "latest write wins," not concurrency control.
|
||
That's a missing primitive, not a tuning problem.
|
||
2. **Read-your-writes consistency for a UI.** A user editing a dashboard
|
||
expects to immediately see their own edit reflected back. ClickHouse's
|
||
MergeTree engines don't guarantee this the way a transactional
|
||
database does without extra machinery (`FINAL`, careful ordering) that
|
||
amounts to reimplementing what Postgres already provides natively.
|
||
|
||
Scope of the addition: new top-level `/metadata` component (naming
|
||
mirrors existing precedent — transport=Redpanda, storage=ClickHouse,
|
||
search=Tantivy → metadata=Postgres), `postgres:16-alpine` in
|
||
docker-compose, `jackc/pgx/v5` as the Go driver in `api` and the new
|
||
`alerting` service. No ORM, no `sqlc` — hand-written SQL per store
|
||
package, matching the project's existing avoidance of query-generation
|
||
machinery (no cobra, no golang-migrate, nothing code-generated from SQL
|
||
anywhere in the repo today).
|
||
|
||
### Migrations: mirror `/storage/migrate.sh`, not a framework
|
||
|
||
`storage/README.md`'s objection to `golang-migrate` ("premature machinery"
|
||
for what's being built) is general, not specific to ClickHouse's
|
||
HTTP-interface constraint — six new tables in one change doesn't meet the
|
||
bar that would justify revisiting that call. `/metadata/migrate.sh`
|
||
mirrors it: bash + `psql -v ON_ERROR_STOP=1 -f <file>` per migration, a
|
||
`schema_migrations` tracking table, one DDL object per file (kept for
|
||
repo-wide consistency of what a migration "version" means, even though
|
||
Postgres itself supports multi-statement transactions unlike ClickHouse's
|
||
HTTP interface), applied by a one-shot init container
|
||
(`metadata-migrate`) that other services `depends_on: condition:
|
||
service_completed_successfully` — same shape as `clickhouse-migrate`.
|
||
|
||
```
|
||
metadata/
|
||
README.md Dockerfile migrate.sh docker-compose.yml
|
||
migrations/
|
||
0001_create_dashboards.sql
|
||
0002_create_dashboard_panels.sql
|
||
0003_create_notification_targets.sql (alerting design doc)
|
||
0004_create_alert_rules.sql (alerting design doc)
|
||
0005_create_alert_state.sql (alerting design doc)
|
||
0006_create_delivery_log.sql (alerting design doc)
|
||
```
|
||
|
||
All six tables live in one migrations directory / one Postgres database,
|
||
even though `api` (dashboards) and `alerting` (rules/targets/state/log)
|
||
are the two services that own different tables within it — mirrors how
|
||
ClickHouse already hosts tables conceptually "owned" by different
|
||
components (`logs` via ingest's consumer, `schema_migrations` via the
|
||
migration runner itself) inside one physical database. No shared Go
|
||
store code between `api` and `alerting` — each owns hand-written SQL for
|
||
the tables it's responsible for; nothing is shared across service
|
||
`internal/` trees in this repo except `/proto`, and that precedent holds
|
||
here too.
|
||
|
||
## Schema
|
||
|
||
All tables carry `tenant_id TEXT NOT NULL DEFAULT 'default'` (populated,
|
||
unenforced — see "Not painting Phase 4 into a corner" below) and
|
||
`created_by TEXT NOT NULL DEFAULT 'anonymous'` for the same reason.
|
||
`updated_at` is app-managed on every UPDATE, no PL/pgSQL trigger —
|
||
consistent with the project's avoidance of database-side procedural code.
|
||
|
||
```sql
|
||
CREATE TABLE dashboards (
|
||
id UUID PRIMARY KEY,
|
||
tenant_id TEXT NOT NULL DEFAULT 'default',
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL DEFAULT '',
|
||
default_earliest TEXT NOT NULL DEFAULT '-1h',
|
||
default_latest TEXT NOT NULL DEFAULT 'now',
|
||
created_by TEXT NOT NULL DEFAULT 'anonymous',
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
|
||
CREATE TABLE dashboard_panels (
|
||
id UUID PRIMARY KEY,
|
||
dashboard_id UUID NOT NULL REFERENCES dashboards(id) ON DELETE CASCADE,
|
||
title TEXT NOT NULL DEFAULT '',
|
||
query TEXT NOT NULL, -- pipe syntax only, no earliest=/latest=
|
||
query_language TEXT NOT NULL DEFAULT '', -- mirrors queryRequest.Language: ''|sql|spl
|
||
viz_type TEXT NOT NULL CHECK (viz_type IN ('table','line','bar','single_stat','top_n')),
|
||
viz_config JSONB NOT NULL DEFAULT '{}', -- e.g. {"x_column":"timestamp","series_column":"host"}
|
||
position_x INT NOT NULL,
|
||
position_y INT NOT NULL,
|
||
width INT NOT NULL,
|
||
height INT NOT NULL,
|
||
earliest_override TEXT NULL, -- NULL = inherit dashboard default
|
||
latest_override TEXT NULL,
|
||
sort_order INT NOT NULL DEFAULT 0, -- deterministic export ordering
|
||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||
);
|
||
```
|
||
|
||
`query_language` deliberately excludes `'sql'` from being usable in
|
||
practice for panels (enforced at the API layer, not the DB CHECK
|
||
constraint — see "Raw-SQL panels out of scope" below): the column exists
|
||
because it mirrors the existing `/query` request shape exactly, not
|
||
because raw SQL panels are supported yet.
|
||
|
||
`position_x/y/width/height` map 1:1 to the web UI's grid layout library's
|
||
own `x/y/w/h` units — no translation layer between stored position and
|
||
rendered position.
|
||
|
||
IDs are app-generated UUIDs (`google/uuid`, already an `api` dependency),
|
||
assigned server-side once, matching how `ingest` assigns `record_id` —
|
||
not `gen_random_uuid()` via a Postgres extension, to keep ID generation
|
||
in one place (Go) rather than split between the app and the database.
|
||
|
||
## Time-range mechanics
|
||
|
||
A dashboard has a default time range (`default_earliest`/
|
||
`default_latest`, in the query language's own relative syntax, e.g.
|
||
`-1h`/`now`). Each panel can override it. Critically, **panel query
|
||
strings never contain `earliest=`/`latest=` themselves** — the query a
|
||
user writes for a panel is time-range-agnostic (e.g. `service=api |
|
||
where status>=500 | stats count by host`), and the effective time range
|
||
is resolved and injected at execution time:
|
||
|
||
```
|
||
effective_earliest = panel.earliest_override ?? dashboard.default_earliest
|
||
effective_latest = panel.latest_override ?? dashboard.default_latest
|
||
executed_query = "earliest={effective_earliest} latest={effective_latest} " + panel.query
|
||
```
|
||
|
||
This works because `earliest=`/`latest=` are ordinary `base_search` terms
|
||
in Phase 2's grammar (`term := ... | "earliest" "=" time_expr | "latest"
|
||
"=" time_expr`), implicitly AND'd with whatever else is in the query,
|
||
and — like every term in `bool_expr` — order-independent. Prepending them
|
||
is syntactically identical to a user having typed them first.
|
||
|
||
**`"now"` is a UI-only sentinel, never injected literally.** The default
|
||
`default_latest` value shown in the picker is the human-readable string
|
||
`"now"`, but `time_expr` only accepts a quoted absolute timestamp or a
|
||
`"-N unit"` relative offset — there is no `now` token in the grammar.
|
||
Injecting `latest=now` produces a real compile error (`expected a quoted
|
||
absolute timestamp or a relative offset`), found by actually running a
|
||
dashboard panel against the live stack, not a hypothetical. The fix:
|
||
when the effective latest value is `"now"` (or empty), the `latest=`
|
||
clause is omitted from the injected query entirely — which is exactly
|
||
what the query language already does to mean "no upper bound" (see
|
||
`planner.go`'s handling of an absent `latest` term). `earliest=` has no
|
||
equivalent sentinel; a dashboard's `default_earliest` is always a real
|
||
`time_expr` value.
|
||
|
||
**This injection only works for pipe-syntax queries.** A raw-SQL query
|
||
(Phase 2's `SELECT ...` escape hatch) has no equivalent injection point —
|
||
there's no reliable way to splice a time bound into arbitrary SQL without
|
||
parsing it, which is exactly the work the raw-SQL escape hatch was
|
||
designed to avoid (see `/docs/query-language-design.md`'s "not parsed,
|
||
wrapped as opaque IR"). **Raw-SQL dashboard panels are out of scope for
|
||
Phase 3** — a disclosed non-goal, not a silent gap. `dashboard_panels.
|
||
query_language` exists in the schema for symmetry with the `/query`
|
||
request shape, but the dashboard API layer (task 3) rejects `'sql'` on
|
||
create/update with a clear error rather than accepting it and having the
|
||
time-range picker silently do nothing.
|
||
|
||
## Panel execution: client-side, not server-side
|
||
|
||
`GET /dashboards/{id}` returns panel *definitions* only (query, viz_type,
|
||
position, overrides) — it does not execute any queries. The web UI
|
||
resolves each panel's effective time range and calls `POST /query`
|
||
per panel directly, exactly the same endpoint the root query page already
|
||
uses. This keeps `api/internal/dashboards` pure CRUD with zero
|
||
query-execution code of its own, consistent with the project's standing
|
||
rule that nothing duplicates `querylang`'s execution path (the same
|
||
reason `cairnobsctl query` calls `/query` over HTTP instead of importing
|
||
`querylang` internals). It also means panels load and error
|
||
independently in the UI — one broken panel query doesn't fail the whole
|
||
dashboard.
|
||
|
||
## Visualization types
|
||
|
||
- **`table`**: renders `{columns, rows}` directly via (an extended)
|
||
`ResultsTable.svelte`. No `viz_config` needed.
|
||
- **`line`** / **`bar`**: `viz_config` names which result column is the
|
||
x-axis/category (`x_column`, typically `timestamp` or a `stats ... by`
|
||
grouping column) and which is plotted (`series_column` for
|
||
multi-series, `value_column` for the plotted value). Rendered via
|
||
uPlot (see below).
|
||
- **`single_stat`**: takes the first row's first numeric column, no
|
||
`viz_config` needed for MVP (a labeled big number).
|
||
- **`top_n`**: a table sorted/limited view — for MVP this is really
|
||
"table, but the query already did `sort`/`head`," so it renders
|
||
through the same table path as `table` with different framing in the
|
||
UI; no separate execution logic.
|
||
|
||
### New frontend dependencies
|
||
|
||
`web/package.json` currently has zero runtime dependencies. Two are
|
||
added this phase, both confirmed with the project owner before landing:
|
||
|
||
- **gridstack.js** — panel grid layout with drag-and-drop/resize.
|
||
Vanilla JS, framework-agnostic (wrapped in a thin Svelte component),
|
||
well-established, used by real dashboard products. Pre-approved
|
||
("a lightweight grid library is fine, don't build drag-and-drop from
|
||
scratch") — listed here so the concrete choice is visible in the
|
||
design record, not just in a `package.json` diff.
|
||
- **uPlot** — line/bar chart rendering. ~45KB, canvas-based, fast, no
|
||
heavy transitive dependency tree. Chosen over Chart.js (heavier) and a
|
||
hand-rolled D3/SVG approach (correct axes/scales/tooltips is a lot of
|
||
code to own well for something a library already does correctly).
|
||
|
||
Table, single-stat, and top-N panels need neither dependency.
|
||
|
||
## Export / import
|
||
|
||
`GET /dashboards/{id}/export` returns the dashboard and its panels
|
||
marshaled as one JSON document (the same `Dashboard`/`Panel` Go structs
|
||
used internally, not a separate export-specific shape). `POST
|
||
/dashboards/import` accepts that same shape and creates a new dashboard
|
||
from it (new IDs assigned, not a literal replay of the source IDs, so
|
||
importing into a different environment doesn't collide). This is
|
||
deliberately the *same* JSON contract `cairnobsctl dashboards apply` (task
|
||
7) consumes — "exportable/importable, Terraform-friendly" isn't a
|
||
separate design, it's one JSON shape used from two call sites (web
|
||
export button, CLI apply).
|
||
|
||
## Not painting Phase 4 into a corner
|
||
|
||
- **`tenant_id`** on every table, populated but unenforced — Phase 4's
|
||
multi-tenancy retrofit needs a partitioning/enforcement layer, not a
|
||
schema migration + backfill on every table.
|
||
- **`created_by`** on every table, defaulted to `'anonymous'` since no
|
||
auth exists yet — same reasoning, cheaper to add the column now than
|
||
during Phase 4 when real identity is also landing.
|
||
- **"Shareable" currently means "shareable within the single trusted
|
||
deployment."** There is no access-control model at all yet — not
|
||
tenant isolation, not even within-tenant per-dashboard permissions.
|
||
Stating this plainly here so it doesn't read as more solved than it is
|
||
once multi-tenancy is on the table.
|
||
- CORS stays wide open (`Access-Control-Allow-Origin: *`) on `api`,
|
||
matching the existing Phase 0–2 posture. Adding dashboards doesn't
|
||
change this tradeoff, just widens the same already-accepted surface
|
||
area — noted so it's not mistaken for a new gap introduced this phase.
|
||
|
||
## Where this lives
|
||
|
||
`api/internal/dashboards/` (`handler.go`, `store.go`, `types.go`, plus
|
||
tests) — mirrors `api/internal/queryapi`'s existing `Handler`/`Store`
|
||
shape. Requires one small prerequisite refactor to existing code:
|
||
`queryapi.Handler.Routes()` currently builds its own `http.NewServeMux()`
|
||
and applies CORS in one shot; with a second handler package, both need to
|
||
register onto one shared mux in `main.go`, with CORS applied once at the
|
||
top. `queryapi.Handler` gains a `RegisterRoutes(mux *http.ServeMux)`
|
||
method in place of `Routes()`.
|