Build container image in CI, bake in datasets, configure auth via env
Build image / build (push) Successful in 3m18s
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:
@@ -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
|
||||
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/vendor/ Plotly 2.35.2 + German locale
|
||||
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
|
||||
```
|
||||
|
||||
`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`.
|
||||
|
||||
Reference in New Issue
Block a user