From 0466954348b13ca283cc4465f63cc2f8b930a004 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 01:40:38 +0000 Subject: [PATCH 1/6] Add Grafana with PostgreSQL datasource and THQ overview dashboard Introduces Grafana as a new Docker Compose service to visualize data already stored in PostgreSQL. Includes provisioned datasource config and a pre-built dashboard with panels for location logs, log events, accuracy, speed, battery level, and device activity. https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- docker-compose.yml | 18 + grafana/dashboards/thq-overview.json | 310 ++++++++++++++++++ grafana/provisioning/dashboards/default.yml | 12 + grafana/provisioning/datasources/postgres.yml | 15 + 4 files changed, 355 insertions(+) create mode 100644 grafana/dashboards/thq-overview.json create mode 100644 grafana/provisioning/dashboards/default.yml create mode 100644 grafana/provisioning/datasources/postgres.yml diff --git a/docker-compose.yml b/docker-compose.yml index ed740e9..da4a627 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,5 +32,23 @@ services: timeout: 5s retries: 5 + grafana: + image: grafana/grafana:11.6.0 + depends_on: + db: + condition: service_healthy + environment: + GF_SECURITY_ADMIN_USER: admin + GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:-admin} + GF_AUTH_ANONYMOUS_ENABLED: "false" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + ports: + - "3000:3000" + restart: unless-stopped + volumes: db-data: + grafana-data: diff --git a/grafana/dashboards/thq-overview.json b/grafana/dashboards/thq-overview.json new file mode 100644 index 0000000..e0da341 --- /dev/null +++ b/grafana/dashboards/thq-overview.json @@ -0,0 +1,310 @@ +{ + "annotations": { "list": [] }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 1, + "links": [], + "panels": [ + { + "id": 1, + "title": "Location Logs Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 20, + "pointSize": 5, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n COUNT(*) AS count\nFROM location_logs\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "id": 2, + "title": "Log Events Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 20, + "pointSize": 5, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n log_level,\n COUNT(*) AS count\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, log_level\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "id": 3, + "title": "Total Location Logs", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 8 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "blue", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT COUNT(*) AS \"Total\" FROM location_logs", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 4, + "title": "Total Log Events", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 4, "y": 8 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT COUNT(*) AS \"Total\" FROM log_events", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 5, + "title": "Active Devices (24h)", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 8, "y": 8 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "orange", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT COUNT(DISTINCT device) AS \"Devices\" FROM location_logs WHERE recorded_at >= NOW() - INTERVAL '24 hours'", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 6, + "title": "Average Accuracy by Device", + "type": "bargauge", + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "unit": "m", + "thresholds": { + "steps": [ + { "color": "green", "value": null }, + { "color": "yellow", "value": 30 }, + { "color": "red", "value": 100 } + ] + } + }, + "overrides": [] + }, + "options": { + "orientation": "horizontal", + "displayMode": "gradient" + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n device,\n ROUND(AVG(accuracy)::numeric, 1) AS \"Avg Accuracy (m)\"\nFROM location_logs\nWHERE accuracy IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY device\nORDER BY \"Avg Accuracy (m)\" DESC\nLIMIT 20", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 7, + "title": "Accuracy Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "unit": "m", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 1, + "fillOpacity": 10, + "pointSize": 3, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND(AVG(accuracy)::numeric, 1) AS accuracy\nFROM location_logs\nWHERE accuracy IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "id": 8, + "title": "Battery Level Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 15, + "pointSize": 3, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND((AVG(battery_level) * 100)::numeric, 1) AS battery\nFROM location_logs\nWHERE battery_level IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "id": 9, + "title": "Speed Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, + "datasource": { "type": "postgres", "uid": "" }, + "fieldConfig": { + "defaults": { + "unit": "velocityms", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 1, + "fillOpacity": 10, + "pointSize": 3, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND(AVG(speed)::numeric, 2) AS speed\nFROM location_logs\nWHERE speed IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", + "format": "time_series", + "refId": "A" + } + ] + }, + { + "id": 10, + "title": "Log Events by Level", + "type": "piechart", + "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 }, + "datasource": { "type": "postgres", "uid": "" }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n log_level,\n COUNT(*) AS count\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY log_level\nORDER BY count DESC", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 11, + "title": "Recent Location Logs", + "type": "table", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 }, + "datasource": { "type": "postgres", "uid": "" }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n recorded_at AS time,\n device,\n state,\n station_id,\n line_id,\n segment_id,\n ROUND(latitude::numeric, 5) AS lat,\n ROUND(longitude::numeric, 5) AS lon,\n ROUND(accuracy::numeric, 1) AS accuracy,\n ROUND(speed::numeric, 1) AS speed,\n ROUND((battery_level * 100)::numeric, 0) AS \"battery%\"\nFROM location_logs\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nORDER BY recorded_at DESC\nLIMIT 100", + "format": "table", + "refId": "A" + } + ] + }, + { + "id": 12, + "title": "Recent Log Events", + "type": "table", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 36 }, + "datasource": { "type": "postgres", "uid": "" }, + "targets": [ + { + "datasource": { "type": "postgres" }, + "rawSql": "SELECT\n recorded_at AS time,\n device,\n log_type,\n log_level,\n message\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nORDER BY recorded_at DESC\nLIMIT 100", + "format": "table", + "refId": "A" + } + ] + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": ["thq", "trainlcd"], + "templating": { "list": [] }, + "time": { "from": "now-24h", "to": "now" }, + "timepicker": {}, + "timezone": "Asia/Tokyo", + "title": "THQ Overview", + "uid": "thq-overview" +} diff --git a/grafana/provisioning/dashboards/default.yml b/grafana/provisioning/dashboards/default.yml new file mode 100644 index 0000000..aa5f7c1 --- /dev/null +++ b/grafana/provisioning/dashboards/default.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: default + orgId: 1 + folder: "" + type: file + disableDeletion: false + editable: true + options: + path: /var/lib/grafana/dashboards + foldersFromFilesStructure: false diff --git a/grafana/provisioning/datasources/postgres.yml b/grafana/provisioning/datasources/postgres.yml new file mode 100644 index 0000000..7e8758f --- /dev/null +++ b/grafana/provisioning/datasources/postgres.yml @@ -0,0 +1,15 @@ +apiVersion: 1 + +datasources: + - name: THQ PostgreSQL + type: postgres + url: db:5432 + database: thq + user: thq + jsonData: + sslmode: disable + postgresVersion: 1800 + secureJsonData: + password: thq + isDefault: true + editable: true From a64a5856ad032f05ecf37a861703195d5ce3de5d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:18:31 +0000 Subject: [PATCH 2/6] Harden Grafana config: require admin password, fix datasource UID, use env vars for PG creds - GF_ADMIN_PASSWORD is now required (no weak default) - Datasource provisioning uses $__env{} for PG user/password - All dashboard panels reference explicit UID "thq-postgres" - .env.example updated with GF_ADMIN_PASSWORD entry https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- .env.example | 3 +- docker-compose.yml | 4 +- grafana/dashboards/thq-overview.json | 48 +++++++++---------- grafana/provisioning/datasources/postgres.yml | 5 +- 4 files changed, 32 insertions(+), 28 deletions(-) diff --git a/.env.example b/.env.example index 945aeaf..4f0c304 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ THQ_WS_AUTH_TOKEN=change-me -THQ_WS_AUTH_REQUIRED=true \ No newline at end of file +THQ_WS_AUTH_REQUIRED=true +GF_ADMIN_PASSWORD=change-me # Grafana admin password – use a strong value in production \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index da4a627..509cb77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,8 +39,10 @@ services: condition: service_healthy environment: GF_SECURITY_ADMIN_USER: admin - GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:-admin} + GF_SECURITY_ADMIN_PASSWORD: ${GF_ADMIN_PASSWORD:?set in .env or shell} GF_AUTH_ANONYMOUS_ENABLED: "false" + GRAFANA_PG_USER: thq + GRAFANA_PG_PASSWORD: thq volumes: - grafana-data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning diff --git a/grafana/dashboards/thq-overview.json b/grafana/dashboards/thq-overview.json index e0da341..005d961 100644 --- a/grafana/dashboards/thq-overview.json +++ b/grafana/dashboards/thq-overview.json @@ -10,7 +10,7 @@ "title": "Location Logs Over Time", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, @@ -25,7 +25,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n COUNT(*) AS count\nFROM location_logs\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", "format": "time_series", "refId": "A" @@ -37,7 +37,7 @@ "title": "Log Events Over Time", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "color": { "mode": "palette-classic" }, @@ -52,7 +52,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n log_level,\n COUNT(*) AS count\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, log_level\nORDER BY time", "format": "time_series", "refId": "A" @@ -64,7 +64,7 @@ "title": "Total Location Logs", "type": "stat", "gridPos": { "h": 4, "w": 4, "x": 0, "y": 8 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "thresholds": { @@ -77,7 +77,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT COUNT(*) AS \"Total\" FROM location_logs", "format": "table", "refId": "A" @@ -89,7 +89,7 @@ "title": "Total Log Events", "type": "stat", "gridPos": { "h": 4, "w": 4, "x": 4, "y": 8 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "thresholds": { @@ -102,7 +102,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT COUNT(*) AS \"Total\" FROM log_events", "format": "table", "refId": "A" @@ -114,7 +114,7 @@ "title": "Active Devices (24h)", "type": "stat", "gridPos": { "h": 4, "w": 4, "x": 8, "y": 8 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "thresholds": { @@ -127,7 +127,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT COUNT(DISTINCT device) AS \"Devices\" FROM location_logs WHERE recorded_at >= NOW() - INTERVAL '24 hours'", "format": "table", "refId": "A" @@ -139,7 +139,7 @@ "title": "Average Accuracy by Device", "type": "bargauge", "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "unit": "m", @@ -159,7 +159,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n device,\n ROUND(AVG(accuracy)::numeric, 1) AS \"Avg Accuracy (m)\"\nFROM location_logs\nWHERE accuracy IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY device\nORDER BY \"Avg Accuracy (m)\" DESC\nLIMIT 20", "format": "table", "refId": "A" @@ -171,7 +171,7 @@ "title": "Accuracy Over Time", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "unit": "m", @@ -187,7 +187,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND(AVG(accuracy)::numeric, 1) AS accuracy\nFROM location_logs\nWHERE accuracy IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", "format": "time_series", "refId": "A" @@ -199,7 +199,7 @@ "title": "Battery Level Over Time", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "unit": "percent", @@ -217,7 +217,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND((AVG(battery_level) * 100)::numeric, 1) AS battery\nFROM location_logs\nWHERE battery_level IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", "format": "time_series", "refId": "A" @@ -229,7 +229,7 @@ "title": "Speed Over Time", "type": "timeseries", "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { "unit": "velocityms", @@ -245,7 +245,7 @@ }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n date_trunc('hour', recorded_at) AS time,\n device,\n ROUND(AVG(speed)::numeric, 2) AS speed\nFROM location_logs\nWHERE speed IS NOT NULL\n AND recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY time, device\nORDER BY time", "format": "time_series", "refId": "A" @@ -257,10 +257,10 @@ "title": "Log Events by Level", "type": "piechart", "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n log_level,\n COUNT(*) AS count\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nGROUP BY log_level\nORDER BY count DESC", "format": "table", "refId": "A" @@ -272,10 +272,10 @@ "title": "Recent Location Logs", "type": "table", "gridPos": { "h": 8, "w": 24, "x": 0, "y": 28 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n recorded_at AS time,\n device,\n state,\n station_id,\n line_id,\n segment_id,\n ROUND(latitude::numeric, 5) AS lat,\n ROUND(longitude::numeric, 5) AS lon,\n ROUND(accuracy::numeric, 1) AS accuracy,\n ROUND(speed::numeric, 1) AS speed,\n ROUND((battery_level * 100)::numeric, 0) AS \"battery%\"\nFROM location_logs\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nORDER BY recorded_at DESC\nLIMIT 100", "format": "table", "refId": "A" @@ -287,10 +287,10 @@ "title": "Recent Log Events", "type": "table", "gridPos": { "h": 8, "w": 24, "x": 0, "y": 36 }, - "datasource": { "type": "postgres", "uid": "" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "targets": [ { - "datasource": { "type": "postgres" }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, "rawSql": "SELECT\n recorded_at AS time,\n device,\n log_type,\n log_level,\n message\nFROM log_events\nWHERE recorded_at >= $__timeFrom() AND recorded_at <= $__timeTo()\nORDER BY recorded_at DESC\nLIMIT 100", "format": "table", "refId": "A" diff --git a/grafana/provisioning/datasources/postgres.yml b/grafana/provisioning/datasources/postgres.yml index 7e8758f..06aed6b 100644 --- a/grafana/provisioning/datasources/postgres.yml +++ b/grafana/provisioning/datasources/postgres.yml @@ -3,13 +3,14 @@ apiVersion: 1 datasources: - name: THQ PostgreSQL type: postgres + uid: thq-postgres url: db:5432 database: thq - user: thq + user: $__env{GRAFANA_PG_USER} jsonData: sslmode: disable postgresVersion: 1800 secureJsonData: - password: thq + password: $__env{GRAFANA_PG_PASSWORD} isDefault: true editable: true From 9f0e684740a81d9b6483262d9a5a7c870ab76106 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:20:55 +0000 Subject: [PATCH 3/6] Fix accuracy panels unit: use lengthmeter (m) instead of minutes Grafana's "m" unit maps to minutes; "lengthmeter" is the correct unit ID for meters. https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- grafana/dashboards/thq-overview.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grafana/dashboards/thq-overview.json b/grafana/dashboards/thq-overview.json index 005d961..824659b 100644 --- a/grafana/dashboards/thq-overview.json +++ b/grafana/dashboards/thq-overview.json @@ -142,7 +142,7 @@ "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { - "unit": "m", + "unit": "lengthmeter", "thresholds": { "steps": [ { "color": "green", "value": null }, @@ -174,7 +174,7 @@ "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { - "unit": "m", + "unit": "lengthmeter", "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, From 148be3ec828705d45b4ed10f5809898f0b32ccf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:22:09 +0000 Subject: [PATCH 4/6] Fix piechart: use rowsToFields transform so log_level becomes slice labels Without the transformation, piechart only shows "count" as a single series instead of breaking it out by log_level. https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- grafana/dashboards/thq-overview.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/grafana/dashboards/thq-overview.json b/grafana/dashboards/thq-overview.json index 824659b..8db36bd 100644 --- a/grafana/dashboards/thq-overview.json +++ b/grafana/dashboards/thq-overview.json @@ -258,6 +258,17 @@ "type": "piechart", "gridPos": { "h": 8, "w": 6, "x": 18, "y": 8 }, "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "transformations": [ + { + "id": "rowsToFields", + "options": { + "mappings": [ + { "fieldName": "log_level", "handlerKey": "field.name" }, + { "fieldName": "count", "handlerKey": "field.value" } + ] + } + } + ], "targets": [ { "datasource": { "type": "postgres", "uid": "thq-postgres" }, From 41d7f3b9ce839c5c092825a6a54645eac685c797 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:23:47 +0000 Subject: [PATCH 5/6] Fix accuracy unit display: use custom suffix instead of invalid unit ID "lengthmeter" is not a recognized Grafana unit and was displayed literally. Use "suffix: m" to show e.g. "33.2 m". https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- grafana/dashboards/thq-overview.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/grafana/dashboards/thq-overview.json b/grafana/dashboards/thq-overview.json index 8db36bd..390c80c 100644 --- a/grafana/dashboards/thq-overview.json +++ b/grafana/dashboards/thq-overview.json @@ -142,7 +142,7 @@ "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { - "unit": "lengthmeter", + "unit": "suffix: m", "thresholds": { "steps": [ { "color": "green", "value": null }, @@ -174,7 +174,7 @@ "datasource": { "type": "postgres", "uid": "thq-postgres" }, "fieldConfig": { "defaults": { - "unit": "lengthmeter", + "unit": "suffix: m", "color": { "mode": "palette-classic" }, "custom": { "lineWidth": 1, From 240848fdc856a40b06cff74082007b98d97fdc5a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Mar 2026 12:25:29 +0000 Subject: [PATCH 6/6] Add recorded_at composite indexes for Grafana dashboard queries All dashboard panels filter on recorded_at with time range macros. Without an index on recorded_at, every query does a full table scan. - idx_location_logs_recorded_at (recorded_at, device) - idx_log_events_recorded_at (recorded_at, log_level) https://claude.ai/code/session_01RADeEtKpYSQno63R2Cd5Ce --- src/storage.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/storage.rs b/src/storage.rs index d747d23..83c7d20 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -141,6 +141,14 @@ impl Storage { .execute(pool) .await?; + sqlx::query("CREATE INDEX IF NOT EXISTS idx_location_logs_recorded_at ON location_logs (recorded_at, device);") + .execute(pool) + .await?; + + sqlx::query("CREATE INDEX IF NOT EXISTS idx_log_events_recorded_at ON log_events (recorded_at, log_level);") + .execute(pool) + .await?; + Ok(()) }