Natural-language monitoring dashboards. Describe what you want to see; a language model authors a validated visualization spec (SQL + chart config) — never the data itself — and Holotable executes the guarded SQL against TimescaleDB and streams the results live.
- Stack: Next.js 16 (App Router) · TypeScript · Tailwind v4 · Base UI ·
ECharts (line/bar/stat/table/heatmap/pie/donut) · TimescaleDB/PostgreSQL
(config + metrics) · Vercel AI SDK (
streamObjectfor specs,streamText+ tool calls for chat) · Keycloak OIDC (group-based auth) · Server-Sent Events. - Contract: one shared Zod IR (
src/lib/ir.ts) is used by the LLM output, the API, persistence, and the client, so the spec cannot drift.
See docs/ARCHITECTURE.md for the full invariant list and
the single-instance poller caveat, docs/PANEL_LIFECYCLE.md
for how a panel is generated, stored, executed, and rendered end to end, and
docs/KEYCLOAK.md for the OIDC group-mapper setup.
Describe the dashboard you want in plain English; the model authors a validated spec (never the data):
Holotable executes the guarded SQL and streams results into a live dashboard:
- Author —
/api/generateruns the LLM exactly once (only on create/edit). It streams a Dashboard IR spec that is validated with Zod. - Save — the whole immutable spec is stored as
jsonb; every save appends a newdashboard_versionsrow. - View — a dashboard opens one
EventSource. A single in-process poller per dashboard executes each panel's guarded SQL per tick and broadcasts deltas to all subscribers. The LLM never runs on view or on a tick. - Render — ECharts merges deltas into a bounded rolling window without recreating the chart.
All model SQL is untrusted: SELECT-only, catalog-table allowlist, no comments,
no time/non-deterministic functions, read-only settings, row/time limits, and a
server-injected time range bound to the panel's timeField. Panels carry
only a stable sourceId; the registry owns the safe connection config, the
catalog, and a secret_ref. Credentials are resolved from the environment at
execution time and never stored.
The viewer can pause live updates (the Live/Pause toggle closes the
EventSource; resuming reattaches to the shared poller) and includes a
read-only chat assistant scoped to that dashboard. Chat reasons over the
panel specs and may fetch fresh data through a guarded runQuery tool that runs
the same validate → plan → execute pipeline as everything else — it cannot
mutate the dashboard, supply a time filter, or reach any source the dashboard
doesn't already reference. Statement-level query failures (bad column, syntax,
timeout) surface as actionable messages with a one-click retry; connection and
infrastructure errors stay generic.
cp .env.example .env
# set a strong SESSION_SECRET and your AI_PROVIDER/AI_MODEL (+ keys)
docker compose up --build # timescaledb, keycloak, migrate, app, seedThe seed service continuously inserts demo metrics and (once) creates the demo
demo workspace sources + dashboards. Open http://localhost:3000. See
Seeding demo data for what it creates and how to tune it.
Requirements: Node 22+ and a TimescaleDB instance.
npm install
cp .env.example .env # edit DATABASE_URL, TIMESCALEDB_URL, secrets, AI_*
psql "$DATABASE_URL" -f timescaledb/init/001_schema.sql
npm run migrate # apply Postgres migrations
npm run seed # looping metrics seeder (+ demo source/dashboard)
npm run dev # http://localhost:3000npm run seed (scripts/seed.ts) is a long-running seeder that gives a fresh
install something to show. It does two things:
-
Once (bootstrap): registers two demo sources and a dashboard for each in the
demoworkspace, if they don't already exist. Both sources point at themetricsschema and share the read-onlyTS_METRICSsecret reference — they differ only in the tables they expose:Source id Table Demo dashboard ts-metricsmetrics.http_requests— per-request eventsDemo service health (RPS, p95 latency, 5xx, requests by route) ts-systemmetrics.system_metrics— per-host infra metricsDemo infrastructure (CPU/memory by host, disk %, CPU by region) -
Loop: every
SEED_INTERVAL_MSit inserts a fresh batch of synthetic rows into both tables so the live dashboards stream. It connects with the privileged metrics user and ensures the demo hypertables exist first, so it also works against a database whose volume predates a table.
# Docker: the `seed` service runs automatically with `docker compose up`.
# Local:
npm run seed # requires DATABASE_URL and TIMESCALEDB_URLBootstrap writes the source/dashboard rows to DATABASE_URL (config DB); the
metric inserts go to TIMESCALEDB_URL (falls back to DATABASE_URL). In the
default single-instance setup these are the same TimescaleDB database.
| Var | Default | Effect |
|---|---|---|
SEED_INTERVAL_MS |
2000 |
Delay between insert batches (Docker dev override: 1000). |
SEED_DEMO |
— | Set to false to skip the one-time source/dashboard bootstrap and only stream metrics. |
TS_METRICS_HOST / TS_METRICS_PORT |
localhost / 5432 |
Host/port written into the seeded source configs. |
POSTGRES_DB |
holotable |
Database name written into the seeded source configs. |
The seeder is for demos and local development. It uses a privileged connection to insert data and create tables; the app only ever reads through the read-only
TS_METRICSrole. Don't run the seeder against production data.
| Path | Purpose | Min role |
|---|---|---|
/dashboards |
List dashboards in a workspace | viewer |
/dashboards/new |
Prompt → preview → save, with one-click starter prompts | editor |
/dashboards/[id] |
Live viewer (SSE) with a Live/Pause toggle and a read-only dashboard chat assistant | viewer |
/dashboards/[id]/edit |
Panel CRUD/layout, single-panel NL edits, version save | editor |
/explore |
Ad-hoc NL questions against editable sources (with sample-question chips); streams one panel spec, then runs it through guarded query preview | editor |
/data-sources |
Source CRUD / test / refresh, plus a natural-language drafter that seeds the create form from a plain-English description | source-admin |
| Route | Method | Notes |
|---|---|---|
/api/generate |
POST | LLM streams a validated dashboard, panel, or explore-panel IR spec |
/api/query |
POST | One-shot guarded query (preview and Explore results) |
/api/dashboards |
GET/POST | List / create |
/api/dashboards/[id] |
GET/PUT/DELETE | Get / new version / delete |
/api/dashboards/[id]/stream |
GET | SSE deltas (cookie auth) |
/api/dashboards/[id]/chat |
POST | Read-only chat scoped to one dashboard; streams a UI message stream, may call a guarded runQuery tool |
/api/sources |
GET/POST | List / create |
/api/sources/generate |
POST | LLM streams a validated source draft (safe connection config + catalog, never credentials) for review before create |
/api/sources/[id] |
GET/PUT/DELETE | Get / update / delete (tombstone if referenced) |
/api/sources/[id]/test |
POST | Connectivity test |
/api/sources/[id]/refresh |
POST | Re-introspect catalog |
/api/auth/login · /callback · /logout |
OIDC session |
A source stores a secret_ref (an uppercase env-var family), never credentials.
resolveCredentials("TS_METRICS") reads TS_METRICS_USERNAME /
TS_METRICS_PASSWORD from the environment at execution time. Point a
secret_ref at your read-only TimescaleDB role; the app never connects with a
privileged user. See src/lib/registry.ts.
This is also why the natural-language source drafter (/api/sources/generate)
only ever emits the safe SourceDraft shape — connection config, table catalog,
and the secret_ref name — and is prompted to ignore any password in the
description. Credentials must already exist in the server environment for the
named secret_ref; a drafted or hand-created source whose secret_ref is
unconfigured saves fine but fails on Test until those env vars are set. The
draft is a starting point: run Test and Refresh to pull the live column
catalog before relying on it.
All defaults are environment-configurable (src/lib/config.ts). Notable
documented defaults:
- Refresh cadence:
DEFAULT_REFRESH_INTERVAL_MS=15000(15s), floored byMIN_REFRESH_INTERVAL_MS=2000. - Time range:
DEFAULT_TIME_FROM=now-1h..DEFAULT_TIME_TO=now. - Limits:
MAX_QUERY_ROWS,QUERY_TIMEOUT_SECONDS,MAX_WINDOW_POINTS. - AI:
AI_PROVIDER(gateway|openai-compatible) +AI_MODEL— no model is baked in; this is a deliberate open decision (see architecture doc). Theopenai-compatiblepath works with any OpenAI-compatible endpoint. It defaults to the Responses API; setOPENAI_API=chatfor providers that only expose Chat Completions (/chat/completions):- OpenRouter: set
OPENAI_BASE_URL=https://openrouter.ai/api/v1,OPENAI_API_KEYto your OpenRouter key, andAI_MODELto any OpenRouter model slug (e.g.openai/gpt-4o-mini). LeaveOPENAI_APIunset — forcingchatbreaks OpenRouter. - OpenCode Zen / Go: set
OPENAI_BASE_URL(e.g.https://opencode.ai/zen/go/v1) andOPENAI_API_KEYto the values provided by OpenCode,OPENAI_API=chat, andAI_MODELto a bare model id (e.g.kimi-k2.7-code) — OpenCode does not usevendor/modelslugs like OpenRouter. QueryGET <base-url>/modelsfor valid ids. Only models that expose an OpenAI-compatible/chat/completionsinterface are supported via this path.
- OpenRouter: set
See .env.example for the complete list.
npm run dev # dev server
npm run build # production build
npm run start # run the production build
npm run lint # eslint (flat config)
npm test # node --test (schema, auth, SQL safety, poller)
npm run migrate # apply Postgres migrations
npm run seed # looping metrics seedernode --test (native) via tsx, covering the highest-risk logic:
test/ir.test.ts— shared IR schema (strict mode, duplicate panels, time expr).test/claims.test.ts— group parsing (highest role wins, fail-closed).test/authorize.test.ts—can()for every action incl. admin bypass and owner delete.test/sql-safety.test.ts— SQL denylist/allowlist + server time injection + time resolution.test/poller.test.ts— delta cursors, poller identity/version replacement, subscriber ref-counting.test/layout.test.ts— panel grid layout packing/normalization.test/dashboard-chat.test.ts— chatrunQueryguard: source scoping, SQL validation, server-owned time injection.
- Dev login is hard-disabled in production and cannot bypass OIDC.
- Keycloak tokens are verified with RS256 via JWKS (
OIDC_JWKS_URL). - Authorization is centralized in
can()and never derived from a request's workspace field; the source is re-authorized on every execution.

