diff --git a/grafana/dashboards/ai-usage.json b/grafana/dashboards/ai-usage.json index 9059255d0..2f97495a7 100644 --- a/grafana/dashboards/ai-usage.json +++ b/grafana/dashboards/ai-usage.json @@ -15,7 +15,7 @@ "label": "Provider", "type": "query", "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, - "query": { "queryType": "table", "rawQueryText": "SELECT DISTINCT provider FROM ai_usage_events WHERE provider IS NOT NULL ORDER BY provider" }, + "query": { "queryType": "table", "queryText": "SELECT DISTINCT provider FROM ai_usage_events WHERE provider IS NOT NULL ORDER BY provider", "rawQueryText": "SELECT DISTINCT provider FROM ai_usage_events WHERE provider IS NOT NULL ORDER BY provider" }, "includeAll": true, "allValue": "$__all", "multi": false, @@ -28,7 +28,7 @@ "label": "Feature", "type": "query", "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, - "query": { "queryType": "table", "rawQueryText": "SELECT DISTINCT feature FROM ai_usage_events ORDER BY feature" }, + "query": { "queryType": "table", "queryText": "SELECT DISTINCT feature FROM ai_usage_events ORDER BY feature", "rawQueryText": "SELECT DISTINCT feature FROM ai_usage_events ORDER BY feature" }, "includeAll": true, "allValue": "$__all", "multi": false, @@ -43,6 +43,7 @@ "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, "query": { "queryType": "table", + "queryText": "SELECT DISTINCT model FROM ai_usage_events WHERE ('$provider' = '$__all' OR provider = '$provider') ORDER BY model", "rawQueryText": "SELECT DISTINCT model FROM ai_usage_events WHERE ('$provider' = '$__all' OR provider = '$provider') ORDER BY model" }, "includeAll": true, diff --git a/grafana/dashboards/github-prs.json b/grafana/dashboards/github-prs.json index 9e4c40ee0..5833ecf0c 100644 --- a/grafana/dashboards/github-prs.json +++ b/grafana/dashboards/github-prs.json @@ -18,6 +18,7 @@ "description": "Dynamic, never hardcoded: built from whichever repos are actually tracked in this self-hoster's own reporting DB. 'All repos' assumes a single-owner setup (the same assumption the dashboard's own GitHub-search-syntax scope always required, since GitHub search can't OR multiple repo: qualifiers in one query) — it widens to org: of the first tracked repo. A self-hoster tracking repos across multiple owners should pick a specific repo instead of 'All repos'.", "query": { "queryType": "table", + "queryText": "SELECT * FROM (SELECT 0 AS ord, 'All repos' AS \"__text\", (SELECT 'org:' || substr(repo, 1, instr(repo, '/') - 1) FROM review_targets LIMIT 1) AS \"__value\" UNION ALL SELECT 1 AS ord, repo AS \"__text\", 'repo:' || repo AS \"__value\" FROM (SELECT DISTINCT repo FROM review_targets ORDER BY repo)) ORDER BY ord, \"__text\"", "rawQueryText": "SELECT * FROM (SELECT 0 AS ord, 'All repos' AS \"__text\", (SELECT 'org:' || substr(repo, 1, instr(repo, '/') - 1) FROM review_targets LIMIT 1) AS \"__value\" UNION ALL SELECT 1 AS ord, repo AS \"__text\", 'repo:' || repo AS \"__value\" FROM (SELECT DISTINCT repo FROM review_targets ORDER BY repo)) ORDER BY ord, \"__text\"" }, "current": { "text": "All repos", "value": "org:JSONbored" }, diff --git a/grafana/dashboards/maintainer-reviews.json b/grafana/dashboards/maintainer-reviews.json index e698d83e7..a8892e0b0 100644 --- a/grafana/dashboards/maintainer-reviews.json +++ b/grafana/dashboards/maintainer-reviews.json @@ -16,7 +16,7 @@ "label": "Repo", "type": "query", "datasource": { "type": "frser-sqlite-datasource", "uid": "gittensory-db" }, - "query": { "queryType": "table", "rawQueryText": "SELECT DISTINCT repo FROM review_targets ORDER BY repo" }, + "query": { "queryType": "table", "queryText": "SELECT DISTINCT repo FROM review_targets ORDER BY repo", "rawQueryText": "SELECT DISTINCT repo FROM review_targets ORDER BY repo" }, "includeAll": true, "allValue": "$__all", "multi": false, diff --git a/test/unit/selfhost-grafana-variable-query-text.test.ts b/test/unit/selfhost-grafana-variable-query-text.test.ts new file mode 100644 index 000000000..fb03e0851 --- /dev/null +++ b/test/unit/selfhost-grafana-variable-query-text.test.ts @@ -0,0 +1,59 @@ +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +// Regression guard (2026-07 fix): every "query"-type template variable across every dashboard silently +// returned ZERO options in the live Grafana instance -- confirmed empirically against a running +// frser-sqlite-datasource via POST /api/ds/query -- because the variable's `query` object only carried +// `rawQueryText` (a display/round-trip field), never the `queryText` field the plugin actually needs to +// execute the query. Panel targets were never affected: they already set BOTH fields at the top level (see +// the `rawQueryText === queryText` check on panel targets elsewhere in this suite), which is exactly why +// every table/timeseries panel rendered real data while every $variable dropdown showed a red error icon and +// resolved to nothing -- cascading into "No data"/zeroed panels wherever a panel's WHERE clause referenced +// one of those variables. This test scans every dashboard file, not just the ones fixed today, so a future +// dashboard can never reintroduce this by copying the variable shape without the fix. +const dashboardsDir = join(process.cwd(), "grafana/dashboards"); + +type TemplateVar = { + name: string; + type: string; + datasource?: { type?: string }; + query?: { queryText?: string; rawQueryText?: string } | string; +}; + +function readDashboardFiles(): Array<{ file: string; vars: TemplateVar[] }> { + return readdirSync(dashboardsDir) + .filter((f) => f.endsWith(".json")) + .map((file) => { + const dashboard = JSON.parse(readFileSync(join(dashboardsDir, file), "utf8")) as { + templating?: { list?: TemplateVar[] }; + }; + return { file, vars: dashboard.templating?.list ?? [] }; + }); +} + +describe("Grafana dashboards: every query-type template variable actually executes (2026-07 fix)", () => { + it("every SQL-datasource query variable's `query` object carries queryText, matching rawQueryText", () => { + const dashboards = readDashboardFiles(); + expect(dashboards.length).toBeGreaterThan(3); // sanity: the scan found real dashboard files + + const violations: string[] = []; + for (const { file, vars } of dashboards) { + for (const v of vars) { + if (v.type !== "query") continue; + if (typeof v.query !== "object" || v.query === null) continue; + // Only the frser-sqlite-datasource plugin exhibits this specific missing-queryText bug (confirmed + // empirically); a Prometheus/other query variable's `query` field is a plain string, not this shape, + // and is unaffected -- skip anything that isn't this plugin. + if (v.datasource?.type !== "frser-sqlite-datasource") continue; + const { queryText, rawQueryText } = v.query; + if (!queryText) { + violations.push(`${file}: $${v.name} is missing "queryText" in its query object (rawQueryText alone silently returns zero rows)`); + } else if (queryText !== rawQueryText) { + violations.push(`${file}: $${v.name}'s queryText and rawQueryText have diverged ("${queryText}" vs "${rawQueryText}")`); + } + } + } + expect(violations).toEqual([]); + }); +});