Dashboards in Grafana; clean up after the Next.js app

- k8s/grafana.yaml: Grafana 13.2.3 with a 1Gi PVC, sign-up and
  anonymous access off. The InfluxDB datasource (URL and token from the
  environment) and three dashboards — Live, Consumption, Charging — are
  provisioned from k8s/grafana/ via kustomize-generated ConfigMaps.
- server.yaml for the Go server (port 8080, UID 65532, smaller limits),
  image tag 2.0.0. The NetworkPolicy now admits Grafana to InfluxDB.
- ingress.yaml: IngressRoutes for car.stinnesbeck.com and Grafana
  (placeholder host car-grafana.stinnesbeck.com) with tls: {}, using
  Traefik's default certificate; the cert-manager Certificate is gone.
- secrets.example.yaml: drops the dashboard secrets, adds grafana-admin.
- AGENTS.md loses its Next.js block; SPECS.md describes the new setup.

The removal of web/ (the Next.js app) and of the stale .next/ cache
upstream had committed already landed in 51e6f6f, which was staged
together with the Go server by mistake.

Checked end to end in podman (Go server + InfluxDB 2.7.12 + Grafana
13.2.3): all 25 dashboard queries return the expected data, and the
dashboards were rendered with headless Chromium. Manifests validated
offline with kustomize and kubeconform. Nothing applied to a cluster.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QLJfZQZeAKAwSqViTmLzki
This commit is contained in:
2026-10-17 15:18:09 +02:00
co-authored by Claude Opus 5.5
parent 51e6f6fd48
commit 5473ab1c2b
14 changed files with 1842 additions and 139 deletions
-10
View File
@@ -120,13 +120,3 @@ a stale Gradle daemon started by VS Code's Java extension is being reused —
Most of this cannot be verified off a vehicle. When reporting work, state plainly what was Most of this cannot be verified off a vehicle. When reporting work, state plainly what was
tested and what was not. "Unit tests pass, not tried on the car" is the expected and tested and what was not. "Unit tests pass, not tried on the car" is the expected and
acceptable answer. Do not imply road-testing that did not happen. acceptable answer. Do not imply road-testing that did not happen.
<!-- BEGIN:nextjs-agent-rules -->
## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+28 -61
View File
@@ -7,76 +7,40 @@ what has been changed, and what is still to do.
- Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader - Upstream: https://github.com/fatihdonmezdev/FatihsMG4 (itself a fork of malys / EVABRPUploader
and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog. and Leon Kernan / ABRP_Uploader). Credited in the app's About dialog.
## Plan: car server at car.stinnesbeck.com ## Architecture: car server at car.stinnesbeck.com
The car sends everything to our own server. The server stores it and forwards live telemetry to The car sends everything to our own server. The server stores it in InfluxDB 2 and forwards live
ABRP, so the ABRP token lives only on the server, never in the APK. telemetry to ABRP, so the ABRP token lives only on the server, never in the APK. Grafana reads
InfluxDB directly for the dashboards.
``` ```
car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.iternio.com/1/tlm/send car ──live telemetry───▶ car.stinnesbeck.com (Go) ──▶ ABRP api.iternio.com/1/tlm/send
──daily totals─────▶ │ ──daily chunks─────▶ │
──charging sessions▶ ▼ ──charging sessions▶ ▼
InfluxDB 2 InfluxDB 2 ◀── Grafana (car-grafana.stinnesbeck.com)
``` ```
### Server — extend `web/` (Next.js) - **Server:** `server/`, Go 1.26, API only — see `server/README.md` for endpoints, configuration
and the storage layout. Replaced the Next.js app from upstream (`web/`, removed 2026-10-17).
- **Storage: MongoDB → InfluxDB 2.** Rewrite `web/src/lib/mongo.ts` / `data.ts` against one bucket - **Dashboards:** Grafana, provisioned from `k8s/grafana/`: Live (stats, SoC/range, power/speed,
(tag `installationId` on every point): route map, temperatures, tyres), Consumption (period totals, lifetime, SoH, per-day energy,
distance and efficiency) and Charging (totals, session table, curves). Prices in euro.
| Measurement | Timestamp | Tags | Fields | - **Car app:** Server tab (URL, ingest token, installation id, config import); live telemetry
|---|---|---|---| every 15 s while driving or charging; consumption chunks and charging sessions as before.
| `telemetry` | sample time | — | soc, power, speed, lat, lon, heading, elevation, is_charging, is_dcfc, is_parked, capacity, soe, odometer, est_battery_range, batt_temp, cabin_temp, ext_temp, hvac_setpoint, voltage, current, tire_pressure_fl/fr/rl/rr | - **Deployment:** `k8s/`, plain manifests. **Nothing is ever applied to the cluster by us** —
| `consumption_chunk` | recordedAt | date | km, kwh, hours, socDrop, sohPercent (days summed on read) | Nils applies them (`k8s/README.md`). TLS comes from Traefik's default certificate.
| `charging_session` | startedAt | sessionId | endedAt, durationSeconds, energyKwh, gridEnergyKwh, pricePerKwh, totalCost, start/endSocPercent |
| `charging_curve` | point time | sessionId | powerKw, socPercent |
Same measurement + tags + timestamp overwrites, so the car's retries stay idempotent
(MongoDB used unique indexes for that).
- **New endpoint `POST /v1/telemetry`:** a batch of live samples, ingest-token auth, validated
and bounded like the existing endpoints.
- **ABRP forwarder:** after storing, forward the newest sample to `tlm/send` with `ABRP_API_KEY` and
`ABRP_USER_TOKEN` from the environment. Rate-limited; a failed forward is logged but not
retried, since stale live data is useless to ABRP. Off when the token is unset.
- **Existing endpoints stay:** `PUT /v1/consumption/daily`, `PUT /v1/charging-sessions/:id`,
`GET /v1/consumption/history/:id`, the dashboard and `/v1/health`.
- **Self-hosting:** `output: "standalone"`, a `Dockerfile`, and InfluxDB's own overwrite semantics
instead of `ensureIndexes()`.
### Car app
- Restore the live telemetry payload and upload cadence from before `ea8032a` (`TelemetryPayload`,
`UploadCadence`, location access), sending to the server instead of ABRP.
- Server URL, ingest token and installation id are entered in a new **Server** tab or imported from
a config file; nothing about the server is compiled into the APK. Each installation generates
its own id.
### Image and deployment
- Image: `git.stinnesbeck.com/nils/mg4-server:<version>`, pushed to the forge's registry
(needs a registry login on the build machine).
- `k8s/` with plain manifests: Namespace, InfluxDB StatefulSet + PVC + Service (cluster-internal),
server Deployment + Service + Ingress for `car.stinnesbeck.com` (TLS), and Secret *templates*
with placeholder values only.
- **Nothing is ever applied to the cluster by us.** Nils applies the manifests.
### Decisions (2026-10-17)
- InfluxDB 2 runs in the cluster from our manifests.
- TLS: cert-manager `ClusterIssuer` `letsencrypt-prod`; Traefik `IngressRoute` on `websecure`.
- Car config is entered in the app (Server tab / config file), not baked into the APK.
- The ABRP API key and user token are set by Nils in the `mg4-server` Secret.
### Status ### Status
- [x] Server on InfluxDB 2 with `/v1/telemetry` and the ABRP forwarder (`web/`), tested locally - [x] Go server 2.0.0: tests and govulncheck clean; image builds (about 11 MB).
against InfluxDB 2.7.12. Image builds. - [x] End-to-end in podman (server + InfluxDB 2.7.12 + Grafana 13.2.3): every endpoint, retry
- [x] Manifests in `k8s/`, validated offline. **Not applied** — that is Nils' step (`k8s/README.md`). idempotency, all 25 dashboard queries return data, dashboards rendered and checked.
- [x] Car app 2.3.0: Server tab with config import, live telemetry upload, cadence controls. - [x] Manifests validated offline (including Traefik CRDs). **Not applied.**
Unit tests and lint pass; **not yet tried on the car**. - [x] Car app 2.3.0: unit tests and lint pass; **not yet tried on the car**.
- [ ] Push the image (`mg4-server:1.1.2`) to git.stinnesbeck.com (needs a registry login on the build machine). - [ ] Push the image (`mg4-server:2.0.0`) to git.stinnesbeck.com (needs a registry login here).
- [ ] Check the Traefik ingress class in `k8s/ingress.yaml` (placeholder `traefik-external`). - [ ] Check in `k8s/ingress.yaml`: the Traefik ingress class (placeholder `traefik-external`) and
- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB and at ABRP. Grafana's host (placeholder `car-grafana.stinnesbeck.com`).
- [ ] First drive with 2.3.0: telemetry arrives in InfluxDB, Grafana and ABRP.
## To do ## To do
@@ -116,6 +80,9 @@ car ──live telemetry───▶ car.stinnesbeck.com ──▶ ABRP api.ite
- 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4. - 2026-10-17 — Accent colour: MG red instead of muted gold. Web app renamed to Nils' MG4.
- 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the - 2026-10-17 — Dashboard translated to English (en-GB formatting); euro instead of lira in the
dashboard and the car app's charging tab. dashboard and the car app's charging tab.
- 2026-10-17 — Server rewritten in Go (API only); dashboards moved to Grafana with provisioned
InfluxDB datasource and three dashboards; the Next.js app (`web/`) removed. Per-host
cert-manager certificates dropped in favour of Traefik's default certificate.
- 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb; - 2026-10-17 — Temporary on-screen diagnostics (b2a5b32) to debug blank values without adb;
removed again (1002962) once values showed on the car. Revert 1002962 to bring them back. removed again (1002962) once values showed on the car. Revert 1002962 to bring them back.
+23 -22
View File
@@ -1,28 +1,30 @@
# Kubernetes manifests — car.stinnesbeck.com # Kubernetes manifests — car.stinnesbeck.com
Plain manifests for the car server and its InfluxDB 2. They are only files: nothing here is Plain manifests for the car server, InfluxDB 2 and Grafana. They are only files: nothing here
applied automatically. Apply them yourself. is applied automatically. Apply them yourself.
| File | What | | File | What |
|---|---| |---|---|
| `namespace.yaml` | Namespace `mg4` | | `namespace.yaml` | Namespace `mg4` |
| `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy allowing only the server | | `influxdb.yaml` | InfluxDB 2.7.12 StatefulSet (10Gi PVC, default storage class), cluster-internal Service, NetworkPolicy admitting only the server and Grafana |
| `server.yaml` | Server Deployment (1 replica, non-root, read-only root filesystem) and Service | | `server.yaml` | Go server Deployment (1 replica, non-root, read-only root filesystem) and Service |
| `ingress.yaml` | cert-manager `Certificate` from `letsencrypt-prod`, Traefik `IngressRoute` on `websecure` | | `grafana.yaml` | Grafana 13.2.3 Deployment (1Gi PVC), Service; datasource and dashboards provisioned |
| `secrets.example.yaml` | **Template** for the two Secrets; not in `kustomization.yaml` | | `grafana/` | The provisioned InfluxDB datasource and the MG4 dashboards (Live, Consumption, Charging) |
| `kustomization.yaml` | Everything except the Secrets; pins the server image tag | | `ingress.yaml` | Traefik `IngressRoute`s for car.stinnesbeck.com and Grafana, TLS from Traefik's default certificate |
| `secrets.example.yaml` | **Template** for the Secrets; not in `kustomization.yaml` |
| `kustomization.yaml` | Everything except the Secrets; pins the server image, generates the Grafana ConfigMaps |
## Before the first apply ## Before the first apply
1. **Check the ingress class** in `ingress.yaml`. It is set to `traefik-external` as a 1. **Check `ingress.yaml`:** the ingress class (placeholder `traefik-external` for the public
placeholder; use the class of your public-facing Traefik. Traefik) and Grafana's host (placeholder `car-grafana.stinnesbeck.com`). Both hosts must be
covered by Traefik's default certificate.
2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every 2. **Create the Secrets.** Copy `secrets.example.yaml` outside the repository, replace every
`CHANGE-ME` (`openssl rand -hex 32` for tokens), then apply the copy: `CHANGE-ME` (`openssl rand -hex 32`), then apply the copy: `influxdb-auth`, `mg4-server`
`influxdb-auth` (InfluxDB admin password and token) and `mg4-server` (ingest token, (ingest token, your ABRP API key and user token) and `grafana-admin`.
session secret, dashboard password, your ABRP API key and user token). 3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create the
3. **Registry access.** If the `mg4-server` package on git.stinnesbeck.com is private, create `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is public,
the `git-stinnesbeck-registry` pull secret (command in `secrets.example.yaml`); if it is delete `imagePullSecrets` from `server.yaml`.
public, delete `imagePullSecrets` from `server.yaml`.
## Apply ## Apply
@@ -32,13 +34,12 @@ kubectl apply -f <your filled-in secrets file>
kubectl apply -k k8s/ kubectl apply -k k8s/
``` ```
The Let's Encrypt HTTP-01 challenge needs car.stinnesbeck.com to resolve to the public
Traefik before the certificate can be issued; check with `kubectl -n mg4 get certificate`.
## Notes ## Notes
- InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret - InfluxDB's setup mode runs only on an empty volume. Changing `admin-token` in the Secret later
later does not change the token inside InfluxDB. does not change the token inside InfluxDB.
- The server uses the InfluxDB admin token. A token scoped to the `mg4` bucket can replace it - The server and Grafana use the InfluxDB admin token. A token scoped to the `mg4` bucket (read
later (`influx auth create --read-bucket … --write-bucket …`). for Grafana, read/write for the server) can replace it later.
- The dashboards are read-only in Grafana's UI. Change the JSON in `grafana/dashboards/`; the
ConfigMap name changes with its content, which rolls Grafana.
- To upgrade the server, push a new image tag and change `newTag` in `kustomization.yaml`. - To upgrade the server, push a new image tag and change `newTag` in `kustomization.yaml`.
+137
View File
@@ -0,0 +1,137 @@
# Grafana for the dashboards. The InfluxDB datasource and the three MG4 dashboards are
# provisioned from k8s/grafana/ (ConfigMaps generated by kustomization.yaml); the dashboards
# are read-only in the UI — change the JSON in git instead.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: grafana-data
namespace: mg4
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: mg4
spec:
accessModes: ["ReadWriteOnce"]
# No storageClassName: the cluster's default class is used.
resources:
requests:
storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: grafana
namespace: mg4
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: mg4
spec:
replicas: 1
strategy:
type: Recreate # the SQLite database on the volume allows one writer
selector:
matchLabels:
app.kubernetes.io/name: grafana
template:
metadata:
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: mg4
spec:
securityContext:
runAsNonRoot: true
runAsUser: 472
runAsGroup: 472
fsGroup: 472
seccompProfile:
type: RuntimeDefault
containers:
- name: grafana
image: docker.io/grafana/grafana:13.2.3
ports:
- name: http
containerPort: 3000
env:
- name: GF_SERVER_ROOT_URL
value: https://car-grafana.stinnesbeck.com
- name: GF_SECURITY_ADMIN_USER
value: admin
- name: GF_SECURITY_ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: grafana-admin
key: admin-password
- name: GF_USERS_ALLOW_SIGN_UP
value: "false"
- name: GF_AUTH_ANONYMOUS_ENABLED
value: "false"
- name: GF_ANALYTICS_REPORTING_ENABLED
value: "false"
- name: GF_ANALYTICS_CHECK_FOR_UPDATES
value: "false"
# Read by datasources.yaml.
- name: INFLUX_URL
value: http://influxdb.mg4.svc:8086
- name: INFLUX_TOKEN
valueFrom:
secretKeyRef:
name: influxdb-auth
key: admin-token
volumeMounts:
- name: data
mountPath: /var/lib/grafana
- name: datasources
mountPath: /etc/grafana/provisioning/datasources
- name: dashboard-provider
mountPath: /etc/grafana/provisioning/dashboards
- name: dashboards
mountPath: /var/lib/grafana/dashboards/mg4
readinessProbe:
httpGet:
path: /api/health
port: http
periodSeconds: 10
livenessProbe:
httpGet:
path: /api/health
port: http
initialDelaySeconds: 30
periodSeconds: 30
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
volumes:
- name: data
persistentVolumeClaim:
claimName: grafana-data
- name: datasources
configMap:
name: grafana-datasources
- name: dashboard-provider
configMap:
name: grafana-dashboard-provider
- name: dashboards
configMap:
name: grafana-dashboards
---
apiVersion: v1
kind: Service
metadata:
name: grafana
namespace: mg4
labels:
app.kubernetes.io/name: grafana
app.kubernetes.io/part-of: mg4
spec:
selector:
app.kubernetes.io/name: grafana
ports:
- name: http
port: 80
targetPort: http
+11
View File
@@ -0,0 +1,11 @@
# Loads the dashboard JSON files in the mounted folder into a "MG4" folder. They are
# managed in git: edits in the UI are not saved back, change the JSON instead.
apiVersion: 1
providers:
- name: mg4
folder: MG4
type: file
disableDeletion: true
allowUiUpdates: false
options:
path: /var/lib/grafana/dashboards/mg4
+517
View File
@@ -0,0 +1,517 @@
{
"uid": "mg4-charging",
"title": "MG4 — Charging",
"description": "DC charging sessions metered by the car. Prices in euro, as entered in the car's Charging tab.",
"tags": [
"mg4"
],
"timezone": "browser",
"editable": false,
"graphTooltip": 1,
"schemaVersion": 39,
"version": 1,
"time": {
"from": "now-90d",
"to": "now"
},
"refresh": "",
"templating": {
"list": [
{
"name": "installationId",
"label": "Car",
"type": "query",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)",
"definition": "installationId tag values",
"refresh": 1,
"includeAll": false,
"multi": false,
"current": {},
"hide": 0,
"sort": 1
}
]
},
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "DC sessions",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"energyKwh\")\n |> group()\n |> count()"
}
],
"fieldConfig": {
"defaults": {
"unit": "none",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 2,
"type": "stat",
"title": "Drawn from grid",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"gridEnergyKwh\")\n |> group()\n |> sum()"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
},
"description": "Net energy into the battery plus 10 % charging loss."
},
{
"id": 3,
"type": "stat",
"title": "Paid",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\" and r._field == \"totalCost\")\n |> group()\n |> sum()"
}
],
"fieldConfig": {
"defaults": {
"unit": "currencyEUR",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 2
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 4,
"type": "stat",
"title": "Peak power",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_curve\" and r.installationId == \"${installationId}\" and r._field == \"powerKw\")\n |> group()\n |> max()\n |> keep(columns: [\"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 5,
"type": "table",
"title": "Sessions",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 24,
"h": 9
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_session\" and r.installationId == \"${installationId}\")\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> group()\n |> map(fn: (r) => ({r with gained: if exists r.endSocPercent and exists r.startSocPercent then r.endSocPercent - r.startSocPercent else 0.0}))\n |> keep(columns: [\"_time\", \"startSocPercent\", \"endSocPercent\", \"gained\", \"energyKwh\", \"gridEnergyKwh\", \"durationSeconds\", \"pricePerKwh\", \"totalCost\"])\n |> sort(columns: [\"_time\"], desc: true)"
}
],
"fieldConfig": {
"defaults": {},
"overrides": [
{
"matcher": {
"id": "byName",
"options": "_time"
},
"properties": [
{
"id": "displayName",
"value": "Start"
},
{
"id": "unit",
"value": "dateTimeAsIso"
}
]
},
{
"matcher": {
"id": "byRegexp",
"options": "startSocPercent|endSocPercent|gained"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "decimals",
"value": 0
}
]
},
{
"matcher": {
"id": "byName",
"options": "startSocPercent"
},
"properties": [
{
"id": "displayName",
"value": "SoC start"
}
]
},
{
"matcher": {
"id": "byName",
"options": "endSocPercent"
},
"properties": [
{
"id": "displayName",
"value": "SoC end"
}
]
},
{
"matcher": {
"id": "byName",
"options": "gained"
},
"properties": [
{
"id": "displayName",
"value": "Charge added"
}
]
},
{
"matcher": {
"id": "byName",
"options": "energyKwh"
},
"properties": [
{
"id": "displayName",
"value": "Net into battery"
},
{
"id": "unit",
"value": "kwatth"
},
{
"id": "decimals",
"value": 2
}
]
},
{
"matcher": {
"id": "byName",
"options": "gridEnergyKwh"
},
"properties": [
{
"id": "displayName",
"value": "From grid (+10%)"
},
{
"id": "unit",
"value": "kwatth"
},
{
"id": "decimals",
"value": 2
}
]
},
{
"matcher": {
"id": "byName",
"options": "durationSeconds"
},
"properties": [
{
"id": "displayName",
"value": "Duration"
},
{
"id": "unit",
"value": "dtdurations"
}
]
},
{
"matcher": {
"id": "byName",
"options": "pricePerKwh"
},
"properties": [
{
"id": "displayName",
"value": "Price"
},
{
"id": "unit",
"value": "currencyEUR"
},
{
"id": "decimals",
"value": 2
}
]
},
{
"matcher": {
"id": "byName",
"options": "totalCost"
},
"properties": [
{
"id": "displayName",
"value": "Cost"
},
{
"id": "unit",
"value": "currencyEUR"
},
{
"id": "decimals",
"value": 2
}
]
}
]
},
"options": {
"showHeader": true,
"cellHeight": "sm"
},
"transformations": [
{
"id": "organize",
"options": {
"indexByName": {
"_time": 0,
"durationSeconds": 1,
"startSocPercent": 2,
"endSocPercent": 3,
"gained": 4,
"energyKwh": 5,
"gridEnergyKwh": 6,
"pricePerKwh": 7,
"totalCost": 8
}
}
}
]
},
{
"id": 6,
"type": "timeseries",
"title": "Charging curves",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 9
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"charging_curve\" and r.installationId == \"${installationId}\" and (r._field == \"powerKw\" or r._field == \"socPercent\"))\n |> group(columns: [\"_field\"])\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])\n |> sort(columns: [\"_time\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"spanNulls": 600000,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": ".*socPercent.*"
},
"properties": [
{
"id": "unit",
"value": "percent"
},
{
"id": "custom.axisPlacement",
"value": "right"
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"description": "Only DC charges above 10 kW are metered. Pick a time range around a session to see its curve."
}
]
}
+415
View File
@@ -0,0 +1,415 @@
{
"uid": "mg4-consumption",
"title": "MG4 — Consumption",
"description": "Daily consumption, summed from the chunks the car sends while driving.",
"tags": [
"mg4"
],
"timezone": "browser",
"editable": false,
"graphTooltip": 1,
"schemaVersion": 39,
"version": 1,
"time": {
"from": "now-30d",
"to": "now"
},
"refresh": "",
"templating": {
"list": [
{
"name": "installationId",
"label": "Car",
"type": "query",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)",
"definition": "installationId tag values",
"refresh": 1,
"includeAll": false,
"multi": false,
"current": {},
"hide": 0,
"sort": 1
}
]
},
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "Distance",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 5,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group()\n |> sum()"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 2,
"type": "stat",
"title": "Energy used",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 5,
"y": 0,
"w": 5,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"kwh\")\n |> group()\n |> sum()"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 2
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 3,
"type": "stat",
"title": "Average",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 10,
"y": 0,
"w": 5,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and (r._field == \"km\" or r._field == \"kwh\"))\n |> group(columns: [\"_field\"])\n |> sum()\n |> group()\n |> pivot(rowKey: [], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> map(fn: (r) => ({efficiency: if r.km >= 1.0 then r.kwh * 100.0 / r.km else 0.0}))"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: kWh/100 km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
},
"description": "kWh/100 km over the selected range."
},
{
"id": 4,
"type": "stat",
"title": "Lifetime distance",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 15,
"y": 0,
"w": 5,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: 0)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group()\n |> sum()"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 5,
"type": "stat",
"title": "Battery health (SoH)",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: 0)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"sohPercent\")\n |> group()\n |> last()\n |> keep(columns: [\"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
},
"description": "The manual SoH entered in the car's Consumption tab."
},
{
"id": 6,
"type": "barchart",
"title": "Energy per day",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"kwh\")\n |> group(columns: [\"date\"])\n |> sum()\n |> group()\n |> map(fn: (r) => ({date: r.date, kwh: r._value}))\n |> sort(columns: [\"date\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
}
},
"overrides": []
},
"options": {
"xField": "date",
"showValue": "never",
"legend": {
"showLegend": false
},
"xTickLabelRotation": -45
}
},
{
"id": 7,
"type": "barchart",
"title": "Distance per day",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and r._field == \"km\")\n |> group(columns: [\"date\"])\n |> sum()\n |> group()\n |> map(fn: (r) => ({date: r.date, km: r._value}))\n |> sort(columns: [\"date\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
}
},
"overrides": []
},
"options": {
"xField": "date",
"showValue": "never",
"legend": {
"showLegend": false
},
"xTickLabelRotation": -45
}
},
{
"id": 8,
"type": "barchart",
"title": "Efficiency per day (kWh/100 km)",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 13,
"w": 24,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"consumption_chunk\" and r.installationId == \"${installationId}\" and (r._field == \"km\" or r._field == \"kwh\"))\n |> group(columns: [\"date\", \"_field\"])\n |> sum()\n |> group()\n |> pivot(rowKey: [\"date\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> filter(fn: (r) => r.km >= 1.0)\n |> map(fn: (r) => ({date: r.date, efficiency: r.kwh * 100.0 / r.km}))\n |> sort(columns: [\"date\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: kWh/100 km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
}
},
"overrides": []
},
"options": {
"xField": "date",
"showValue": "never",
"legend": {
"showLegend": false
},
"xTickLabelRotation": -45
}
}
]
}
+619
View File
@@ -0,0 +1,619 @@
{
"uid": "mg4-live",
"title": "MG4 — Live",
"description": "Live telemetry from the car, as stored before it is forwarded to ABRP.",
"tags": [
"mg4"
],
"timezone": "browser",
"editable": false,
"graphTooltip": 1,
"schemaVersion": 39,
"version": 1,
"time": {
"from": "now-24h",
"to": "now"
},
"refresh": "30s",
"templating": {
"list": [
{
"name": "installationId",
"label": "Car",
"type": "query",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"mg4\", tag: \"installationId\", start: -400d)",
"definition": "installationId tag values",
"refresh": 1,
"includeAll": false,
"multi": false,
"current": {},
"hide": 0,
"sort": 1
}
]
},
"annotations": {
"list": []
},
"panels": [
{
"id": 1,
"type": "stat",
"title": "State of charge",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soc\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 2,
"type": "stat",
"title": "Range",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 4,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"est_battery_range\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 3,
"type": "stat",
"title": "Odometer",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 8,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"odometer\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "suffix: km",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 4,
"type": "stat",
"title": "Battery temperature",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"batt_temp\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 5,
"type": "stat",
"title": "Outside temperature",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 16,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"ext_temp\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 0
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 6,
"type": "stat",
"title": "Energy in pack",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 20,
"y": 0,
"w": 4,
"h": 4
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: -30d)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and r._field == \"soe\")\n |> last()"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatth",
"color": {
"mode": "fixed",
"fixedColor": "#E0424C"
},
"decimals": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
],
"fields": "",
"values": false
},
"colorMode": "value",
"graphMode": "none",
"textMode": "value"
}
},
{
"id": 7,
"type": "timeseries",
"title": "State of charge and range",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"soc\" or r._field == \"est_battery_range\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"spanNulls": 600000,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": ".*est_battery_range.*"
},
"properties": [
{
"id": "unit",
"value": "suffix: km"
},
{
"id": "custom.axisPlacement",
"value": "right"
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 8,
"type": "timeseries",
"title": "Power and speed",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 8
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"power\" or r._field == \"speed\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "kwatt",
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"spanNulls": 600000,
"showPoints": "never"
}
},
"overrides": [
{
"matcher": {
"id": "byRegexp",
"options": ".*speed.*"
},
"properties": [
{
"id": "unit",
"value": "velocitykmh"
},
{
"id": "custom.axisPlacement",
"value": "right"
}
]
}
]
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
},
"description": "Power is positive leaving the battery, negative while charging or regenerating."
},
{
"id": 9,
"type": "geomap",
"title": "Where the car was",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 0,
"y": 12,
"w": 12,
"h": 12
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"lat\" or r._field == \"lon\"))\n |> pivot(rowKey: [\"_time\"], columnKey: [\"_field\"], valueColumn: \"_value\")\n |> keep(columns: [\"_time\", \"lat\", \"lon\"])\n |> sort(columns: [\"_time\"])"
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"view": {
"id": "fit",
"lat": 0,
"lon": 0,
"zoom": 6,
"allLayers": true
},
"controls": {
"showZoom": true,
"mouseWheelZoom": true
},
"basemap": {
"type": "default",
"name": "Basemap"
},
"layers": [
{
"type": "route",
"name": "Drive",
"config": {
"style": {
"color": {
"fixed": "#E0424C"
},
"size": {
"fixed": 3
},
"opacity": 0.9
}
},
"location": {
"mode": "coords",
"latitude": "lat",
"longitude": "lon"
}
}
]
}
},
{
"id": 10,
"type": "timeseries",
"title": "Temperatures",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 12,
"w": 12,
"h": 6
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"batt_temp\" or r._field == \"cabin_temp\" or r._field == \"ext_temp\" or r._field == \"hvac_setpoint\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius",
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"spanNulls": 600000,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"id": 11,
"type": "timeseries",
"title": "Tire pressures",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"gridPos": {
"x": 12,
"y": 18,
"w": 12,
"h": 6
},
"targets": [
{
"refId": "A",
"datasource": {
"type": "influxdb",
"uid": "mg4-influxdb"
},
"query": "from(bucket: \"mg4\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"telemetry\" and r.installationId == \"${installationId}\" and (r._field == \"tire_pressure_fl\" or r._field == \"tire_pressure_fr\" or r._field == \"tire_pressure_rl\" or r._field == \"tire_pressure_rr\"))\n |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false)\n |> keep(columns: [\"_time\", \"_field\", \"_value\"])"
}
],
"fieldConfig": {
"defaults": {
"unit": "pressurekpa",
"custom": {
"lineWidth": 2,
"fillOpacity": 8,
"spanNulls": 600000,
"showPoints": "never"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
]
}
+17
View File
@@ -0,0 +1,17 @@
# Provisioned InfluxDB datasource. URL and token come from the Grafana container's
# environment (INFLUX_URL, INFLUX_TOKEN), so no credential is stored in this file.
apiVersion: 1
datasources:
- name: InfluxDB (mg4)
uid: mg4-influxdb
type: influxdb
access: proxy
url: $INFLUX_URL
isDefault: true
editable: false
jsonData:
version: Flux
organization: mg4
defaultBucket: mg4
secureJsonData:
token: $INFLUX_TOKEN
+6 -2
View File
@@ -110,11 +110,12 @@ spec:
port: 8086 port: 8086
targetPort: http targetPort: http
--- ---
# Only the server may reach InfluxDB. Has no effect on a CNI without NetworkPolicy support. # Only the server and Grafana may reach InfluxDB. Has no effect on a CNI without
# NetworkPolicy support.
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: NetworkPolicy kind: NetworkPolicy
metadata: metadata:
name: influxdb-from-server-only name: influxdb-from-server-and-grafana
namespace: mg4 namespace: mg4
spec: spec:
podSelector: podSelector:
@@ -126,5 +127,8 @@ spec:
- podSelector: - podSelector:
matchLabels: matchLabels:
app.kubernetes.io/name: mg4-server app.kubernetes.io/name: mg4-server
- podSelector:
matchLabels:
app.kubernetes.io/name: grafana
ports: ports:
- port: 8086 - port: 8086
+25 -18
View File
@@ -1,18 +1,5 @@
# TLS certificate from cert-manager. cert-manager does not watch Traefik IngressRoutes, so the # Public routes. TLS uses Traefik's default certificate, so there is no per-host
# certificate is requested explicitly and the IngressRoute references its Secret. # Certificate here.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: car-stinnesbeck-com
namespace: mg4
spec:
secretName: car-stinnesbeck-com-tls
dnsNames:
- car.stinnesbeck.com
issuerRef:
kind: ClusterIssuer
name: letsencrypt-prod
---
apiVersion: traefik.io/v1alpha1 apiVersion: traefik.io/v1alpha1
kind: IngressRoute kind: IngressRoute
metadata: metadata:
@@ -20,7 +7,7 @@ metadata:
namespace: mg4 namespace: mg4
annotations: annotations:
# CHECK: the class of your public-facing Traefik. Your internal example uses # CHECK: the class of your public-facing Traefik. Your internal example uses
# "traefik-internal"; this host must be served by the external one. # "traefik-internal"; these hosts must be served by the external one.
kubernetes.io/ingress.class: traefik-external kubernetes.io/ingress.class: traefik-external
spec: spec:
entryPoints: entryPoints:
@@ -33,5 +20,25 @@ spec:
name: mg4-server name: mg4-server
namespace: mg4 namespace: mg4
port: 80 port: 80
tls: tls: {}
secretName: car-stinnesbeck-com-tls ---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: grafana
namespace: mg4
annotations:
kubernetes.io/ingress.class: traefik-external # CHECK, as above
spec:
entryPoints:
- websecure
routes:
- kind: Rule
# CHECK: placeholder host. It must be covered by Traefik's default certificate.
match: Host(`car-grafana.stinnesbeck.com`)
services:
- kind: Service
name: grafana
namespace: mg4
port: 80
tls: {}
+20 -1
View File
@@ -6,7 +6,26 @@ resources:
- namespace.yaml - namespace.yaml
- influxdb.yaml - influxdb.yaml
- server.yaml - server.yaml
- grafana.yaml
- ingress.yaml - ingress.yaml
images: images:
- name: git.stinnesbeck.com/nils/mg4-server - name: git.stinnesbeck.com/nils/mg4-server
newTag: "1.1.2" newTag: "2.0.0"
generatorOptions:
labels:
app.kubernetes.io/part-of: mg4
configMapGenerator:
- name: grafana-datasources
namespace: mg4
files:
- mg4.yaml=grafana/datasources.yaml
- name: grafana-dashboard-provider
namespace: mg4
files:
- mg4.yaml=grafana/dashboards.yaml
- name: grafana-dashboards
namespace: mg4
files:
- grafana/dashboards/live.json
- grafana/dashboards/consumption.json
- grafana/dashboards/charging.json
+14 -4
View File
@@ -1,7 +1,7 @@
# TEMPLATE ONLY — not part of kustomization.yaml. Copy it outside the repository, fill in # TEMPLATE ONLY — not part of kustomization.yaml. Copy it outside the repository, fill in
# real values, and apply that copy yourself. Never commit the filled-in file. # real values, and apply that copy yourself. Never commit the filled-in file.
# #
# openssl rand -hex 32 for every token and secret below # openssl rand -hex 32 for every token and password below
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -19,15 +19,25 @@ metadata:
namespace: mg4 namespace: mg4
type: Opaque type: Opaque
stringData: stringData:
# The car's write token, entered in the app. At least 24 characters. # The car's write token, entered in the app. At least 24 characters, or the server
# refuses to start.
INGEST_TOKEN: CHANGE-ME-openssl-rand-hex-32 INGEST_TOKEN: CHANGE-ME-openssl-rand-hex-32
SESSION_SECRET: CHANGE-ME-openssl-rand-hex-32
DASHBOARD_PASSWORD: CHANGE-ME-at-least-8-characters
# Your ABRP credentials. Leave either empty to switch forwarding off. # Your ABRP credentials. Leave either empty to switch forwarding off.
ABRP_API_KEY: CHANGE-ME ABRP_API_KEY: CHANGE-ME
ABRP_USER_TOKEN: CHANGE-ME ABRP_USER_TOKEN: CHANGE-ME
ABRP_MIN_INTERVAL_SECONDS: "5" ABRP_MIN_INTERVAL_SECONDS: "5"
--- ---
apiVersion: v1
kind: Secret
metadata:
name: grafana-admin
namespace: mg4
type: Opaque
stringData:
# Grafana's admin login (user "admin"). Only read on first start: change it in Grafana
# afterwards, or reset it with grafana cli.
admin-password: CHANGE-ME
---
# Pull secret for a private image on git.stinnesbeck.com. Easier to create with: # Pull secret for a private image on git.stinnesbeck.com. Easier to create with:
# kubectl -n mg4 create secret docker-registry git-stinnesbeck-registry \ # kubectl -n mg4 create secret docker-registry git-stinnesbeck-registry \
# --docker-server=git.stinnesbeck.com --docker-username=<user> --docker-password=<token> # --docker-server=git.stinnesbeck.com --docker-username=<user> --docker-password=<token>
+10 -21
View File
@@ -1,5 +1,5 @@
# The car server. One replica on purpose: the ABRP rate limit is kept in memory, and two # The car server (Go). One replica on purpose: the ABRP rate limit is kept in memory, and
# replicas would each forward to ABRP at the full rate. # two replicas would each forward to ABRP at the full rate.
apiVersion: apps/v1 apiVersion: apps/v1
kind: Deployment kind: Deployment
metadata: metadata:
@@ -26,8 +26,8 @@ spec:
- name: git-stinnesbeck-registry - name: git-stinnesbeck-registry
securityContext: securityContext:
runAsNonRoot: true runAsNonRoot: true
runAsUser: 10001 runAsUser: 65532
runAsGroup: 10001 runAsGroup: 65532
seccompProfile: seccompProfile:
type: RuntimeDefault type: RuntimeDefault
containers: containers:
@@ -35,7 +35,7 @@ spec:
image: git.stinnesbeck.com/nils/mg4-server:latest # pinned by kustomization.yaml image: git.stinnesbeck.com/nils/mg4-server:latest # pinned by kustomization.yaml
ports: ports:
- name: http - name: http
containerPort: 3000 containerPort: 8080
env: env:
- name: INFLUX_URL - name: INFLUX_URL
value: http://influxdb.mg4.svc:8086 value: http://influxdb.mg4.svc:8086
@@ -48,7 +48,7 @@ spec:
secretKeyRef: secretKeyRef:
name: influxdb-auth name: influxdb-auth
key: admin-token key: admin-token
# INGEST_TOKEN, SESSION_SECRET, DASHBOARD_PASSWORD, ABRP_API_KEY, ABRP_USER_TOKEN. # INGEST_TOKEN, ABRP_API_KEY, ABRP_USER_TOKEN, ABRP_MIN_INTERVAL_SECONDS.
envFrom: envFrom:
- secretRef: - secretRef:
name: mg4-server name: mg4-server
@@ -61,30 +61,19 @@ spec:
httpGet: httpGet:
path: /v1/health path: /v1/health
port: http port: http
initialDelaySeconds: 10 initialDelaySeconds: 5
periodSeconds: 30 periodSeconds: 30
resources: resources:
requests: requests:
cpu: 50m cpu: 10m
memory: 128Mi memory: 32Mi
limits: limits:
memory: 512Mi memory: 128Mi
securityContext: securityContext:
allowPrivilegeEscalation: false allowPrivilegeEscalation: false
readOnlyRootFilesystem: true readOnlyRootFilesystem: true
capabilities: capabilities:
drop: ["ALL"] drop: ["ALL"]
volumeMounts:
# Next.js writes its fetch/image cache here; the rest of the filesystem is read-only.
- name: next-cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: next-cache
emptyDir: {}
- name: tmp
emptyDir: {}
--- ---
apiVersion: v1 apiVersion: v1
kind: Service kind: Service