Build container image in CI, bake in datasets, configure auth via env
Build image / build (push) Successful in 3m18s

- Dockerfile bakes the app and every CSV in data/ into an nginx image
- UI discovers data/*.csv via nginx JSON autoindex and offers a
  dropdown when there is more than one dataset
- docker-entrypoint.d/40-basic-auth.sh creates .htpasswd from
  BASIC_AUTH_USER / BASIC_AUTH_PASSWORD(_FILE) and refuses to start
  without credentials; replaces set-password.sh
- Gitea Actions workflow builds and pushes
  registry.traberph.de/public/interval_viz (latest, sha-*, semver)
- Escape resets the chart zoom

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-26 11:12:12 +02:00
co-authored by Claude Opus 5.5
parent 77a594c3af
commit e70d15f861
17 changed files with 431 additions and 122 deletions
+7
View File
@@ -0,0 +1,7 @@
*
!Dockerfile
!html/
!data/
!nginx/default.conf
!nginx/security-headers.conf
!docker-entrypoint.d/
+3
View File
@@ -0,0 +1,3 @@
BASIC_AUTH_USER=admin
BASIC_AUTH_PASSWORD=change-me
# HEATPUMP_PORT=8080
+47
View File
@@ -0,0 +1,47 @@
name: Build image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
workflow_dispatch:
env:
REGISTRY: registry.traberph.de
IMAGE: registry.traberph.de/public/interval_viz
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- name: Log in to registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Image tags
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,format=short
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-4
View File
@@ -1,5 +1 @@
# secrets
nginx/.htpasswd
# personal data export
history.csv
.env
+11
View File
@@ -0,0 +1,11 @@
FROM nginx:1.29-alpine
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
COPY nginx/security-headers.conf /etc/nginx/security-headers.conf
COPY --chmod=755 docker-entrypoint.d/40-basic-auth.sh /docker-entrypoint.d/
COPY html /usr/share/nginx/html
# every *.csv in data/ becomes selectable in the UI
COPY data /usr/share/nginx/html/data
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/healthz || exit 1
+87 -56
View File
@@ -1,51 +1,34 @@
# Heatpump cycle analysis
A small static web app that visualises when a heat pump compressor is running,
based on a Home Assistant history export (`binary_sensor.*` on/off states).
based on Home Assistant history exports (`binary_sensor.*` on/off states).
- Interactive timeline (pan, zoom, mouse-wheel, quick ranges 6 h / 24 h / 3 d / 7 d)
- Hover an on-phase to see its start, end and duration
- Statistics for the visible range: number of phases, min / max / mean / median
duration, total on-time, duty cycle, mean pause between phases
- Table of all on-phases in the visible range
- Several datasets, selectable from a dropdown
- English / German UI (auto-detected from the browser, switchable, remembered)
- Light / dark mode following the OS setting
- Served by nginx in Docker, protected with HTTP basic auth; no external requests
(Plotly is vendored in `html/vendor/`)
- nginx container with HTTP basic auth configured through environment variables;
no external requests (Plotly is vendored in `html/vendor/`)
## Quick start
## Data
```sh
# 1. put your export next to docker-compose.yml
cp /path/to/export.csv history.csv
Put CSV exports into `data/`. Every `*.csv` in there is baked into the image.
# 2. create the login (prompts for the password)
./set-password.sh admin
- **One file:** it is loaded directly.
- **Several files:** a dropdown in the header lists them alphabetically. The
most recently modified file (or the viewer's last choice) is preselected.
# 3. start
docker compose up -d
```
A different CSV can also be opened ad hoc with **Load CSV…**. It is parsed in
the browser and not uploaded.
Open <http://localhost:8080> and log in.
The file list comes from nginx's JSON directory listing of `/data/`, so nothing
has to be registered anywhere. Add or remove files and rebuild the image.
> Run `set-password.sh` **before** the first `docker compose up`. If
> `nginx/.htpasswd` does not exist, Docker creates an empty directory in its
> place and nginx rejects every login. The script removes that directory
> automatically; restart the container afterwards.
### Configuration
Set via environment or a `.env` file next to `docker-compose.yml`:
| Variable | Default | Meaning |
|-----------------|-----------------|-----------------------------------|
| `HEATPUMP_PORT` | `8080` | Host port |
| `HEATPUMP_CSV` | `./history.csv` | CSV file served as `history.csv` |
Change the password with `./set-password.sh <user>` followed by
`docker compose restart`. Replacing the CSV needs no restart; reload the page.
## CSV format
### CSV format
Home Assistant's history download (*History → ⋮ → Download data*):
@@ -55,9 +38,7 @@ binary_sensor.heatpump_compressor,on,2026-09-17T03:00:19.068Z
binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z
```
Only the `state` and `last_changed` columns are used; column order doesn't
matter. A different CSV can also be opened ad hoc with **Load CSV…**. It is
parsed in the browser and not uploaded.
Only the `state` and `last_changed` columns are used; column order doesn't matter.
### How phases are computed
@@ -69,38 +50,88 @@ parsed in the browser and not uploaded.
- `unknown` / `unavailable` periods are shown as gaps in the timeline.
- All times are shown in the browser's local time zone.
## Container
```sh
docker build -t interval_viz .
docker run -p 8080:80 -e BASIC_AUTH_USER=admin -e BASIC_AUTH_PASSWORD='…' interval_viz
```
| Variable | Meaning |
|----------------------------|----------------------------------------------------------------|
| `BASIC_AUTH_USER` | Login name |
| `BASIC_AUTH_PASSWORD` | Password |
| `BASIC_AUTH_PASSWORD_FILE` | Alternative to `BASIC_AUTH_PASSWORD`: path to a file holding it |
At startup `docker-entrypoint.d/40-basic-auth.sh` writes `/etc/nginx/.htpasswd`
(SHA-512 crypt). Without the variables, an `.htpasswd` mounted at that path is
used instead. If neither is present, the container exits instead of serving
the app without a password.
- **Port:** 80 inside the container (plain HTTP). Terminate TLS in front of it,
since basic auth sends credentials in clear text.
- **Health:** `GET /healthz` returns `ok` without auth. It is also the image's
`HEALTHCHECK`.
### Local run with Compose
```sh
cp .env.example .env # set BASIC_AUTH_USER / BASIC_AUTH_PASSWORD
docker compose up -d --build
```
Open <http://localhost:8080> (port configurable with `HEATPUMP_PORT`).
## CI: build and push
`.gitea/workflows/docker.yml` builds the image with Gitea Actions and pushes it
to the Harbor registry as `registry.traberph.de/public/interval_viz`.
| Trigger | Tags pushed |
|--------------------|-----------------------------------|
| push to `main` | `latest`, `sha-<short>` |
| tag `v1.2.3` | `1.2.3`, `1.2`, `sha-<short>` |
| pull request | build only, nothing pushed |
| manual dispatch | as for the selected ref |
Setup:
1. Repository → Settings → Actions → **Secrets**:
- `REGISTRY_USERNAME`: a Harbor robot account with push permission on the
`public` project, e.g. `robot$public+interval_viz`
- `REGISTRY_TOKEN`: that robot account's secret
2. A runner with Docker access must be registered for the repo, user or
instance (`act_runner` with the Docker socket mounted), and it must provide
the `ubuntu-latest` label.
## Project layout
```
docker-compose.yml nginx container, volumes, healthcheck
set-password.sh writes nginx/.htpasswd (apr1 hash via openssl)
nginx/default.conf basic auth, gzip, CSV alias, /healthz
Dockerfile image: nginx + app + data
docker-entrypoint.d/40-basic-auth.sh creates .htpasswd from env at startup
docker-compose.yml local build & run
.gitea/workflows/docker.yml CI: build & push image
data/ CSV files baked into the image
nginx/default.conf basic auth, gzip, JSON listing of /data/, /healthz
nginx/security-headers.conf CSP and other headers, included per location
html/index.html markup
html/style.css styles (light/dark tokens)
html/i18n.js English / German strings
html/app.js CSV parsing, statistics, chart, UI wiring
html/app.js dataset loading, CSV parsing, statistics, chart
html/vendor/ Plotly 2.35.2 + German locale
```
`nginx/.htpasswd` and `history.csv` are git-ignored.
## Maintenance
## Operations
**Updating Plotly:**
- **Health:** `GET /healthz` (no auth) returns `ok`; used by the Docker
healthcheck.
- **TLS:** the container serves plain HTTP. Basic auth sends credentials in
clear text, so outside a trusted LAN put it behind a TLS-terminating reverse
proxy (Traefik, Caddy, nginx, …).
- **Updating Plotly:**
```sh
v=2.35.2
curl -fLo html/vendor/plotly.min.js https://cdn.jsdelivr.net/npm/plotly.js-dist-min@$v/plotly.min.js
curl -fLo html/vendor/plotly-locale-de.js https://cdn.jsdelivr.net/npm/plotly.js@$v/dist/plotly-locale-de.js
```
```sh
v=2.35.2
curl -fLo html/vendor/plotly.min.js https://cdn.jsdelivr.net/npm/plotly.js-dist-min@$v/plotly.min.js
curl -fLo html/vendor/plotly-locale-de.js https://cdn.jsdelivr.net/npm/plotly.js@$v/dist/plotly-locale-de.js
```
## Adding a language
Add an entry to `I18N` in `html/i18n.js` (copy `en`, translate, set `locale`),
add a `<button data-lang="xx">` in `index.html`, and, for translated chart
controls, vendor `plotly-locale-xx.js` and include it in `index.html`.
**Adding a language:** add an entry to `I18N` in `html/i18n.js` (copy `en`,
translate, set `locale`) and a `<button data-lang="xx">` in `index.html`. For
translated chart controls, also vendor `plotly-locale-xx.js` and include it in
`index.html`.
View File
+71
View File
@@ -0,0 +1,71 @@
entity_id,state,last_changed
binary_sensor.heatpump_compressor,on,2026-09-17T03:00:19.068Z
binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z
binary_sensor.heatpump_compressor,on,2026-09-17T11:26:17.041Z
binary_sensor.heatpump_compressor,off,2026-09-17T11:41:03.220Z
binary_sensor.heatpump_compressor,on,2026-09-18T03:00:22.102Z
binary_sensor.heatpump_compressor,off,2026-09-18T03:19:35.943Z
binary_sensor.heatpump_compressor,on,2026-09-18T10:19:43.304Z
binary_sensor.heatpump_compressor,off,2026-09-18T10:36:39.042Z
binary_sensor.heatpump_compressor,on,2026-09-19T01:20:46.148Z
binary_sensor.heatpump_compressor,off,2026-09-19T01:22:54.097Z
binary_sensor.heatpump_compressor,on,2026-09-19T02:22:51.267Z
binary_sensor.heatpump_compressor,off,2026-09-19T02:28:06.281Z
binary_sensor.heatpump_compressor,on,2026-09-19T03:28:40.219Z
binary_sensor.heatpump_compressor,off,2026-09-19T03:35:46.221Z
binary_sensor.heatpump_compressor,on,2026-09-19T04:41:58.300Z
binary_sensor.heatpump_compressor,off,2026-09-19T04:49:50.417Z
binary_sensor.heatpump_compressor,on,2026-09-19T05:57:54.487Z
binary_sensor.heatpump_compressor,off,2026-09-19T06:43:11.390Z
binary_sensor.heatpump_compressor,on,2026-09-19T09:12:49.606Z
binary_sensor.heatpump_compressor,off,2026-09-19T09:29:53.590Z
binary_sensor.heatpump_compressor,on,2026-09-20T03:39:39.610Z
binary_sensor.heatpump_compressor,off,2026-09-20T03:42:03.650Z
binary_sensor.heatpump_compressor,on,2026-09-20T04:41:55.897Z
binary_sensor.heatpump_compressor,off,2026-09-20T04:47:38.833Z
binary_sensor.heatpump_compressor,on,2026-09-20T05:48:38.700Z
binary_sensor.heatpump_compressor,off,2026-09-20T05:55:45.486Z
binary_sensor.heatpump_compressor,on,2026-09-20T06:00:23.429Z
binary_sensor.heatpump_compressor,off,2026-09-20T06:36:28.105Z
binary_sensor.heatpump_compressor,on,2026-09-20T09:50:15.244Z
binary_sensor.heatpump_compressor,off,2026-09-20T10:01:52.926Z
binary_sensor.heatpump_compressor,on,2026-09-21T03:00:04.587Z
binary_sensor.heatpump_compressor,off,2026-09-21T03:05:11.361Z
binary_sensor.heatpump_compressor,unknown,2026-09-21T08:18:28.438Z
binary_sensor.heatpump_compressor,off,2026-09-21T08:18:30.643Z
binary_sensor.heatpump_compressor,on,2026-09-22T03:00:05.235Z
binary_sensor.heatpump_compressor,off,2026-09-22T03:20:51.957Z
binary_sensor.heatpump_compressor,on,2026-09-22T09:45:24.980Z
binary_sensor.heatpump_compressor,off,2026-09-22T10:05:30.687Z
binary_sensor.heatpump_compressor,on,2026-09-23T03:00:08.414Z
binary_sensor.heatpump_compressor,off,2026-09-23T03:12:46.457Z
binary_sensor.heatpump_compressor,on,2026-09-23T09:45:29.000Z
binary_sensor.heatpump_compressor,off,2026-09-23T10:04:03.857Z
binary_sensor.heatpump_compressor,on,2026-09-24T03:00:09.997Z
binary_sensor.heatpump_compressor,off,2026-09-24T03:11:26.925Z
binary_sensor.heatpump_compressor,on,2026-09-24T12:34:34.499Z
binary_sensor.heatpump_compressor,off,2026-09-24T13:05:30.499Z
binary_sensor.heatpump_compressor,on,2026-09-25T03:00:07.285Z
binary_sensor.heatpump_compressor,off,2026-09-25T03:39:07.324Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:35:58.362Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:36:05.755Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:36:57.698Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:36:58.846Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:41:12.345Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:41:15.133Z
binary_sensor.heatpump_compressor,on,2026-09-25T09:28:41.719Z
binary_sensor.heatpump_compressor,off,2026-09-25T09:54:51.506Z
binary_sensor.heatpump_compressor,on,2026-09-26T00:42:45.260Z
binary_sensor.heatpump_compressor,off,2026-09-26T00:44:28.166Z
binary_sensor.heatpump_compressor,on,2026-09-26T01:18:30.843Z
binary_sensor.heatpump_compressor,off,2026-09-26T01:23:43.918Z
binary_sensor.heatpump_compressor,on,2026-09-26T02:10:50.330Z
binary_sensor.heatpump_compressor,off,2026-09-26T02:18:56.282Z
binary_sensor.heatpump_compressor,on,2026-09-26T03:08:29.828Z
binary_sensor.heatpump_compressor,off,2026-09-26T03:17:03.786Z
binary_sensor.heatpump_compressor,on,2026-09-26T04:07:50.486Z
binary_sensor.heatpump_compressor,off,2026-09-26T04:17:22.353Z
binary_sensor.heatpump_compressor,on,2026-09-26T05:06:08.060Z
binary_sensor.heatpump_compressor,off,2026-09-26T05:16:05.238Z
binary_sensor.heatpump_compressor,on,2026-09-26T06:00:08.484Z
binary_sensor.heatpump_compressor,off,2026-09-26T06:37:50.037Z
1 entity_id state last_changed
2 binary_sensor.heatpump_compressor on 2026-09-17T03:00:19.068Z
3 binary_sensor.heatpump_compressor off 2026-09-17T03:08:01.062Z
4 binary_sensor.heatpump_compressor on 2026-09-17T11:26:17.041Z
5 binary_sensor.heatpump_compressor off 2026-09-17T11:41:03.220Z
6 binary_sensor.heatpump_compressor on 2026-09-18T03:00:22.102Z
7 binary_sensor.heatpump_compressor off 2026-09-18T03:19:35.943Z
8 binary_sensor.heatpump_compressor on 2026-09-18T10:19:43.304Z
9 binary_sensor.heatpump_compressor off 2026-09-18T10:36:39.042Z
10 binary_sensor.heatpump_compressor on 2026-09-19T01:20:46.148Z
11 binary_sensor.heatpump_compressor off 2026-09-19T01:22:54.097Z
12 binary_sensor.heatpump_compressor on 2026-09-19T02:22:51.267Z
13 binary_sensor.heatpump_compressor off 2026-09-19T02:28:06.281Z
14 binary_sensor.heatpump_compressor on 2026-09-19T03:28:40.219Z
15 binary_sensor.heatpump_compressor off 2026-09-19T03:35:46.221Z
16 binary_sensor.heatpump_compressor on 2026-09-19T04:41:58.300Z
17 binary_sensor.heatpump_compressor off 2026-09-19T04:49:50.417Z
18 binary_sensor.heatpump_compressor on 2026-09-19T05:57:54.487Z
19 binary_sensor.heatpump_compressor off 2026-09-19T06:43:11.390Z
20 binary_sensor.heatpump_compressor on 2026-09-19T09:12:49.606Z
21 binary_sensor.heatpump_compressor off 2026-09-19T09:29:53.590Z
22 binary_sensor.heatpump_compressor on 2026-09-20T03:39:39.610Z
23 binary_sensor.heatpump_compressor off 2026-09-20T03:42:03.650Z
24 binary_sensor.heatpump_compressor on 2026-09-20T04:41:55.897Z
25 binary_sensor.heatpump_compressor off 2026-09-20T04:47:38.833Z
26 binary_sensor.heatpump_compressor on 2026-09-20T05:48:38.700Z
27 binary_sensor.heatpump_compressor off 2026-09-20T05:55:45.486Z
28 binary_sensor.heatpump_compressor on 2026-09-20T06:00:23.429Z
29 binary_sensor.heatpump_compressor off 2026-09-20T06:36:28.105Z
30 binary_sensor.heatpump_compressor on 2026-09-20T09:50:15.244Z
31 binary_sensor.heatpump_compressor off 2026-09-20T10:01:52.926Z
32 binary_sensor.heatpump_compressor on 2026-09-21T03:00:04.587Z
33 binary_sensor.heatpump_compressor off 2026-09-21T03:05:11.361Z
34 binary_sensor.heatpump_compressor unknown 2026-09-21T08:18:28.438Z
35 binary_sensor.heatpump_compressor off 2026-09-21T08:18:30.643Z
36 binary_sensor.heatpump_compressor on 2026-09-22T03:00:05.235Z
37 binary_sensor.heatpump_compressor off 2026-09-22T03:20:51.957Z
38 binary_sensor.heatpump_compressor on 2026-09-22T09:45:24.980Z
39 binary_sensor.heatpump_compressor off 2026-09-22T10:05:30.687Z
40 binary_sensor.heatpump_compressor on 2026-09-23T03:00:08.414Z
41 binary_sensor.heatpump_compressor off 2026-09-23T03:12:46.457Z
42 binary_sensor.heatpump_compressor on 2026-09-23T09:45:29.000Z
43 binary_sensor.heatpump_compressor off 2026-09-23T10:04:03.857Z
44 binary_sensor.heatpump_compressor on 2026-09-24T03:00:09.997Z
45 binary_sensor.heatpump_compressor off 2026-09-24T03:11:26.925Z
46 binary_sensor.heatpump_compressor on 2026-09-24T12:34:34.499Z
47 binary_sensor.heatpump_compressor off 2026-09-24T13:05:30.499Z
48 binary_sensor.heatpump_compressor on 2026-09-25T03:00:07.285Z
49 binary_sensor.heatpump_compressor off 2026-09-25T03:39:07.324Z
50 binary_sensor.heatpump_compressor unknown 2026-09-25T08:35:58.362Z
51 binary_sensor.heatpump_compressor off 2026-09-25T08:36:05.755Z
52 binary_sensor.heatpump_compressor unknown 2026-09-25T08:36:57.698Z
53 binary_sensor.heatpump_compressor off 2026-09-25T08:36:58.846Z
54 binary_sensor.heatpump_compressor unknown 2026-09-25T08:41:12.345Z
55 binary_sensor.heatpump_compressor off 2026-09-25T08:41:15.133Z
56 binary_sensor.heatpump_compressor on 2026-09-25T09:28:41.719Z
57 binary_sensor.heatpump_compressor off 2026-09-25T09:54:51.506Z
58 binary_sensor.heatpump_compressor on 2026-09-26T00:42:45.260Z
59 binary_sensor.heatpump_compressor off 2026-09-26T00:44:28.166Z
60 binary_sensor.heatpump_compressor on 2026-09-26T01:18:30.843Z
61 binary_sensor.heatpump_compressor off 2026-09-26T01:23:43.918Z
62 binary_sensor.heatpump_compressor on 2026-09-26T02:10:50.330Z
63 binary_sensor.heatpump_compressor off 2026-09-26T02:18:56.282Z
64 binary_sensor.heatpump_compressor on 2026-09-26T03:08:29.828Z
65 binary_sensor.heatpump_compressor off 2026-09-26T03:17:03.786Z
66 binary_sensor.heatpump_compressor on 2026-09-26T04:07:50.486Z
67 binary_sensor.heatpump_compressor off 2026-09-26T04:17:22.353Z
68 binary_sensor.heatpump_compressor on 2026-09-26T05:06:08.060Z
69 binary_sensor.heatpump_compressor off 2026-09-26T05:16:05.238Z
70 binary_sensor.heatpump_compressor on 2026-09-26T06:00:08.484Z
71 binary_sensor.heatpump_compressor off 2026-09-26T06:37:50.037Z
+71
View File
@@ -0,0 +1,71 @@
entity_id,state,last_changed
binary_sensor.heatpump_compressor,on,2026-09-17T03:00:19.068Z
binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z
binary_sensor.heatpump_compressor,on,2026-09-17T11:26:17.041Z
binary_sensor.heatpump_compressor,off,2026-09-17T11:41:03.220Z
binary_sensor.heatpump_compressor,on,2026-09-18T03:00:22.102Z
binary_sensor.heatpump_compressor,off,2026-09-18T03:19:35.943Z
binary_sensor.heatpump_compressor,on,2026-09-18T10:19:43.304Z
binary_sensor.heatpump_compressor,off,2026-09-18T10:36:39.042Z
binary_sensor.heatpump_compressor,on,2026-09-19T01:20:46.148Z
binary_sensor.heatpump_compressor,off,2026-09-19T01:22:54.097Z
binary_sensor.heatpump_compressor,on,2026-09-19T02:22:51.267Z
binary_sensor.heatpump_compressor,off,2026-09-19T02:28:06.281Z
binary_sensor.heatpump_compressor,on,2026-09-19T03:28:40.219Z
binary_sensor.heatpump_compressor,off,2026-09-19T03:35:46.221Z
binary_sensor.heatpump_compressor,on,2026-09-19T04:41:58.300Z
binary_sensor.heatpump_compressor,off,2026-09-19T04:49:50.417Z
binary_sensor.heatpump_compressor,on,2026-09-19T05:57:54.487Z
binary_sensor.heatpump_compressor,off,2026-09-19T06:43:11.390Z
binary_sensor.heatpump_compressor,on,2026-09-19T09:12:49.606Z
binary_sensor.heatpump_compressor,off,2026-09-19T09:29:53.590Z
binary_sensor.heatpump_compressor,on,2026-09-20T03:39:39.610Z
binary_sensor.heatpump_compressor,off,2026-09-20T03:42:03.650Z
binary_sensor.heatpump_compressor,on,2026-09-20T04:41:55.897Z
binary_sensor.heatpump_compressor,off,2026-09-20T04:47:38.833Z
binary_sensor.heatpump_compressor,on,2026-09-20T05:48:38.700Z
binary_sensor.heatpump_compressor,off,2026-09-20T05:55:45.486Z
binary_sensor.heatpump_compressor,on,2026-09-20T06:00:23.429Z
binary_sensor.heatpump_compressor,off,2026-09-20T06:36:28.105Z
binary_sensor.heatpump_compressor,on,2026-09-20T09:50:15.244Z
binary_sensor.heatpump_compressor,off,2026-09-20T10:01:52.926Z
binary_sensor.heatpump_compressor,on,2026-09-21T03:00:04.587Z
binary_sensor.heatpump_compressor,off,2026-09-21T03:05:11.361Z
binary_sensor.heatpump_compressor,unknown,2026-09-21T08:18:28.438Z
binary_sensor.heatpump_compressor,off,2026-09-21T08:18:30.643Z
binary_sensor.heatpump_compressor,on,2026-09-22T03:00:05.235Z
binary_sensor.heatpump_compressor,off,2026-09-22T03:20:51.957Z
binary_sensor.heatpump_compressor,on,2026-09-22T09:45:24.980Z
binary_sensor.heatpump_compressor,off,2026-09-22T10:05:30.687Z
binary_sensor.heatpump_compressor,on,2026-09-23T03:00:08.414Z
binary_sensor.heatpump_compressor,off,2026-09-23T03:12:46.457Z
binary_sensor.heatpump_compressor,on,2026-09-23T09:45:29.000Z
binary_sensor.heatpump_compressor,off,2026-09-23T10:04:03.857Z
binary_sensor.heatpump_compressor,on,2026-09-24T03:00:09.997Z
binary_sensor.heatpump_compressor,off,2026-09-24T03:11:26.925Z
binary_sensor.heatpump_compressor,on,2026-09-24T12:34:34.499Z
binary_sensor.heatpump_compressor,off,2026-09-24T13:05:30.499Z
binary_sensor.heatpump_compressor,on,2026-09-25T03:00:07.285Z
binary_sensor.heatpump_compressor,off,2026-09-25T03:39:07.324Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:35:58.362Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:36:05.755Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:36:57.698Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:36:58.846Z
binary_sensor.heatpump_compressor,unknown,2026-09-25T08:41:12.345Z
binary_sensor.heatpump_compressor,off,2026-09-25T08:41:15.133Z
binary_sensor.heatpump_compressor,on,2026-09-25T09:28:41.719Z
binary_sensor.heatpump_compressor,off,2026-09-25T09:54:51.506Z
binary_sensor.heatpump_compressor,on,2026-09-26T00:42:45.260Z
binary_sensor.heatpump_compressor,off,2026-09-26T00:44:28.166Z
binary_sensor.heatpump_compressor,on,2026-09-26T01:18:30.843Z
binary_sensor.heatpump_compressor,off,2026-09-26T01:23:43.918Z
binary_sensor.heatpump_compressor,on,2026-09-26T02:10:50.330Z
binary_sensor.heatpump_compressor,off,2026-09-26T02:18:56.282Z
binary_sensor.heatpump_compressor,on,2026-09-26T03:08:29.828Z
binary_sensor.heatpump_compressor,off,2026-09-26T03:17:03.786Z
binary_sensor.heatpump_compressor,on,2026-09-26T04:07:50.486Z
binary_sensor.heatpump_compressor,off,2026-09-26T04:17:22.353Z
binary_sensor.heatpump_compressor,on,2026-09-26T05:06:08.060Z
binary_sensor.heatpump_compressor,off,2026-09-26T05:16:05.238Z
binary_sensor.heatpump_compressor,on,2026-09-26T06:00:08.484Z
binary_sensor.heatpump_compressor,off,2026-09-26T06:37:50.037Z
1 entity_id state last_changed
2 binary_sensor.heatpump_compressor on 2026-09-17T03:00:19.068Z
3 binary_sensor.heatpump_compressor off 2026-09-17T03:08:01.062Z
4 binary_sensor.heatpump_compressor on 2026-09-17T11:26:17.041Z
5 binary_sensor.heatpump_compressor off 2026-09-17T11:41:03.220Z
6 binary_sensor.heatpump_compressor on 2026-09-18T03:00:22.102Z
7 binary_sensor.heatpump_compressor off 2026-09-18T03:19:35.943Z
8 binary_sensor.heatpump_compressor on 2026-09-18T10:19:43.304Z
9 binary_sensor.heatpump_compressor off 2026-09-18T10:36:39.042Z
10 binary_sensor.heatpump_compressor on 2026-09-19T01:20:46.148Z
11 binary_sensor.heatpump_compressor off 2026-09-19T01:22:54.097Z
12 binary_sensor.heatpump_compressor on 2026-09-19T02:22:51.267Z
13 binary_sensor.heatpump_compressor off 2026-09-19T02:28:06.281Z
14 binary_sensor.heatpump_compressor on 2026-09-19T03:28:40.219Z
15 binary_sensor.heatpump_compressor off 2026-09-19T03:35:46.221Z
16 binary_sensor.heatpump_compressor on 2026-09-19T04:41:58.300Z
17 binary_sensor.heatpump_compressor off 2026-09-19T04:49:50.417Z
18 binary_sensor.heatpump_compressor on 2026-09-19T05:57:54.487Z
19 binary_sensor.heatpump_compressor off 2026-09-19T06:43:11.390Z
20 binary_sensor.heatpump_compressor on 2026-09-19T09:12:49.606Z
21 binary_sensor.heatpump_compressor off 2026-09-19T09:29:53.590Z
22 binary_sensor.heatpump_compressor on 2026-09-20T03:39:39.610Z
23 binary_sensor.heatpump_compressor off 2026-09-20T03:42:03.650Z
24 binary_sensor.heatpump_compressor on 2026-09-20T04:41:55.897Z
25 binary_sensor.heatpump_compressor off 2026-09-20T04:47:38.833Z
26 binary_sensor.heatpump_compressor on 2026-09-20T05:48:38.700Z
27 binary_sensor.heatpump_compressor off 2026-09-20T05:55:45.486Z
28 binary_sensor.heatpump_compressor on 2026-09-20T06:00:23.429Z
29 binary_sensor.heatpump_compressor off 2026-09-20T06:36:28.105Z
30 binary_sensor.heatpump_compressor on 2026-09-20T09:50:15.244Z
31 binary_sensor.heatpump_compressor off 2026-09-20T10:01:52.926Z
32 binary_sensor.heatpump_compressor on 2026-09-21T03:00:04.587Z
33 binary_sensor.heatpump_compressor off 2026-09-21T03:05:11.361Z
34 binary_sensor.heatpump_compressor unknown 2026-09-21T08:18:28.438Z
35 binary_sensor.heatpump_compressor off 2026-09-21T08:18:30.643Z
36 binary_sensor.heatpump_compressor on 2026-09-22T03:00:05.235Z
37 binary_sensor.heatpump_compressor off 2026-09-22T03:20:51.957Z
38 binary_sensor.heatpump_compressor on 2026-09-22T09:45:24.980Z
39 binary_sensor.heatpump_compressor off 2026-09-22T10:05:30.687Z
40 binary_sensor.heatpump_compressor on 2026-09-23T03:00:08.414Z
41 binary_sensor.heatpump_compressor off 2026-09-23T03:12:46.457Z
42 binary_sensor.heatpump_compressor on 2026-09-23T09:45:29.000Z
43 binary_sensor.heatpump_compressor off 2026-09-23T10:04:03.857Z
44 binary_sensor.heatpump_compressor on 2026-09-24T03:00:09.997Z
45 binary_sensor.heatpump_compressor off 2026-09-24T03:11:26.925Z
46 binary_sensor.heatpump_compressor on 2026-09-24T12:34:34.499Z
47 binary_sensor.heatpump_compressor off 2026-09-24T13:05:30.499Z
48 binary_sensor.heatpump_compressor on 2026-09-25T03:00:07.285Z
49 binary_sensor.heatpump_compressor off 2026-09-25T03:39:07.324Z
50 binary_sensor.heatpump_compressor unknown 2026-09-25T08:35:58.362Z
51 binary_sensor.heatpump_compressor off 2026-09-25T08:36:05.755Z
52 binary_sensor.heatpump_compressor unknown 2026-09-25T08:36:57.698Z
53 binary_sensor.heatpump_compressor off 2026-09-25T08:36:58.846Z
54 binary_sensor.heatpump_compressor unknown 2026-09-25T08:41:12.345Z
55 binary_sensor.heatpump_compressor off 2026-09-25T08:41:15.133Z
56 binary_sensor.heatpump_compressor on 2026-09-25T09:28:41.719Z
57 binary_sensor.heatpump_compressor off 2026-09-25T09:54:51.506Z
58 binary_sensor.heatpump_compressor on 2026-09-26T00:42:45.260Z
59 binary_sensor.heatpump_compressor off 2026-09-26T00:44:28.166Z
60 binary_sensor.heatpump_compressor on 2026-09-26T01:18:30.843Z
61 binary_sensor.heatpump_compressor off 2026-09-26T01:23:43.918Z
62 binary_sensor.heatpump_compressor on 2026-09-26T02:10:50.330Z
63 binary_sensor.heatpump_compressor off 2026-09-26T02:18:56.282Z
64 binary_sensor.heatpump_compressor on 2026-09-26T03:08:29.828Z
65 binary_sensor.heatpump_compressor off 2026-09-26T03:17:03.786Z
66 binary_sensor.heatpump_compressor on 2026-09-26T04:07:50.486Z
67 binary_sensor.heatpump_compressor off 2026-09-26T04:17:22.353Z
68 binary_sensor.heatpump_compressor on 2026-09-26T05:06:08.060Z
69 binary_sensor.heatpump_compressor off 2026-09-26T05:16:05.238Z
70 binary_sensor.heatpump_compressor on 2026-09-26T06:00:08.484Z
71 binary_sensor.heatpump_compressor off 2026-09-26T06:37:50.037Z
+5 -12
View File
@@ -1,17 +1,10 @@
services:
heatpump:
image: nginx:1.29-alpine
build: .
image: registry.traberph.de/public/interval_viz:latest
restart: unless-stopped
ports:
- "${HEATPUMP_PORT:-8080}:80"
volumes:
- ./html:/usr/share/nginx/html:ro
- ${HEATPUMP_CSV:-./history.csv}:/data/history.csv:ro
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./nginx/security-headers.conf:/etc/nginx/security-headers.conf:ro
- ./nginx/.htpasswd:/etc/nginx/.htpasswd:ro
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1/healthz"]
interval: 30s
timeout: 5s
retries: 3
environment:
BASIC_AUTH_USER: dev
BASIC_AUTH_PASSWORD: dev
+35
View File
@@ -0,0 +1,35 @@
#!/bin/sh
# Creates the basic-auth file for nginx from environment variables.
#
# BASIC_AUTH_USER user name
# BASIC_AUTH_PASSWORD password, or
# BASIC_AUTH_PASSWORD_FILE path to a file containing the password (e.g. a mounted secret)
#
# Without these variables an already mounted /etc/nginx/.htpasswd is used.
# If neither exists the container refuses to start, so the app is never served unprotected.
set -eu
HTPASSWD=/etc/nginx/.htpasswd
ME=$(basename "$0")
if [ -n "${BASIC_AUTH_PASSWORD_FILE:-}" ]; then
[ -r "$BASIC_AUTH_PASSWORD_FILE" ] || { echo "$ME: cannot read $BASIC_AUTH_PASSWORD_FILE" >&2; exit 1; }
BASIC_AUTH_PASSWORD=$(cat "$BASIC_AUTH_PASSWORD_FILE")
fi
if [ -n "${BASIC_AUTH_USER:-}" ] || [ -n "${BASIC_AUTH_PASSWORD:-}" ]; then
: "${BASIC_AUTH_USER:?$ME: BASIC_AUTH_USER is not set}"
: "${BASIC_AUTH_PASSWORD:?$ME: BASIC_AUTH_PASSWORD is empty}"
case "$BASIC_AUTH_USER" in *:*) echo "$ME: BASIC_AUTH_USER must not contain ':'" >&2; exit 1 ;; esac
# SHA-512 crypt; password is passed on stdin so it never shows up in the process list
hash=$(printf '%s' "$BASIC_AUTH_PASSWORD" | mkpasswd -m sha512 -P 0)
printf '%s:%s\n' "$BASIC_AUTH_USER" "$hash" > "$HTPASSWD"
chmod 644 "$HTPASSWD"
echo "$ME: basic auth configured for user '$BASIC_AUTH_USER'"
elif [ -s "$HTPASSWD" ]; then
echo "$ME: using existing $HTPASSWD"
else
echo "$ME: no credentials - set BASIC_AUTH_USER and BASIC_AUTH_PASSWORD (or mount $HTPASSWD)" >&2
exit 1
fi
+70 -14
View File
@@ -1,6 +1,8 @@
'use strict';
const LANG_KEY = 'heatpump.lang';
const DATASET_KEY = 'heatpump.dataset';
const DATA_DIR = 'data/';
const HOUR = 3600e3;
const state = {
@@ -10,7 +12,9 @@ const state = {
dataMin: 0,
dataMax: 0,
range: null, // [from, to] in ms, or null = everything
source: '',
datasets: [], // CSV file names in data/
source: '', // name of the loaded dataset or local file
localFile: false, // source was opened via "Load CSV…"
error: null, // { key, params } so it can be re-rendered on language change
};
@@ -42,6 +46,7 @@ function setLang(lang) {
if (el.dataset.i18n === 'note') el.innerHTML = t('note');
else el.textContent = t(el.dataset.i18n);
});
$('dataset').setAttribute('aria-label', t('dataset'));
document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
render();
}
@@ -119,11 +124,11 @@ function parseCsv(text) {
return { events, phases };
}
function load(text, source) {
function load(text, source, localFile = false) {
try {
const { events, phases } = parseCsv(text);
Object.assign(state, {
events, phases, source, error: null, range: null,
events, phases, source, localFile, error: null, range: null,
dataMin: events[0].t, dataMax: events[events.length - 1].t,
});
} catch (err) {
@@ -157,12 +162,14 @@ const tilesHtml = tiles => tiles.map(([key, value, cls = '']) =>
`<div class="tile"><div class="k">${escapeHtml(t(key))}</div><div class="v ${cls}">${escapeHtml(value)}</div></div>`).join('');
function render() {
renderDatasetSelect();
const src = $('source');
if (state.error) {
src.textContent = t(state.error.key, state.error.params);
src.className = 'sub error';
} else {
src.textContent = state.source || t('loading');
// with a dropdown the selected entry already names the source
src.textContent = $('dataset').hidden ? state.source || t('loading') : '';
src.className = 'sub';
}
if (!state.events.length) return;
@@ -178,6 +185,17 @@ function render() {
renderStats();
}
function renderDatasetSelect() {
const select = $('dataset');
select.hidden = state.datasets.length < 2;
if (select.hidden) return;
const option = (value, label, extra = '') =>
`<option value="${escapeHtml(value)}"${extra}>${escapeHtml(label)}</option>`;
select.innerHTML = state.datasets.map(name => option(name, name)).join('') +
(state.localFile ? option('', `${t('localFile')}: ${state.source}`, ' disabled') : '');
select.value = state.localFile ? '' : state.source;
}
function drawChart() {
const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line');
@@ -283,20 +301,58 @@ document.querySelectorAll('[data-span]').forEach(btn => btn.addEventListener('cl
$('file').addEventListener('change', async e => {
const file = e.target.files[0];
e.target.value = ''; // allow re-selecting the same file
if (file) load(await file.text(), file.name);
if (file) load(await file.text(), file.name, true);
});
// Escape resets the zoom, like a double-click in the chart
document.addEventListener('keydown', e => {
if (e.key !== 'Escape' || e.defaultPrevented || !state.range) return;
if (e.target.closest('select, input')) return; // let open controls handle their own Escape
setRange(null);
});
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
setLang(detectLang());
$('dataset').addEventListener('change', e => loadDataset(e.target.value));
fetch('history.csv', { cache: 'no-cache' })
.then(r => {
async function loadDataset(name) {
try {
const r = await fetch(DATA_DIR + encodeURIComponent(name), { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(text => load(text, 'history.csv'))
.catch(err => {
state.error = { key: 'errFetch', params: { status: err.message } };
load(await r.text(), name);
// remember only selections that actually loaded
if (!state.error) try { localStorage.setItem(DATASET_KEY, name); } catch { /* storage unavailable */ }
} catch (err) {
state.error = { key: 'errFetch', params: { file: name, status: err.message } };
render();
});
}
}
// data/ is served as an nginx JSON directory listing: [{ name, type, mtime, size }, …]
async function init() {
setLang(detectLang());
let files = [];
try {
const r = await fetch(DATA_DIR, { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
files = (await r.json())
.filter(f => f.type === 'file' && /\.csv$/i.test(f.name))
.sort((a, b) => a.name.localeCompare(b.name));
} catch (err) {
state.error = { key: 'errFetch', params: { file: DATA_DIR, status: err.message } };
return render();
}
if (!files.length) {
state.error = { key: 'errNoData' };
return render();
}
state.datasets = files.map(f => f.name);
// last selection if still present, otherwise the most recently modified file
let saved = null;
try { saved = localStorage.getItem(DATASET_KEY); } catch { /* storage unavailable */ }
const newest = files.reduce((a, b) => Date.parse(b.mtime) > Date.parse(a.mtime) ? b : a);
loadDataset(state.datasets.includes(saved) ? saved : newest.name);
}
init();
+10 -4
View File
@@ -33,9 +33,12 @@ const I18N = {
units: { d: 'd', h: 'h', m: 'm', s: 's' },
errColumns: 'CSV needs the columns "state" and "last_changed".',
errEmpty: 'CSV contains no valid rows.',
errFetch: 'Could not load history.csv ({status}) – use “Load CSV…”.',
dataset: 'Dataset',
localFile: 'Local file',
errFetch: 'Could not load {file} ({status}) – use “Load CSV…”.',
errNoData: 'No CSV files found in data/ – use “Load CSV…”.',
note: 'Hover a phase to see its duration. Drag to zoom, shift+drag or the pan tool to pan, ' +
'mouse wheel to zoom, double-click to reset. Statistics use the on-phases that <em>start</em> ' +
'mouse wheel to zoom, double-click or Esc to reset. Statistics use the on-phases that <em>start</em> ' +
'within the visible range; total on-time and duty cycle are clipped to the range. ' +
'A phase ends at the first state that is not "on" (off, unknown, unavailable); ' +
'a phase still running at the end of the data is excluded. Times are shown in your local time zone.',
@@ -71,9 +74,12 @@ const I18N = {
units: { d: 'T', h: 'h', m: 'min', s: 's' },
errColumns: 'Die CSV benötigt die Spalten "state" und "last_changed".',
errEmpty: 'Die CSV enthält keine gültigen Zeilen.',
errFetch: 'history.csv konnte nicht geladen werden ({status}) – bitte „CSV laden…“ verwenden.',
dataset: 'Datensatz',
localFile: 'Lokale Datei',
errFetch: '{file} konnte nicht geladen werden ({status}) – bitte „CSV laden…“ verwenden.',
errNoData: 'Keine CSV-Dateien in data/ gefunden – bitte „CSV laden…“ verwenden.',
note: 'Für die Dauer über eine Phase fahren. Ziehen zum Zoomen, Umschalt+Ziehen oder das Verschieben-Werkzeug ' +
'zum Verschieben, Mausrad zum Zoomen, Doppelklick zum Zurücksetzen. Die Statistik berücksichtigt Laufphasen, ' +
'zum Verschieben, Mausrad zum Zoomen, Doppelklick oder Esc zum Zurücksetzen. Die Statistik berücksichtigt Laufphasen, ' +
'die im sichtbaren Zeitraum <em>beginnen</em>; Laufzeit gesamt und Einschaltanteil werden auf den Zeitraum begrenzt. ' +
'Eine Phase endet beim ersten Zustand ungleich „on“ (off, unknown, unavailable); ' +
'eine am Datenende noch laufende Phase wird nicht gezählt. Zeiten in lokaler Zeitzone.',
+2 -1
View File
@@ -15,7 +15,8 @@
<header>
<h1 data-i18n="title">Heatpump compressor cycles</h1>
<div class="header-right">
<span class="sub" id="source" data-i18n="loading">loading…</span>
<select id="dataset" hidden aria-label="Dataset"></select>
<span class="sub" id="source"></span>
<div class="lang" role="group" aria-label="Language">
<button type="button" data-lang="en">EN</button>
<button type="button" data-lang="de">DE</button>
+2 -2
View File
@@ -26,11 +26,11 @@ h2 {
.sub { color: var(--ink-2); font-size: 13px; }
.error { color: var(--error); }
button, label.file {
button, label.file, select {
font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
}
button:hover, label.file:hover { border-color: var(--accent); }
button:hover, label.file:hover, select:hover { border-color: var(--accent); }
label.file:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
label.file input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+5 -4
View File
@@ -9,7 +9,7 @@ server {
gzip on;
gzip_comp_level 6;
gzip_types text/css text/csv application/javascript;
gzip_types text/css text/csv application/javascript application/json;
auth_basic "Heatpump";
auth_basic_user_file /etc/nginx/.htpasswd;
@@ -19,9 +19,10 @@ server {
include /etc/nginx/security-headers.conf;
}
# the CSV is mounted separately so it can be replaced without touching the app
location = /history.csv {
alias /data/history.csv;
# data/ is listed as JSON so the UI can discover the CSV files
location /data/ {
autoindex on;
autoindex_format json;
default_type text/csv;
include /etc/nginx/security-headers.conf;
}
-20
View File
@@ -1,20 +0,0 @@
#!/bin/sh
# Creates nginx/.htpasswd for HTTP basic auth.
# Usage: ./set-password.sh <user> [password] (prompts for the password if omitted)
set -eu
user="${1:?usage: $0 <user> [password]}"
case "$user" in *:*) echo "user name must not contain ':'" >&2; exit 1 ;; esac
if [ $# -ge 2 ]; then
hash=$(openssl passwd -apr1 "$2")
else
hash=$(openssl passwd -apr1)
fi
file="$(dirname "$0")/nginx/.htpasswd"
[ -d "$file" ] && rmdir "$file" # docker creates a directory if the file was missing on first start
umask 077
printf '%s:%s\n' "$user" "$hash" > "$file"
chmod 644 "$file" # nginx workers must be able to read it
echo "Wrote $file for user '$user'. Run 'docker compose restart' if the container is already running."