traberphandClaude Opus 5.5 e70d15f861
Build image / build (push) Successful in 3m18s
Build container image in CI, bake in datasets, configure auth via env
- 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>
2026-09-26 11:12:12 +02:00

Heatpump cycle analysis

A small static web app that visualises when a heat pump compressor is running, 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
  • nginx container with HTTP basic auth configured through environment variables; no external requests (Plotly is vendored in html/vendor/)

Data

Put CSV exports into data/. Every *.csv in there is baked into the image.

  • 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.

A different CSV can also be opened ad hoc with Load CSV…. It is parsed in the browser and not uploaded.

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.

CSV format

Home Assistant's history download (History → ⋮ → Download data):

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

Only the state and last_changed columns are used; column order doesn't matter.

How phases are computed

  • An on-phase starts at the first on and ends at the next state that is not on (off, unknown, unavailable, …). Repeated on rows are merged.
  • A phase still running at the end of the data is not counted.
  • Statistics include the phases that start inside the visible range. Total on-time and duty cycle are clipped to the range.
  • unknown / unavailable periods are shown as gaps in the timeline.
  • All times are shown in the browser's local time zone.

Container

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

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

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                         dataset loading, CSV parsing, statistics, chart
html/vendor/                        Plotly 2.35.2 + German locale

Maintenance

Updating Plotly:

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) 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.

S
Description
No description provided
Readme
1.6 MiB
Languages
JavaScript 70.2%
CSS 13.2%
HTML 8.4%
Shell 6.3%
Dockerfile 1.9%