From e70d15f861efafbf5ee7fcc967cc17e42281cc3d Mon Sep 17 00:00:00 2001 From: Philipp Traber Date: Sat, 26 Sep 2026 11:12:12 +0200 Subject: [PATCH] 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 --- .dockerignore | 7 ++ .env.example | 3 + .gitea/workflows/docker.yml | 47 ++++++++ .gitignore | 4 - Dockerfile | 11 ++ README.md | 153 ++++++++++++++++----------- data/.gitkeep | 0 data/history.csv | 71 +++++++++++++ data/history2.csv | 71 +++++++++++++ docker-compose.yml | 17 +-- docker-entrypoint.d/40-basic-auth.sh | 35 ++++++ html/app.js | 84 ++++++++++++--- html/i18n.js | 14 ++- html/index.html | 3 +- html/style.css | 4 +- nginx/default.conf | 9 +- set-password.sh | 20 ---- 17 files changed, 431 insertions(+), 122 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/workflows/docker.yml create mode 100644 Dockerfile create mode 100644 data/.gitkeep create mode 100644 data/history.csv create mode 100644 data/history2.csv create mode 100755 docker-entrypoint.d/40-basic-auth.sh delete mode 100755 set-password.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9aaa61b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +* +!Dockerfile +!html/ +!data/ +!nginx/default.conf +!nginx/security-headers.conf +!docker-entrypoint.d/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1d0205a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +BASIC_AUTH_USER=admin +BASIC_AUTH_PASSWORD=change-me +# HEATPUMP_PORT=8080 diff --git a/.gitea/workflows/docker.yml b/.gitea/workflows/docker.yml new file mode 100644 index 0000000..7edd05e --- /dev/null +++ b/.gitea/workflows/docker.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index e238efd..4c49bd7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1 @@ -# secrets -nginx/.htpasswd -# personal data export -history.csv .env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..45c8951 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index fca6f8f..4124447 100644 --- a/README.md +++ b/README.md @@ -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 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 ` 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 (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-` | +| tag `v1.2.3` | `1.2.3`, `1.2`, `sha-` | +| 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 ` diff --git a/html/style.css b/html/style.css index f615647..abc2843 100644 --- a/html/style.css +++ b/html/style.css @@ -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; } diff --git a/nginx/default.conf b/nginx/default.conf index 39bfe6e..111d0dd 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -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; } diff --git a/set-password.sh b/set-password.sh deleted file mode 100755 index 3b7115a..0000000 --- a/set-password.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/bin/sh -# Creates nginx/.htpasswd for HTTP basic auth. -# Usage: ./set-password.sh [password] (prompts for the password if omitted) -set -eu - -user="${1:?usage: $0 [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."