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:
@@ -0,0 +1,7 @@
|
||||
*
|
||||
!Dockerfile
|
||||
!html/
|
||||
!data/
|
||||
!nginx/default.conf
|
||||
!nginx/security-headers.conf
|
||||
!docker-entrypoint.d/
|
||||
@@ -0,0 +1,3 @@
|
||||
BASIC_AUTH_USER=admin
|
||||
BASIC_AUTH_PASSWORD=change-me
|
||||
# HEATPUMP_PORT=8080
|
||||
@@ -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 }}
|
||||
@@ -1,5 +1 @@
|
||||
# secrets
|
||||
nginx/.htpasswd
|
||||
# personal data export
|
||||
history.csv
|
||||
.env
|
||||
|
||||
+11
@@ -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
|
||||
@@ -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
|
||||
```
|
||||
|
||||
## 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`.
|
||||
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
+5
-12
@@ -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
|
||||
|
||||
Executable
+35
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user