diff --git a/k8s/monitoring/AGENTS.md b/k8s/monitoring/AGENTS.md index c3c4983..f9dc34c 100644 --- a/k8s/monitoring/AGENTS.md +++ b/k8s/monitoring/AGENTS.md @@ -4,22 +4,36 @@ This folder manages the **kube-prometheus-stack** deployment and **Grafana dashboards as code** for the `haumdaucher` Kubernetes cluster. The monitoring stack runs in the `monitoring` namespace. +## Prerequisites — Grafana Skills + +Before creating or modifying dashboards, **read these skills first** — they cover general Grafana topics (JSON schema, panel types, PromQL, provisioning) that this document does not repeat: + +| Skill | Covers | +|---|---| +| **`dashboarding`** | Dashboard JSON schema, panel types, units, template variables, transformations, annotations | +| **`grafana-oss`** | Dashboard/datasource provisioning, RBAC, plugin config, health checks | +| **`promql`** | PromQL query writing, rate/irate, histograms, recording rules, cardinality | + +For project-specific dashboard creation workflows (ConfigMap template, InfluxDB Flux queries, deploy/verify steps), see **[DASHBOARD_CREATION.md](DASHBOARD_CREATION.md)**. + ## Folder Structure ``` monitoring/ ├── AGENTS.md ← You are here ├── README.md ← Human-oriented deployment guide -├── DASHBOARD_CREATION.md ← How to create/update dashboards (read this!) +├── DASHBOARD_CREATION.md ← Project-specific dashboard creation guide ├── prometheus-operator.secret.yml ← Helm values for kube-prometheus-stack ├── alertmanagerconfig.secret.yaml ← Alertmanager config (Telegram alerts) ├── servicemonitor.secret.yml ← ServiceMonitor examples (commented out) +├── taupi-fan.yml ← Cellar fan exporter (Service, Endpoints, ServiceMonitor, Alerts) ├── tankerkoenig.yml ← Tankerkoenig fuel price exporter ├── dashboards/ ← Grafana dashboards as ConfigMaps │ ├── grafana-dashboard-home-climate.yaml │ ├── grafana-dashboard-energy-monitor.yaml │ ├── grafana-dashboard-heating-gas.yaml -│ └── grafana-dashboard-outdoor-weather.yaml +│ ├── grafana-dashboard-outdoor-weather.yaml +│ └── grafana-dashboard-taupi-fan.yaml └── scripts/ ← Discovery & helper scripts ├── discover-influxdb.sh ← Query InfluxDB schema └── discover-home-assistant.sh ← Query Home Assistant entities @@ -41,6 +55,10 @@ monitoring/ Home Assistant sensors → InfluxDB (bucket: default, org: influxdata) ↓ Grafana (Flux queries via datasource) + +Custom exporters (taupi-fan, etc.) → Prometheus (scraped via ServiceMonitor) + ↓ + Grafana (PromQL queries) ``` ### Grafana Datasources @@ -73,92 +91,21 @@ Home Assistant sensors → InfluxDB (bucket: default, org: influxdata) ## Dashboard Architecture -Dashboards are managed as **Kubernetes ConfigMaps** with the label `grafana_dashboard: "1"`. The kube-prometheus-stack's **Grafana Dashboard Sidecar** (`grafana-sc-dashboard` container) watches for these ConfigMaps and auto-loads them into Grafana. +Dashboards are managed as **Kubernetes ConfigMaps** with the label `grafana_dashboard: "1"`. The kube-prometheus-stack's **Grafana Dashboard Sidecar** (`grafana-sc-dashboard` container, singular!) watches for these ConfigMaps and auto-loads them into Grafana. ### Key Design Decisions -1. **One file per dashboard** — Each ConfigMap YAML contains the dashboard JSON embedded directly in `data`. No separate JSON files (avoids duplication). -2. **Strict GitOps** — Dashboards are `editable: false` in Grafana UI. All changes must be made to the YAML files and re-applied. -3. **Namespace-scoped** — Sidecar only watches the `monitoring` namespace (`searchNamespace: monitoring`). -4. **Folder annotation** — Use `grafana_dashboard_folder` annotation to organize dashboards in Grafana folders. +1. **One file per dashboard** — Each ConfigMap YAML contains the dashboard JSON embedded directly in `data`. +2. **Strict GitOps** — Dashboards are `editable: false` in Grafana UI. All changes go through YAML files. +3. **Namespace-scoped** — Sidecar only watches `monitoring` namespace (`searchNamespace: monitoring`). +4. **Folder annotation** — Use `grafana_dashboard_folder` annotation to organize dashboards. +5. **No `"dashboard"` wrapper** — File provisioning requires top-level JSON properties. The `{"dashboard": {...}}` wrapper is only for the HTTP API. +6. **Never add `uid` to helm datasource config** — Let Grafana auto-generate UIDs. Adding `uid` to `additionalDataSources` crashes provisioning. -### ConfigMap Template - -```yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: grafana-dashboard- - namespace: monitoring - labels: - grafana_dashboard: "1" - annotations: - grafana_dashboard_folder: "🏠 Home" -data: - .json: | - { - "uid": "", - "title": "", - ... - } -``` - -## InfluxDB Data Schema - -Home Assistant writes sensor data to InfluxDB with this structure: - -- **Measurement name** = unit of measurement (e.g., `°C`, `%`, `W`, `kWh`, `m³`, `hPa`, `km/h`, `mm/h`) -- **Fields**: `value` (numeric), plus metadata fields (`friendly_name_str`, `device_class_str`, `id_str`, etc.) -- **Tags**: `entity_id` (e.g., `home_wohnzimmer_temperature` — **NOTE: no `sensor.` prefix!**), `domain` (e.g., `sensor`) - -### Flux Query Pattern (MUST follow this exactly) - -**Exact match on entity_id:** -```flux -from(bucket: "default") - |> range(start: v.timeRangeStart, stop: v.timeRangeStop) - |> filter(fn: (r) => r["_measurement"] == "°C") - |> filter(fn: (r) => r["_field"] == "value") - |> filter(fn: (r) => r.entity_id == "home_wandthermostat_bad_temperature") - |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) - |> yield(name: "mean") -``` - -**Regex match on entity_id** (use `=~` operator with `/pattern/`): -```flux -from(bucket: "default") - |> range(start: v.timeRangeStart, stop: v.timeRangeStop) - |> filter(fn: (r) => r["_measurement"] == "°C") - |> filter(fn: (r) => r["_field"] == "value") - |> filter(fn: (r) => r.entity_id =~ /^home_/) - |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) - |> yield(name: "mean") -``` - -**Critical rules:** -- Always use bracket notation: `r["_measurement"]`, `r["_field"]` (fields starting with `_`) -- Always use dynamic time: `range(start: v.timeRangeStart, stop: v.timeRangeStop)` and `aggregateWindow(every: v.windowPeriod, ...)` -- Always end with `|> yield(name: "mean")` -- **entity_id in InfluxDB does NOT have the `sensor.` prefix** — use `home_wohnzimmer_temperature`, NOT `sensor.home_wohnzimmer_temperature` -- For "current value" stat panels, use `range(start: -15m)` with `last()` instead of aggregateWindow -- Regex operator is `=~` with `/pattern/` syntax (e.g., `=~ /^home_/` matches all entities starting with `home_`) - -## Common Pitfalls (Lessons Learned) - -1. **Entity IDs have no `sensor.` prefix in InfluxDB** — HA stores domain separately. Use `home_wohnzimmer_temperature`, not `sensor.home_wohnzimmer_temperature`. This is the #1 cause of "no data". -2. **Dashboard JSON must NOT be wrapped in `"dashboard"` key** — Grafana file provisioning expects top-level properties (`uid`, `title`, `panels`, etc.). The `"dashboard"` wrapper is only used in the HTTP API. -3. **Datasource reference must use actual UID** — Discover via `kubectl exec` + Grafana API. Current UID: `P2AB959DC95E5519F`. -4. **Don't add `uid` to helm datasource config** — Adding `uid` to existing datasources in `additionalDataSources` causes Grafana provisioning to fail with "data source not found". Let UIDs be auto-generated. -5. **Always use bracket notation for `_measurement` and `_field`** — Use `r["_measurement"]` and `r["_field"]`, not dot notation. Fields starting with underscore may not resolve with dot notation in Flux. -6. **Always use Grafana time variables** — Never hardcode `range(start: -24h)` or `aggregateWindow(every: 5m)`. Always use `v.timeRangeStart`, `v.timeRangeStop`, `v.windowPeriod`. -7. **Always add `yield(name: "mean")`** — Required for Grafana to properly parse Flux results. -8. **InfluxDB has multiple fields per measurement** — Always filter `r["_field"] == "value"` to get numeric data. -9. **Sidecar container name is `grafana-sc-dashboard`** (singular), not `grafana-sc-dashboards`. -10. **Use Wandthermostat entities for temperature/humidity** — Query HA to filter by device type. Wandthermostat entities have `wandthermostat` or `thermostat` in their entity_id. +For the full creation workflow, Flux query templates, and InfluxDB conventions, see **[DASHBOARD_CREATION.md](DASHBOARD_CREATION.md)**. ## Related Files Outside This Folder - `../home-assistant/home-assistant.secret.yaml` — HA helm values (InfluxDB integration config) - `../home-assistant/tmp_long_lived_token.secret.yml` — HA API token for discovery -- `../influxdb/influxdb2.secret.yml` — InfluxDB helm values -- `../../kochbuch/plans/grafana-dashboards-as-code.md` — Original architecture plan \ No newline at end of file +- `../influxdb/influxdb2.secret.yml` — InfluxDB helm values \ No newline at end of file diff --git a/k8s/monitoring/DASHBOARD_CREATION.md b/k8s/monitoring/DASHBOARD_CREATION.md index 4377b33..385f7bf 100644 --- a/k8s/monitoring/DASHBOARD_CREATION.md +++ b/k8s/monitoring/DASHBOARD_CREATION.md @@ -2,102 +2,79 @@ > **Linked from**: [AGENTS.md](AGENTS.md) -This document captures the exact process used to create the Grafana dashboards in this folder, including all pitfalls encountered and lessons learned. Use this as a reference when creating new dashboards or updating existing ones. +This document captures the project-specific process for creating Grafana dashboards in this folder. For general Grafana dashboard JSON schema, panel types, units, transformations, and API workflows, use the **Grafana skills** (`dashboarding`, `grafana-oss`, `promql`) — they are the authoritative reference for Grafana-native topics. + +> [!IMPORTANT] +> **Always read the skills first** before creating a dashboard: +> - **`dashboarding`** — JSON schema, panel types, units, template variables, transformations, links, annotations +> - **`grafana-oss`** — Dashboard provisioning, datasource config, RBAC, plugin provisioning +> - **`promql`** — PromQL query patterns, rate/irate, histograms, recording rules --- -## Step-by-Step Dashboard Creation Process +## Step 1: Identify the Data Source -### Step 1: Discovery — Find Available Metrics +This project has **two** types of dashboard data sources: -Before creating any dashboard, discover what data actually exists: +| Data Source | Type | When to Use | Query Language | +|---|---|---|---| +| Prometheus | `prometheus` | Kubernetes metrics, custom exporters (taupi-fan, tankerkoenig) | PromQL | +| InfluxDB Home Assistant | `influxdb` (Flux) | Home Assistant sensor data (temperature, humidity, energy, etc.) | Flux | -#### 1a. Query Home Assistant for Entity Metadata +**Prometheus dashboards** are simpler — just write PromQL expressions in `targets[].expr`. See the `promql` skill and existing examples like [grafana-dashboard-taupi-fan.yaml](dashboards/grafana-dashboard-taupi-fan.yaml). -```bash -# Get all entities with their friendly names, units, and device classes -curl -s -H "Authorization: Bearer " \ - https://hass.moritzgraf.de/api/states | \ - jq '[.[] | select(.attributes.unit_of_measurement != null) | { - entity_id, - unit: .attributes.unit_of_measurement, - friendly_name: (.attributes.friendly_name // ""), - device_class: (.attributes.device_class // "") - }] | group_by(.unit)' -``` +**InfluxDB dashboards** require Flux queries with project-specific conventions (see [§ InfluxDB Flux Queries](#influxdb-flux-queries) below). -Or use the helper script: `./scripts/discover-home-assistant.sh` +### Datasource UIDs -#### 1b. Query InfluxDB for Measurements and Schema - -```bash -# Port-forward to InfluxDB (no ingress configured) -kubectl -n influxdb port-forward svc/influxdb-influxdb2 8086:80 - -# List measurements with data in last 6 months -curl -s "http://localhost:8086/api/v2/query?org=influxdata" \ - -H "Authorization: Token " \ - -H "Content-Type: application/vnd.flux" \ - -d 'from(bucket: "default") - |> range(start: -6mo) - |> keep(columns: ["_measurement"]) - |> distinct() - |> sort()' - -# For a specific measurement, get fields and tags -curl -s "http://localhost:8086/api/v2/query?org=influxdata" \ - -H "Authorization: Token " \ - -H "Content-Type: application/vnd.flux" \ - -d 'import "influxdata/influxdb/schema" - schema.measurementFieldKeys(bucket: "default", measurement: "°C")' -``` - -Or use: `./scripts/discover-influxdb.sh` - -#### 1c. Verify Data Exists - -Always test a sample query before building dashboards: - -```bash -curl -s "http://localhost:8086/api/v2/query?org=influxdata" \ - -H "Authorization: Token " \ - -H "Content-Type: application/vnd.flux" \ - -d 'from(bucket: "default") - |> range(start: -1h) - |> filter(fn: (r) => r._measurement == "°C" and r._field == "value") - |> limit(n: 5)' -``` - -### Step 2: Discover Grafana Datasource UIDs - -Datasource UIDs change when Grafana is redeployed. Always discover the current UIDs: +Datasource references in panels require the **actual UID**, not the display name. UIDs can change when Grafana is redeployed. Current known UIDs are documented in [AGENTS.md](AGENTS.md) — always verify before creating a new dashboard: ```bash kubectl -n monitoring exec deploy/prometheus-operator-grafana -c grafana -- \ curl -s -u "admin:" \ - http://localhost:3000/api/datasources | \ - jq '.[] | {name, uid, type}' + http://localhost:3000/api/datasources | jq '.[] | {name, uid, type}' ``` The Grafana admin password is in [`prometheus-operator.secret.yml`](prometheus-operator.secret.yml) under `grafana.adminPassword`. -### Step 3: Select the Right Entities +--- -**Important**: Not all sensors of the same type are equal. For example: -- Use **Wandthermostat** (wall thermostat) entities for temperature/humidity, not raw sensor entities. Wandthermostats provide more reliable, calibrated readings. -- Query Home Assistant to filter by entity name pattern or device type. +## Step 2: Discover Available Metrics + +### For Prometheus Dashboards + +Query Prometheus directly or use the Grafana Explore UI: ```bash -# Find all Wandthermostat temperature sensors -curl -s -H "Authorization: Bearer " \ - https://hass.moritzgraf.de/api/states | \ - jq '[.[] | select(.attributes.unit_of_measurement == "°C" and - (.entity_id | test("wandthermostat|thermostat"; "i")))]' +# Port-forward to Prometheus +kubectl -n monitoring port-forward svc/prometheus-operated 9090 + +# Browse available metrics +curl -s http://localhost:9090/api/v1/label/__name__/values | jq '.data[]' | grep '' ``` -### Step 4: Create the Dashboard ConfigMap YAML +### For InfluxDB Dashboards (Home Assistant Data) -Create a new file in `dashboards/` following this template: +Use the helper scripts to discover what data exists: + +```bash +# Query Home Assistant for entity metadata +./scripts/discover-home-assistant.sh + +# Query InfluxDB for measurements and schema +# (requires port-forward first: kubectl -n influxdb port-forward svc/influxdb-influxdb2 8086:80) +./scripts/discover-influxdb.sh +``` + +**Sensor selection tip**: Use **Wandthermostat** entities for temperature/humidity (they provide calibrated readings). Filter by `wandthermostat` or `thermostat` in the entity_id. + +--- + +## Step 3: Create the Dashboard ConfigMap + +Dashboards are deployed as **Kubernetes ConfigMaps** with the label `grafana_dashboard: "1"`. The Grafana sidecar (`grafana-sc-dashboard` container) watches for these and auto-loads them. + +### ConfigMap Template ```yaml apiVersion: v1 @@ -114,57 +91,94 @@ data: { "uid": "", "title": "", - "tags": ["home", "", "auto-generated"], + "tags": [""], "timezone": "browser", "schemaVersion": 39, "version": 1, - "refresh": "5m", + "refresh": "30s", "editable": false, - "graphTooltip": 1, + "graphTooltip": 2, "time": { "from": "now-24h", "to": "now" }, "panels": [ ... ] } ``` -#### Critical JSON Structure Rules +> [!CAUTION] +> **No `"dashboard"` wrapper.** The JSON must have `uid`, `title`, `panels` at the top level. The `{"dashboard": {...}}` wrapper is only for the Grafana HTTP API — file provisioning via the sidecar will fail with *"Dashboard title cannot be empty"* if you use it. -**✅ CORRECT** (top-level properties, no wrapper): -```json -{ - "uid": "my-dashboard", - "title": "My Dashboard", - "panels": [...] -} +For the full panel JSON schema, units, thresholds, overrides, and transformations, see the **`dashboarding`** skill's `references/json-schema.md`. + +### Project-Specific Dashboard Conventions + +- `"editable": false` — strict GitOps; never edit in the Grafana UI +- `"graphTooltip": 2` — shared crosshair + tooltip across all panels +- Use emoji in titles for visual appeal (🏠 ⚡ 🔥 🌡️ 💧 💨) +- Include `calcs` in legend: `["lastNotNull", "min", "max", "mean"]` +- Use `"spanNulls": true` to handle gaps in sensor data +- Use semantic threshold colors: blue=cold, green=comfort, orange=warm, red=hot + +--- + +## Step 4: Deploy & Verify + +```bash +# Apply +kubectl apply -f dashboards/grafana-dashboard-.yaml + +# Verify ConfigMap labeling +kubectl get configmaps -n monitoring -l grafana_dashboard=1 + +# Check sidecar pickup (container name is singular: grafana-sc-dashboard) +kubectl -n monitoring logs -l "app.kubernetes.io/name=grafana" \ + -c grafana-sc-dashboard --tail=20 + +# Check for Grafana errors +kubectl -n monitoring logs -l "app.kubernetes.io/name=grafana" \ + -c grafana --tail=30 | grep -i "error\|failed" ``` -**❌ WRONG** (wrapped in "dashboard" key — causes "Dashboard title cannot be empty"): -```json -{ - "dashboard": { - "uid": "my-dashboard", - "title": "My Dashboard", - "panels": [...] - } -} +### Updating an Existing Dashboard + +1. Edit the YAML file in `dashboards/` +2. Increment the `version` field in the dashboard JSON +3. `kubectl apply -f dashboards/grafana-dashboard-.yaml` +4. Sidecar detects the change within ~60 seconds + +### Removing a Dashboard + +```bash +kubectl delete configmap -n monitoring grafana-dashboard- ``` -The `"dashboard"` wrapper is **only** used in the Grafana HTTP API (`/api/dashboards/db`). For file-based provisioning via the sidecar, properties must be at the top level. +--- -#### Datasource Reference +## InfluxDB Flux Queries -**✅ CORRECT** (by actual UID): -```json -"datasource": { "type": "influxdb", "uid": "P2AB959DC95E5519F" } -``` +This section covers the **project-specific Flux conventions** for querying Home Assistant data in InfluxDB. These patterns are unique to this project and not covered by the Grafana skills. -**❌ WRONG** (by name — won't resolve): -```json -"datasource": { "type": "influxdb", "uid": "InfluxDB Home Assistant" } -``` +### Data Schema -#### Flux Query Template +Home Assistant writes to InfluxDB with this structure: -**Exact match on entity_id:** +- **Measurement name** = unit of measurement (e.g., `°C`, `%`, `W`, `kWh`) +- **Tags**: `entity_id` (e.g., `home_wohnzimmer_temperature`), `domain` (e.g., `sensor`) +- **Fields**: `value` (numeric), plus metadata strings (`friendly_name_str`, etc.) + +### Critical Flux Query Rules + +> [!WARNING] +> These rules come from hard-won debugging. Violating any of them will result in "no data" panels. + +1. **`entity_id` has NO `sensor.` prefix** — HA stores the domain in a separate `domain` tag. Use `home_wohnzimmer_temperature`, NOT `sensor.home_wohnzimmer_temperature`. +2. **Always use bracket notation** for underscore-prefixed fields: `r["_measurement"]`, `r["_field"]` — dot notation (`r._measurement`) may silently fail. +3. **Always use Grafana time variables** — `v.timeRangeStart`, `v.timeRangeStop`, `v.windowPeriod`. Never hardcode `range(start: -24h)`. +4. **Always end with `|> yield(name: "mean")`** — required for Grafana to parse Flux results. +5. **Always filter `r["_field"] == "value"`** — InfluxDB stores metadata strings in other fields. +6. **Use regex for multi-entity panels** — `r.entity_id =~ /^home_/` instead of `or` chains (Flux `or` inside a single filter is unreliable). + +### Flux Query Templates + +**Time series panel (aggregated):** ```flux from(bucket: "default") |> range(start: v.timeRangeStart, stop: v.timeRangeStop) @@ -174,17 +188,7 @@ from(bucket: "default") |> yield(name: "mean") ``` -**Regex match on entity_id** (for panels showing multiple entities matching a pattern): -```flux -from(bucket: "default") - |> range(start: v.timeRangeStart, stop: v.timeRangeStop) - |> filter(fn: (r) => r["_measurement"] == "" and r["_field"] == "value") - |> filter(fn: (r) => r.entity_id =~ /^home_/) - |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) - |> yield(name: "mean") -``` - -**Current value** (for stat panels showing last reading): +**Stat panel (current value):** ```flux from(bucket: "default") |> range(start: -15m) @@ -194,133 +198,29 @@ from(bucket: "default") |> yield(name: "mean") ``` -**Key points**: -- Use bracket notation `r["_measurement"]`, `r["_field"]` for fields starting with underscore. -- **entity_id in InfluxDB has NO `sensor.` prefix** — HA stores domain in a separate `domain` tag. Use `home_wohnzimmer_temperature`, not `sensor.home_wohnzimmer_temperature`. -- Always use `v.timeRangeStart`, `v.timeRangeStop`, `v.windowPeriod` — never hardcode time values. -- Always end with `|> yield(name: "mean")` — required for Grafana to parse Flux results. -- Use regex `=~ /pattern/` for multi-entity panels (e.g., `=~ /^home_/` matches all home entities). -- Always filter `r["_field"] == "value"` — InfluxDB stores metadata strings in other fields. - -### Step 5: Deploy - -```bash -# Apply all dashboards -kubectl apply -f dashboards/ - -# Apply a single dashboard -kubectl apply -f dashboards/grafana-dashboard-.yaml +**Multi-entity panel (regex):** +```flux +from(bucket: "default") + |> range(start: v.timeRangeStart, stop: v.timeRangeStop) + |> filter(fn: (r) => r["_measurement"] == "" and r["_field"] == "value") + |> filter(fn: (r) => r.entity_id =~ /^home_/) + |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) + |> yield(name: "mean") ``` -### Step 6: Verify - -```bash -# Check ConfigMaps are labeled correctly -kubectl get configmaps -n monitoring -l grafana_dashboard=1 - -# Check sidecar logs (container name is grafana-sc-dashboard, singular!) -kubectl -n monitoring logs -l "app.kubernetes.io/name=grafana" \ - -c grafana-sc-dashboard --tail=20 - -# Check Grafana main logs for errors -kubectl -n monitoring logs -l "app.kubernetes.io/name=grafana" \ - -c grafana --tail=30 | grep -i "error\|failed" -``` - ---- - -## Update Process (Modifying an Existing Dashboard) - -1. Edit the YAML file in `dashboards/` -2. Increment the `version` field in the dashboard JSON -3. Run `kubectl apply -f dashboards/grafana-dashboard-.yaml` -4. The sidecar will detect the change and reload within ~60 seconds -5. Verify in Grafana UI - -**Never edit dashboards in the Grafana UI** — they are `editable: false`. All changes must go through the YAML files. - ---- - -## Removing a Dashboard - -```bash -kubectl delete configmap -n monitoring grafana-dashboard- -``` - -The sidecar will remove it from Grafana within ~60 seconds. - ---- - -## Pitfalls Encountered in This Conversation - -### Pitfall 1: Entity IDs lack `sensor.` prefix in InfluxDB -**Problem**: Used `sensor.home_wohnzimmer_temperature` in queries — no data appeared. Home Assistant stores the domain (`sensor`) in a separate `domain` tag, not in `entity_id`. -**Fix**: Use `home_wohnzimmer_temperature` (without prefix). Always query InfluxDB directly to verify entity_id format before building queries. - -### Pitfall 2: Hardcoded time ranges -**Problem**: Used `range(start: -24h)` and `aggregateWindow(every: 5m)` — dashboard time picker had no effect, data not loaded properly. -**Fix**: Always use `v.timeRangeStart`, `v.timeRangeStop`, `v.windowPeriod`. These Grafana variables adapt to the dashboard time picker. - -### Pitfall 3: Missing `yield()` -**Problem**: Flux queries missing `yield(name: "mean")` — Grafana couldn't parse results. -**Fix**: Always end Flux queries with `|> yield(name: "mean")`. - -### Pitfall 4: Dot notation for underscore fields -**Problem**: Used `r._measurement` and `r._field` — Flux may not resolve fields starting with `_` via dot notation. -**Fix**: Use bracket notation: `r["_measurement"]`, `r["_field"]`. - -### Pitfall 5: Datasource UID vs Name -**Problem**: Used datasource name as UID (`"uid": "InfluxDB Home Assistant"`). Dashboards loaded but showed "no data". -**Fix**: Discover actual UID via `kubectl exec` + Grafana API. Current: `P2AB959DC95E5519F`. - -### Pitfall 6: `"dashboard"` JSON Wrapper -**Problem**: Dashboard JSON wrapped in `{"dashboard": {...}}`. Grafana showed "Dashboard title cannot be empty". -**Fix**: Top-level properties only. The `"dashboard"` wrapper is for HTTP API, not file provisioning. - -### Pitfall 7: Adding `uid` to Helm Datasource Config -**Problem**: Added `uid: influxdb-ha-flux` to helm values — Grafana pod crashed. -**Fix**: Let Grafana auto-generate UIDs. Never add `uid` to existing datasources. - -### Pitfall 8: Multi-entity panels using `or` in filter -**Problem**: Used `filter(fn: (r) => r.entity_id == "A" or r.entity_id == "B")` — Flux `or` inside a single filter call doesn't reliably work. -**Fix**: Use regex: `filter(fn: (r) => r.entity_id =~ /^home_/)` for matching multiple entities. - -### Pitfall 9: Wrong Sensor Selection -**Problem**: Used raw temperature sensors instead of Wandthermostat sensors. -**Fix**: Query HA to filter entities with `wandthermostat` or `thermostat` in entity_id. - -### Pitfall 10: Sidecar Container Name -**Problem**: `kubectl logs -c grafana-sc-dashboards` (plural). -**Fix**: Container name is `grafana-sc-dashboard` (singular). - ---- - -## Dashboard Design Guidelines - -1. **Use semantic thresholds with colors**: Blue=cold, green=comfort, orange=warm, red=hot for temperature; reverse for grid import (green=low, red=high). -2. **Use Stat panels with sparklines** for current values — they show the 24h mini-trend. -3. **Use BarGauge for day comparisons** (today vs yesterday vs avg). -4. **Use Gauge for ranged values** (battery SoC 0-100%, cloud cover 0-100%). -5. **Color-code panel backgrounds** with `"colorMode": "background"` for at-a-glance status. -6. **Set `"editable": false`** for strict GitOps. -7. **Use emoji in dashboard and panel titles** for visual appeal (🏠 ⚡ 🔥 🌡️ 💧). -8. **Add `"auto-generated"` tag** to distinguish from manually created dashboards. -9. **Include calcs in legend** (`mean`, `max`, `min`, `lastNotNull`) for quick statistics. -10. **Use `"spanNulls": true`** in timeseries to handle gaps in sensor data. - --- ## InfluxDB Measurement Reference -| Measurement | Unit | Typical Entities | -|-------------|------|------------------| -| `°C` | celsius | Wandthermostat temperatures, outdoor temp, battery temp | -| `%` | percent | Humidity, battery levels, heating valve position, cloud cover | -| `W` | watt | Solar production, self-consumption, grid import/export, house consumption | -| `kWh` | kilowatt-hour | Daily energy totals (production, consumption, import, export) | -| `m³` | cubic meter | Gas volume counter | -| `m³/h` | cubic meter/hour | Current gas flow rate | -| `hPa` | hectopascal | Barometric pressure | -| `km/h` | kilometer/hour | Wind speed | -| `mm/h` | millimeter/hour | Precipitation rate | -| `EUR` | euro | Gas cost, electricity cost | +| Measurement | Grafana Unit | Typical Entities | +|---|---|---| +| `°C` | `celsius` | Wandthermostat temperatures, outdoor temp, battery temp | +| `%` | `percent` | Humidity, battery levels, heating valve position, cloud cover | +| `W` | `watt` | Solar production, self-consumption, grid import/export | +| `kWh` | `kwatth` | Daily energy totals (production, consumption, import, export) | +| `m³` | `m3` | Gas volume counter | +| `m³/h` | `m3/h` | Current gas flow rate | +| `hPa` | `pressurehpa` | Barometric pressure | +| `km/h` | `velocitykmh` | Wind speed | +| `mm/h` | `lengthmm` | Precipitation rate | +| `EUR` | `currencyEUR` | Gas cost, electricity cost | diff --git a/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml b/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml index 73b7a57..6b50fa4 100644 --- a/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml +++ b/k8s/monitoring/dashboards/grafana-dashboard-taupi-fan.yaml @@ -15,10 +15,10 @@ data: "tags": ["climate", "fan", "cellar", "prometheus"], "timezone": "browser", "schemaVersion": 39, - "version": 1, + "version": 3, "refresh": "30s", "editable": false, - "graphTooltip": 1, + "graphTooltip": 2, "time": { "from": "now-24h", "to": "now" @@ -50,7 +50,7 @@ data: "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} }, "targets": [ - {"expr": "taupi_relay_status", "legendFormat": "Fan Status"} + {"expr": "taupi_relay_status", "legendFormat": "Fan Status", "refId": "A"} ] }, { @@ -72,7 +72,7 @@ data: "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} }, "targets": [ - {"expr": "taupi_is_critical", "legendFormat": "Mold Danger"} + {"expr": "taupi_is_critical", "legendFormat": "Mold Danger", "refId": "A"} ] }, { @@ -94,7 +94,7 @@ data: "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} }, "targets": [ - {"expr": "taupi_would_fan_activate", "legendFormat": "Dewpoint Condition"} + {"expr": "taupi_would_fan_activate", "legendFormat": "Dewpoint Condition", "refId": "A"} ] }, { @@ -116,150 +116,389 @@ data: "reduceOptions": {"values": false, "calcs": ["lastNotNull"]} }, "targets": [ - {"expr": "up{job=\"taupi-fan\"}", "legendFormat": "Connectivity"} + {"expr": "up{job=\"taupi-fan\"}", "legendFormat": "Connectivity", "refId": "A"} ] }, + { - "title": "🌡️ Climate Details", + "title": "📊 Climate Correlation", "type": "row", "gridPos": {"h": 1, "w": 24, "x": 0, "y": 5}, "collapsed": false }, + { - "title": "Temperatures", + "title": "📊 Full Climate & Fan Correlation", + "description": "All metrics in one view. Left axis: temperatures & dewpoints (°C). Right axis: humidity & mold threshold (%). Colored background bands: orange = fan running, red = mold critical, green = activation criteria met. Hover anywhere to read all values at that timestamp.", "type": "timeseries", "datasource": {"type": "prometheus", "uid": "prometheus"}, - "gridPos": {"h": 8, "w": 12, "x": 0, "y": 6}, + "gridPos": {"h": 14, "w": 24, "x": 0, "y": 6}, "fieldConfig": { "defaults": { - "unit": "celsius", "custom": { "lineWidth": 2, - "fillOpacity": 5, + "fillOpacity": 0, "spanNulls": true, "showPoints": "never" } - } - }, - "options": { - "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, - "tooltip": {"mode": "multi", "sort": "desc"} - }, - "targets": [ - {"expr": "taupi_temperature_celsius_innen", "legendFormat": "Indoor Temperature"}, - {"expr": "taupi_temperature_celsius_aussen", "legendFormat": "Outdoor Temperature"} - ] - }, - { - "title": "Dew Points", - "type": "timeseries", - "datasource": {"type": "prometheus", "uid": "prometheus"}, - "gridPos": {"h": 8, "w": 12, "x": 12, "y": 6}, - "fieldConfig": { - "defaults": { - "unit": "celsius", - "custom": { - "lineWidth": 2, - "fillOpacity": 5, - "spanNulls": true, - "showPoints": "never" + }, + "overrides": [ + { + "matcher": {"id": "byName", "options": "Fan Active"}, + "properties": [ + {"id": "color", "value": {"fixedColor": "#FF9800", "mode": "fixed"}}, + {"id": "custom.axisPlacement", "value": "hidden"}, + {"id": "custom.drawStyle", "value": "line"}, + {"id": "custom.lineInterpolation", "value": "stepAfter"}, + {"id": "custom.lineWidth", "value": 0}, + {"id": "custom.fillOpacity", "value": 15}, + {"id": "min", "value": 0}, + {"id": "max", "value": 1}, + {"id": "decimals", "value": 0}, + {"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "OFF"}, "1": {"text": "ON"}}}]} + ] + }, + { + "matcher": {"id": "byName", "options": "Mold Critical"}, + "properties": [ + {"id": "color", "value": {"fixedColor": "#F44336", "mode": "fixed"}}, + {"id": "custom.axisPlacement", "value": "hidden"}, + {"id": "custom.drawStyle", "value": "line"}, + {"id": "custom.lineInterpolation", "value": "stepAfter"}, + {"id": "custom.lineWidth", "value": 0}, + {"id": "custom.fillOpacity", "value": 10}, + {"id": "min", "value": 0}, + {"id": "max", "value": 1}, + {"id": "decimals", "value": 0}, + {"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "SAFE"}, "1": {"text": "CRITICAL"}}}]} + ] + }, + { + "matcher": {"id": "byName", "options": "Activation Criteria"}, + "properties": [ + {"id": "color", "value": {"fixedColor": "#4CAF50", "mode": "fixed"}}, + {"id": "custom.axisPlacement", "value": "hidden"}, + {"id": "custom.drawStyle", "value": "line"}, + {"id": "custom.lineInterpolation", "value": "stepAfter"}, + {"id": "custom.lineWidth", "value": 0}, + {"id": "custom.fillOpacity", "value": 8}, + {"id": "min", "value": 0}, + {"id": "max", "value": 1}, + {"id": "decimals", "value": 0}, + {"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "NO"}, "1": {"text": "YES"}}}]} + ] + }, + { + "matcher": {"id": "byName", "options": "Indoor Temp"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "left"}, + {"id": "unit", "value": "celsius"}, + {"id": "color", "value": {"fixedColor": "#FF6D00", "mode": "fixed"}}, + {"id": "custom.lineWidth", "value": 2} + ] + }, + { + "matcher": {"id": "byName", "options": "Outdoor Temp"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "left"}, + {"id": "unit", "value": "celsius"}, + {"id": "color", "value": {"fixedColor": "#2979FF", "mode": "fixed"}}, + {"id": "custom.lineWidth", "value": 2} + ] + }, + { + "matcher": {"id": "byName", "options": "Indoor Dewpoint"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "left"}, + {"id": "unit", "value": "celsius"}, + {"id": "color", "value": {"fixedColor": "#FF6D00", "mode": "fixed"}}, + {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}}, + {"id": "custom.lineWidth", "value": 1} + ] + }, + { + "matcher": {"id": "byName", "options": "Outdoor Dewpoint"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "left"}, + {"id": "unit", "value": "celsius"}, + {"id": "color", "value": {"fixedColor": "#2979FF", "mode": "fixed"}}, + {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}}, + {"id": "custom.lineWidth", "value": 1} + ] + }, + { + "matcher": {"id": "byName", "options": "Indoor Humidity"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "right"}, + {"id": "unit", "value": "percent"}, + {"id": "color", "value": {"fixedColor": "#00BFA5", "mode": "fixed"}}, + {"id": "custom.lineWidth", "value": 2}, + {"id": "custom.fillOpacity", "value": 5} + ] + }, + { + "matcher": {"id": "byName", "options": "Outdoor Humidity"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "right"}, + {"id": "unit", "value": "percent"}, + {"id": "color", "value": {"fixedColor": "#448AFF", "mode": "fixed"}}, + {"id": "custom.lineWidth", "value": 1} + ] + }, + { + "matcher": {"id": "byName", "options": "Mold Threshold"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "right"}, + {"id": "unit", "value": "percent"}, + {"id": "color", "value": {"fixedColor": "#F44336", "mode": "fixed"}}, + {"id": "custom.lineStyle", "value": {"fill": "dash", "dash": [10, 5]}}, + {"id": "custom.lineWidth", "value": 1} + ] } - } + ] }, "options": { - "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull", "min", "max"]}, "tooltip": {"mode": "multi", "sort": "desc"} }, "targets": [ - {"expr": "taupi_dewpoint_celsius_innen", "legendFormat": "Indoor Dewpoint"}, - {"expr": "taupi_dewpoint_celsius_aussen", "legendFormat": "Outdoor Dewpoint"} + {"expr": "taupi_relay_status", "legendFormat": "Fan Active", "refId": "A"}, + {"expr": "taupi_is_critical", "legendFormat": "Mold Critical", "refId": "B"}, + {"expr": "taupi_would_fan_activate", "legendFormat": "Activation Criteria", "refId": "C"}, + {"expr": "taupi_temperature_celsius_innen", "legendFormat": "Indoor Temp", "refId": "D"}, + {"expr": "taupi_temperature_celsius_aussen", "legendFormat": "Outdoor Temp", "refId": "E"}, + {"expr": "taupi_dewpoint_celsius_innen", "legendFormat": "Indoor Dewpoint", "refId": "F"}, + {"expr": "taupi_dewpoint_celsius_aussen", "legendFormat": "Outdoor Dewpoint", "refId": "G"}, + {"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity", "refId": "H"}, + {"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity", "refId": "I"}, + {"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Threshold", "refId": "J"} ] }, + { - "title": "Relative Humidity vs. Mold Threshold", - "type": "timeseries", - "datasource": {"type": "prometheus", "uid": "prometheus"}, - "gridPos": {"h": 8, "w": 24, "x": 0, "y": 14}, - "fieldConfig": { - "defaults": { - "unit": "percent", - "custom": { - "lineWidth": 2, - "fillOpacity": 5, - "spanNulls": true, - "showPoints": "never" - } + "title": "📈 Deep Dive", + "type": "row", + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 16}, + "collapsed": true, + "panels": [ + { + "title": "🌡️ Temperature Differential (Indoor − Outdoor)", + "description": "Positive = cellar warmer than outside (normal). Large delta means the fan can effectively dry the cellar air by exchanging it with drier outside air.", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 17}, + "fieldConfig": { + "defaults": { + "unit": "celsius", + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": true, + "showPoints": "never", + "gradientMode": "scheme", + "thresholdsStyle": {"mode": "area"} + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "blue", "value": null}, + {"color": "green", "value": 0}, + {"color": "orange", "value": 5} + ] + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_temperature_celsius_innen - taupi_temperature_celsius_aussen", "legendFormat": "Temp Delta (In − Out)", "refId": "A"} + ] + }, + { + "title": "💧 Dewpoint Spread (Indoor − Outdoor)", + "description": "When indoor dewpoint exceeds outdoor dewpoint, running the fan helps: outside air can absorb moisture. Negative = fan would make things worse.", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 17}, + "fieldConfig": { + "defaults": { + "unit": "celsius", + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": true, + "showPoints": "never", + "gradientMode": "scheme", + "thresholdsStyle": {"mode": "area"} + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "green", "value": 0}, + {"color": "super-light-green", "value": 3} + ] + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_dewpoint_celsius_innen - taupi_dewpoint_celsius_aussen", "legendFormat": "Dewpoint Delta (In − Out)", "refId": "A"} + ] + }, + { + "title": "💧 Humidity Delta (Indoor − Outdoor)", + "description": "How much more humid the cellar is compared to outside. High positive values indicate the fan can effectively remove moisture.", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 0, "y": 25}, + "fieldConfig": { + "defaults": { + "unit": "percent", + "custom": { + "lineWidth": 2, + "fillOpacity": 10, + "spanNulls": true, + "showPoints": "never", + "gradientMode": "scheme", + "thresholdsStyle": {"mode": "area"} + }, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "blue", "value": null}, + {"color": "green", "value": 0}, + {"color": "orange", "value": 15} + ] + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_humidity_percent_innen - taupi_humidity_percent_aussen", "legendFormat": "Humidity Delta (In − Out)", "refId": "A"} + ] + }, + { + "title": "📉 Indoor Humidity vs Fan Runtime", + "description": "Overlay of indoor humidity (left axis) and fan state (right axis). Shows the direct effect: when the orange fan-on band appears, does the teal humidity line drop?", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 8, "w": 12, "x": 12, "y": 25}, + "fieldConfig": { + "defaults": { + "custom": { + "lineWidth": 2, + "spanNulls": true, + "showPoints": "never" + } + }, + "overrides": [ + { + "matcher": {"id": "byName", "options": "Indoor Humidity"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "left"}, + {"id": "unit", "value": "percent"}, + {"id": "color", "value": {"fixedColor": "#00BFA5", "mode": "fixed"}}, + {"id": "custom.fillOpacity", "value": 10} + ] + }, + { + "matcher": {"id": "byName", "options": "Fan Active"}, + "properties": [ + {"id": "custom.axisPlacement", "value": "right"}, + {"id": "custom.drawStyle", "value": "line"}, + {"id": "custom.lineInterpolation", "value": "stepAfter"}, + {"id": "custom.fillOpacity", "value": 30}, + {"id": "color", "value": {"fixedColor": "orange", "mode": "fixed"}}, + {"id": "min", "value": 0}, + {"id": "max", "value": 1}, + {"id": "decimals", "value": 0}, + {"id": "mappings", "value": [{"type": "value", "options": {"0": {"text": "OFF"}, "1": {"text": "ON"}}}]} + ] + } + ] + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean", "lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity", "refId": "A"}, + {"expr": "taupi_relay_status", "legendFormat": "Fan Active", "refId": "B"} + ] } - }, - "options": { - "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["min", "max", "mean"]}, - "tooltip": {"mode": "multi", "sort": "desc"} - }, - "targets": [ - {"expr": "taupi_humidity_percent_innen", "legendFormat": "Indoor Humidity"}, - {"expr": "taupi_humidity_percent_aussen", "legendFormat": "Outdoor Humidity"}, - {"expr": "taupi_critical_humidity_threshold_percent", "legendFormat": "Mold Danger Threshold"} ] }, + { "title": "🔋 BLE Sensors Diagnostic", "type": "row", - "gridPos": {"h": 1, "w": 24, "x": 0, "y": 22}, - "collapsed": false - }, - { - "title": "Sensor Batteries", - "type": "gauge", - "datasource": {"type": "prometheus", "uid": "prometheus"}, - "gridPos": {"h": 6, "w": 12, "x": 0, "y": 23}, - "fieldConfig": { - "defaults": { - "unit": "percent", - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - {"color": "red", "value": null}, - {"color": "orange", "value": 20}, - {"color": "green", "value": 50} - ] - } + "gridPos": {"h": 1, "w": 24, "x": 0, "y": 17}, + "collapsed": true, + "panels": [ + { + "title": "Sensor Batteries", + "type": "gauge", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 6, "w": 12, "x": 0, "y": 18}, + "fieldConfig": { + "defaults": { + "unit": "percent", + "min": 0, + "max": 100, + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "red", "value": null}, + {"color": "orange", "value": 20}, + {"color": "green", "value": 50} + ] + } + } + }, + "options": { + "reduceOptions": {"values": false, "calcs": ["lastNotNull"]}, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "targets": [ + {"expr": "taupi_battery_percent_innen", "legendFormat": "Indoor Sensor Battery", "refId": "A"}, + {"expr": "taupi_battery_percent_aussen", "legendFormat": "Outdoor Sensor Battery", "refId": "B"} + ] + }, + { + "title": "Sensor Connection Age (Staleness)", + "type": "timeseries", + "datasource": {"type": "prometheus", "uid": "prometheus"}, + "gridPos": {"h": 6, "w": 12, "x": 12, "y": 18}, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "lineWidth": 2, + "fillOpacity": 5, + "spanNulls": true, + "showPoints": "never" + } + } + }, + "options": { + "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull"]}, + "tooltip": {"mode": "multi", "sort": "desc"} + }, + "targets": [ + {"expr": "taupi_lost_connection_seconds_innen", "legendFormat": "Indoor Connection Stale Time", "refId": "A"}, + {"expr": "taupi_lost_connection_seconds_aussen", "legendFormat": "Outdoor Connection Stale Time", "refId": "B"} + ] } - }, - "options": { - "reduceOptions": {"values": false, "calcs": ["lastNotNull"]}, - "showThresholdLabels": false, - "showThresholdMarkers": true - }, - "targets": [ - {"expr": "taupi_battery_percent_innen", "legendFormat": "Indoor Sensor Battery"}, - {"expr": "taupi_battery_percent_aussen", "legendFormat": "Outdoor Sensor Battery"} - ] - }, - { - "title": "Sensor Connection Age (Staleness)", - "type": "timeseries", - "datasource": {"type": "prometheus", "uid": "prometheus"}, - "gridPos": {"h": 6, "w": 12, "x": 12, "y": 23}, - "fieldConfig": { - "defaults": { - "unit": "s", - "custom": { - "lineWidth": 2, - "fillOpacity": 5, - "spanNulls": true, - "showPoints": "never" - } - } - }, - "options": { - "legend": {"displayMode": "table", "placement": "bottom", "calcs": ["lastNotNull"]}, - "tooltip": {"mode": "multi", "sort": "desc"} - }, - "targets": [ - {"expr": "taupi_lost_connection_seconds_innen", "legendFormat": "Indoor Connection Stale Time"}, - {"expr": "taupi_lost_connection_seconds_aussen", "legendFormat": "Outdoor Connection Stale Time"} ] } ]