A first-party Grafana data source plugin that queries the Quix Lakehouse over the
REST API (POST /query, Arrow IPC). It has a Go backend, so queries run inside
grafana-server: dashboards, Explore, a working server-side "Save & test", and alert
rules on lakehouse data.
Build a query without writing SQL — tables, partition columns and their values all come
from the catalog, so the dropdowns fill in under a second where a SELECT DISTINCT on
the same column would not return at all:
split by turns a tag into one series per value, named after the tag:
- Plugin id:
quix-quixlakehouse-datasource - Backend binary:
gpx_quixlakehouse - Transport: HTTP
POST {url}/query?format=arrow(SQL as atext/plainbody) - Contributing: CONTRIBUTING.md · Changes: CHANGELOG.md
- Known gaps: ARCHITECTURE.md §8, and "Known limitations" below.
An earlier revision of this plugin used Arrow Flight SQL. It was retargeted to REST because
quix-ts-datalake-flightis a translation layer over this same API (an extra hop for no added capability), and because thesqldscode saving that justified Flight turned out not to exist. ARCHITECTURE.md §2 records the full reasoning. Flight remains the right path for BI tools (DBeaver, Tableau, ADBC).
Licensed under Apache-2.0 — see LICENSE. Dependencies
(grafana-plugin-sdk-go, arrow-go, @grafana/create-plugin) are Apache-2.0 too.
Grafana core is AGPLv3 and no code from it is used here.
Three routes, cheapest first. All of them need the plugin allowlisted as unsigned — it has no Grafana signature yet, so Grafana refuses to load it otherwise:
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS=quix-quixlakehouse-datasource
A stock Grafana with the plugin already baked in, published from this repo:
ghcr.io/quixio/quixlakehouse-grafana
The image sets GF_PATHS_PROVISIONING, the unsigned allowlist and an entrypoint that
writes the datasource provisioning file from two environment variables — so a working
Grafana is one docker run:
docker run -d -p 3000:3000 \
-e QUIXLAKE_URL='https://<your-lakehouse-query-host>' \
-e QUIXLAKE_TOKEN='<token or PAT>' \
-e GF_SECURITY_ADMIN_PASSWORD='<password>' \
ghcr.io/quixio/quixlakehouse-grafana:devOn Quix Cloud the two lakehouse values are injected by the platform instead — see deploy/README.md.
To add your own dashboards, plugins or config, FROM it and layer on top:
FROM ghcr.io/quixio/quixlakehouse-grafana:dev
# Dashboards as code. The entrypoint copies anything under the provisioning template
# directory through untouched, so they survive the datasource rendering step.
COPY dashboards/ /etc/grafana/provisioning-template/dashboards/
# Anything else stock Grafana supports still applies.
ENV GF_USERS_DEFAULT_THEME=lightDo not override ENTRYPOINT — it is what renders the datasource from
QUIXLAKE_URL / QUIXLAKE_TOKEN before starting Grafana. Overriding it gives you a
Grafana with the plugin installed but no datasource configured.
Pin by digest rather than :dev for anything you care about. :dev tracks the current
feature branch and moves under you, and an unchanged FROM line can be served from a
build cache — so a retag alone does not guarantee you get new code:
FROM ghcr.io/quixio/quixlakehouse-grafana@sha256:<digest>Read the digest from the publish-image workflow run, or with
docker manifest inspect ghcr.io/quixio/quixlakehouse-grafana:dev.
For a Grafana you already run. No release zip is published yet — until one is, build
dist/ yourself (see Build) and copy it in. The directory name must equal the
plugin id or Grafana will not discover it:
cp -r dist/ /var/lib/grafana/plugins/quix-quixlakehouse-datasource
chmod +x /var/lib/grafana/plugins/quix-quixlakehouse-datasource/gpx_*
# then restart grafana-serverOnce releases exist, this becomes the usual one-liner on a stock image — no rebuild:
environment:
GF_INSTALL_PLUGINS: "https://github.com/quixio/quixlakehouse-datasource/releases/download/v<x.y.z>/quix-quixlakehouse-datasource-<x.y.z>.zip;quix-quixlakehouse-datasource"
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINS: "quix-quixlakehouse-datasource"That route needs egress to GitHub at boot. Where that is not guaranteed — Quix environments included — use route 1, which is self-contained.
Not available. Catalog publication requires a Grafana Labs signature, which also makes this installable on Grafana Cloud (unsigned plugins cannot run there at all). Nothing else changes for self-hosted users, who can already use routes 1 and 2 today.
However you install it, the datasource needs an API URL and an API token — see Configuration. Provisioning them is strongly preferred over the UI: Grafana's database is not persisted in a container deployment, so a hand-created datasource disappears on restart while a provisioned one is recreated every boot.
| Tool | Version used | Notes |
|---|---|---|
| Go | 1.25+ | go.mod declares go 1.25.0 |
| Node | 22+ | .nvmrc says 22; 24 also works |
| mage | 1.17+ | go install github.com/magefile/mage@latest |
| Docker | any recent | for the dev Grafana |
@grafana/create-plugin (only needed to re-scaffold) does not run on Windows — use
WSL or a node:24-bookworm container.
# from the repo root
# Frontend -> dist/module.js, dist/plugin.json
npm ci
npm run build
# Backend -> dist/gpx_quixlakehouse_linux_amd64
mage -v build:linux
chmod +x dist/gpx_quixlakehouse_linux_amd64 # required; mage does not set it on Windowsmage -v build:all cross-compiles every platform. Without mage:
GOOS=linux GOARCH=amd64 go build -o dist/gpx_quixlakehouse_linux_amd64 ./pkgGrafana will not launch the plugin if the binary lacks the executable bit. The symptom is
Successfully started backend plugin processnever appearing in the Grafana log.
The dev stack is a stock Grafana with dist/ bind-mounted — the same way a user
installs a plugin. It does not run its own lakehouse; it joins the integration stack's
network so it can reach api:80 directly (the same service is localhost:8080 from the
host).
# 1. The lakehouse. This compose file lives in the SEPARATE, private
# Quix.DataLake.Timeseries repo -- not here. `flight-sql` is NOT needed.
docker compose -f docker-compose.integration-test.yml up -d api
# 2. From THIS repo: Grafana with the plugin.
docker compose -f docker-compose.dev.yml up -dOpen http://localhost:3002 (admin/admin). Port 3002, because the integration stack's own Grafana already owns 3001.
The datasource is pre-provisioned as QuixLakeHouse, uid quixlake-rest
(provisioning/datasources/quixlakehouse.yml) pointing at http://api:80, so it
exists on boot. Open it and click Save & test:
Two different uids exist, deliberately. This dev stack uses
quixlake-rest, fixed so integration tests can address it without a name lookup. The deployable image (deploy/provisioning/datasources/quixlakehouse.yml.tpl) usesquixlakehouse. A dashboard JSON hardcoding one will not resolve its datasource on the other — use a dashboard variable if a dashboard has to work on both.
Connected to the QuixLake API at http://api:80.
Teardown (only ever touches this file's container):
docker compose -f docker-compose.dev.yml downNew dashboard → new panel → datasource QuixLakeHouse:
SELECT timestamp, speed_kmh
FROM test_telemetry
WHERE $__timeFilter(timestamp)
AND year = 2026 AND circuit = 'monaco' AND session_type = 'practice'
ORDER BY timestampLeave Format = Time series and Time format = Epoch milliseconds.
Always include partition filters (
year,circuit,session_type). Unpruned full-table scans currently fail intermittently against the local stack — an API-side Arrow-path bug, not a plugin bug. ARCHITECTURE.md §7 item 1.
Expanded in the backend, so they work in alert rules too (an alert rule has no browser, so frontend-interpolated variables do not exist there).
| Macro | epoch_ms column |
native TIMESTAMP column |
|---|---|---|
$__timeFilter(col) |
col >= 1785835272409 AND col <= 1785838872409 |
col >= '2026-08-04T09:21:58Z' AND col <= '...' |
$__timeFrom() / $__timeTo() |
1785835272409 |
'2026-08-04T09:21:58Z' |
$__timeGroup(col, 1m) |
time_bucket(INTERVAL '1 minutes', epoch_ms(col)) |
time_bucket(INTERVAL '1 minutes', CAST(col AS TIMESTAMP)) |
$__timeGroup(col, $__interval) |
interval taken from the panel | same |
Interval units: ms, s, m, h, d, w, M, y.
$__from, $__to and $__interval on their own are Grafana global variables, not
plugin macros — the frontend replaces them with bare epoch milliseconds before the
backend sees them. Use $__timeFrom() / $__timeTo() instead so dashboards and alerts
agree. ($__interval is honoured inside $__timeGroup.)
QuixLake time-series tables store time as an INT64 epoch, not a native TIMESTAMP —
test_telemetry.timestamp is epoch milliseconds. The backend needs to know that both to
compare $__timeFilter against an integer instead of a string, and to turn the column
into a real Grafana time field. Getting it wrong is the difference between a rendered
chart and an empty panel (or timestamps in 1970).
Backend:
mage -v build:linux && chmod +x dist/gpx_quixlakehouse_linux_amd64
docker compose -f docker-compose.dev.yml restart grafanaGrafana caches the plugin process, so a restart is required. Backend logs:
docker logs -f quixlakehouse-plugin-dev-grafana 2>&1 | grep quixlakehouseGF_LOG_FILTERS already sets this plugin to debug, so every executed SQL statement is
logged.
Frontend:
npm run dev # webpack watch; rewrites dist/module.jsThen hard-reload the browser — no Grafana restart needed for frontend-only changes.
| Field | Storage | Default | Notes |
|---|---|---|---|
| API URL | Grafana's standard url field |
— | e.g. http://api:80. The API root — no /grafana suffix. Using the standard field gives the backend TLS + proxy options for free. |
| API token | secureJsonData.token |
— | sent as Authorization: Bearer <token>; accepts the static API token or a Quix platform token / PAT. Encrypted at rest; never returned to the browser. |
| Union by name | jsonData.unionByName |
true |
maps to ?union_by_name=true, so schema drift across files does not break SELECT * |
| Query timeout (s) | jsonData.timeoutSeconds |
60 |
per-query budget |
All are settable from provisioning YAML.
pkg/
main.go datasource.Manage(plugin.PluginID, ...)
models/settings.go jsonData + secureJsonData
plugin/
ids.go plugin id / binary name constants
datasource.go QueryData + CheckHealth, query model
rest.go POST /query, error detection, content-type dispatch
frames.go Arrow record batches -> data.Frame
csvframe.go CSV -> data.Frame (the API does not always honour ?format=arrow)
macros.go $__timeFilter / $__timeFrom / $__timeTo / $__timeGroup
timefmt.go epoch-vs-timestamp model
src/
plugin.json backend: true, alerting: true, category: sql
module.ts, datasource.ts, types.ts
components/ConfigEditor.tsx, QueryEditor.tsx
provisioning/datasources/quixlakehouse.yml dev stack, uid quixlake-rest
deploy/ deployable image: Dockerfile, entrypoint, provisioning template
.github/workflows/ ci.yml (lint/test/build) + publish-image.yml (GHCR)
docker-compose.dev.yml
.env PLUGIN_ID / GRAFANA_PORT / GRAFANA_VERSION
NOT tracked -- gitignored, it holds live tokens
UI is functional and unstyled by design — visual polish is a separate pass.
Full list in ARCHITECTURE.md §7. The ones you will hit first:
- Queries touching many files fail against a churned local stack — the sink writes ~6 tiny files every few seconds, so a wide scan must HEAD thousands of objects and MinIO starts refusing connections. Use partition filters; if even those fail, the stack needs compaction. Not a plugin bug.
- Raw SQL only — no visual builder, no dashboard variables, no ad-hoc filters, no annotations.
maxDataPointsis not pushed down — a panel returns whatever the SQL returns.- An empty result costs one extra query. Arrow has no error channel, so an empty Arrow body is ambiguous (failed vs genuinely empty) and the plugin re-asks over CSV once to disambiguate. Never on the happy path. ARCHITECTURE.md §5.
- Queries are attributed to
source="api", not to Grafana. - Unsigned — needs
GF_PLUGINS_ALLOW_LOADING_UNSIGNED_PLUGINSand Grafana shows a warning banner. linux/amd64only. CI (.github/workflows/ci.yml) runs lint, vet, typecheck and build, but there are no unit tests yet —go test ./...finds no test files andtest:ciruns jest with--passWithNoTests, so both pass vacuously.- The whole result is buffered in memory before the frame is built — no result-size ceiling yet. ARCHITECTURE.md §8.

