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 ed740e9..509cb77 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -32,5 +32,25 @@ 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:?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 + - ./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..390c80c --- /dev/null +++ b/grafana/dashboards/thq-overview.json @@ -0,0 +1,321 @@ +{ + "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": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 20, + "pointSize": 5, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 2, + "title": "Log Events Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 2, + "fillOpacity": 20, + "pointSize": 5, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 3, + "title": "Total Location Logs", + "type": "stat", + "gridPos": { "h": 4, "w": 4, "x": 0, "y": 8 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "blue", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres", "uid": "thq-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": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "green", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "datasource": { "type": "postgres", "uid": "thq-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": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "thresholds": { + "steps": [ + { "color": "orange", "value": null } + ] + } + }, + "overrides": [] + }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 6, + "title": "Average Accuracy by Device", + "type": "bargauge", + "gridPos": { "h": 8, "w": 6, "x": 12, "y": 8 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "unit": "suffix: 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", "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" + } + ] + }, + { + "id": 7, + "title": "Accuracy Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "unit": "suffix: m", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 1, + "fillOpacity": 10, + "pointSize": 3, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 8, + "title": "Battery Level Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 16 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "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", "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" + } + ] + }, + { + "id": 9, + "title": "Speed Over Time", + "type": "timeseries", + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 20 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "fieldConfig": { + "defaults": { + "unit": "velocityms", + "color": { "mode": "palette-classic" }, + "custom": { + "lineWidth": 1, + "fillOpacity": 10, + "pointSize": 3, + "showPoints": "auto" + } + }, + "overrides": [] + }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 10, + "title": "Log Events by Level", + "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" }, + "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": "thq-postgres" }, + "targets": [ + { + "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" + } + ] + }, + { + "id": 12, + "title": "Recent Log Events", + "type": "table", + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 36 }, + "datasource": { "type": "postgres", "uid": "thq-postgres" }, + "targets": [ + { + "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" + } + ] + } + ], + "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..06aed6b --- /dev/null +++ b/grafana/provisioning/datasources/postgres.yml @@ -0,0 +1,16 @@ +apiVersion: 1 + +datasources: + - name: THQ PostgreSQL + type: postgres + uid: thq-postgres + url: db:5432 + database: thq + user: $__env{GRAFANA_PG_USER} + jsonData: + sslmode: disable + postgresVersion: 1800 + secureJsonData: + password: $__env{GRAFANA_PG_PASSWORD} + isDefault: true + editable: true 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(()) }