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 .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 # Heatpump cycle analysis
A small static web app that visualises when a heat pump compressor is running, 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) - 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 - Hover an on-phase to see its start, end and duration
- Statistics for the visible range: number of phases, min / max / mean / median - Statistics for the visible range: number of phases, min / max / mean / median
duration, total on-time, duty cycle, mean pause between phases duration, total on-time, duty cycle, mean pause between phases
- Table of all on-phases in the visible range - 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) - English / German UI (auto-detected from the browser, switchable, remembered)
- Light / dark mode following the OS setting - Light / dark mode following the OS setting
- Served by nginx in Docker, protected with HTTP basic auth; no external requests - nginx container with HTTP basic auth configured through environment variables;
(Plotly is vendored in `html/vendor/`) no external requests (Plotly is vendored in `html/vendor/`)
## Quick start ## Data
```sh Put CSV exports into `data/`. Every `*.csv` in there is baked into the image.
# 1. put your export next to docker-compose.yml
cp /path/to/export.csv history.csv
# 2. create the login (prompts for the password) - **One file:** it is loaded directly.
./set-password.sh admin - **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 A different CSV can also be opened ad hoc with **Load CSV…**. It is parsed in
docker compose up -d 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 ### CSV format
> `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
Home Assistant's history download (*History → ⋮ → Download data*): 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 binary_sensor.heatpump_compressor,off,2026-09-17T03:08:01.062Z
``` ```
Only the `state` and `last_changed` columns are used; column order doesn't Only the `state` and `last_changed` columns are used; column order doesn't matter.
matter. A different CSV can also be opened ad hoc with **Load CSV…**. It is
parsed in the browser and not uploaded.
### How phases are computed ### 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. - `unknown` / `unavailable` periods are shown as gaps in the timeline.
- All times are shown in the browser's local time zone. - 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 ## Project layout
``` ```
docker-compose.yml nginx container, volumes, healthcheck Dockerfile image: nginx + app + data
set-password.sh writes nginx/.htpasswd (apr1 hash via openssl) docker-entrypoint.d/40-basic-auth.sh creates .htpasswd from env at startup
nginx/default.conf basic auth, gzip, CSV alias, /healthz 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 nginx/security-headers.conf CSP and other headers, included per location
html/index.html markup html/index.html markup
html/style.css styles (light/dark tokens) html/style.css styles (light/dark tokens)
html/i18n.js English / German strings 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 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 ```sh
healthcheck. v=2.35.2
- **TLS:** the container serves plain HTTP. Basic auth sends credentials in curl -fLo html/vendor/plotly.min.js https://cdn.jsdelivr.net/npm/plotly.js-dist-min@$v/plotly.min.js
clear text, so outside a trusted LAN put it behind a TLS-terminating reverse curl -fLo html/vendor/plotly-locale-de.js https://cdn.jsdelivr.net/npm/plotly.js@$v/dist/plotly-locale-de.js
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 **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
Add an entry to `I18N` in `html/i18n.js` (copy `en`, translate, set `locale`), translated chart controls, also vendor `plotly-locale-xx.js` and include it in
add a `<button data-lang="xx">` in `index.html`, and, for translated chart `index.html`.
controls, 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: services:
heatpump: heatpump:
image: nginx:1.29-alpine build: .
image: registry.traberph.de/public/interval_viz:latest
restart: unless-stopped restart: unless-stopped
ports: ports:
- "${HEATPUMP_PORT:-8080}:80" - "${HEATPUMP_PORT:-8080}:80"
volumes: environment:
- ./html:/usr/share/nginx/html:ro BASIC_AUTH_USER: dev
- ${HEATPUMP_CSV:-./history.csv}:/data/history.csv:ro BASIC_AUTH_PASSWORD: dev
- ./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
+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'; 'use strict';
const LANG_KEY = 'heatpump.lang'; const LANG_KEY = 'heatpump.lang';
const DATASET_KEY = 'heatpump.dataset';
const DATA_DIR = 'data/';
const HOUR = 3600e3; const HOUR = 3600e3;
const state = { const state = {
@@ -10,7 +12,9 @@ const state = {
dataMin: 0, dataMin: 0,
dataMax: 0, dataMax: 0,
range: null, // [from, to] in ms, or null = everything 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 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'); if (el.dataset.i18n === 'note') el.innerHTML = t('note');
else el.textContent = t(el.dataset.i18n); 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))); document.querySelectorAll('[data-lang]').forEach(b => b.setAttribute('aria-pressed', String(b.dataset.lang === lang)));
render(); render();
} }
@@ -119,11 +124,11 @@ function parseCsv(text) {
return { events, phases }; return { events, phases };
} }
function load(text, source) { function load(text, source, localFile = false) {
try { try {
const { events, phases } = parseCsv(text); const { events, phases } = parseCsv(text);
Object.assign(state, { 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, dataMin: events[0].t, dataMax: events[events.length - 1].t,
}); });
} catch (err) { } 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(''); `<div class="tile"><div class="k">${escapeHtml(t(key))}</div><div class="v ${cls}">${escapeHtml(value)}</div></div>`).join('');
function render() { function render() {
renderDatasetSelect();
const src = $('source'); const src = $('source');
if (state.error) { if (state.error) {
src.textContent = t(state.error.key, state.error.params); src.textContent = t(state.error.key, state.error.params);
src.className = 'sub error'; src.className = 'sub error';
} else { } 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'; src.className = 'sub';
} }
if (!state.events.length) return; if (!state.events.length) return;
@@ -178,6 +185,17 @@ function render() {
renderStats(); 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() { function drawChart() {
const accent = css('--accent'), ink = css('--ink'), ink2 = css('--ink-2'), line = css('--line'); 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 => { $('file').addEventListener('change', async e => {
const file = e.target.files[0]; const file = e.target.files[0];
e.target.value = ''; // allow re-selecting the same file 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); matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render);
setLang(detectLang()); $('dataset').addEventListener('change', e => loadDataset(e.target.value));
fetch('history.csv', { cache: 'no-cache' }) async function loadDataset(name) {
.then(r => { try {
const r = await fetch(DATA_DIR + encodeURIComponent(name), { cache: 'no-cache' });
if (!r.ok) throw new Error(`HTTP ${r.status}`); if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text(); load(await r.text(), name);
}) // remember only selections that actually loaded
.then(text => load(text, 'history.csv')) if (!state.error) try { localStorage.setItem(DATASET_KEY, name); } catch { /* storage unavailable */ }
.catch(err => { } catch (err) {
state.error = { key: 'errFetch', params: { status: err.message } }; state.error = { key: 'errFetch', params: { file: name, status: err.message } };
render(); 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' }, units: { d: 'd', h: 'h', m: 'm', s: 's' },
errColumns: 'CSV needs the columns "state" and "last_changed".', errColumns: 'CSV needs the columns "state" and "last_changed".',
errEmpty: 'CSV contains no valid rows.', 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, ' + 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. ' + '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 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.', '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' }, units: { d: 'T', h: 'h', m: 'min', s: 's' },
errColumns: 'Die CSV benötigt die Spalten "state" und "last_changed".', errColumns: 'Die CSV benötigt die Spalten "state" und "last_changed".',
errEmpty: 'Die CSV enthält keine gültigen Zeilen.', 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 ' + 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. ' + '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 Phase endet beim ersten Zustand ungleich „on“ (off, unknown, unavailable); ' +
'eine am Datenende noch laufende Phase wird nicht gezählt. Zeiten in lokaler Zeitzone.', 'eine am Datenende noch laufende Phase wird nicht gezählt. Zeiten in lokaler Zeitzone.',
+2 -1
View File
@@ -15,7 +15,8 @@
<header> <header>
<h1 data-i18n="title">Heatpump compressor cycles</h1> <h1 data-i18n="title">Heatpump compressor cycles</h1>
<div class="header-right"> <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"> <div class="lang" role="group" aria-label="Language">
<button type="button" data-lang="en">EN</button> <button type="button" data-lang="en">EN</button>
<button type="button" data-lang="de">DE</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; } .sub { color: var(--ink-2); font-size: 13px; }
.error { color: var(--error); } .error { color: var(--error); }
button, label.file { button, label.file, select {
font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 6px; cursor: pointer; font: inherit; font-size: 13px; padding: 5px 11px; border-radius: 6px; cursor: pointer;
border: 1px solid var(--line); background: var(--surface); color: var(--ink); 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:focus-within { outline: 2px solid var(--accent); outline-offset: 1px; }
label.file input { position: absolute; opacity: 0; width: 1px; height: 1px; } label.file input { position: absolute; opacity: 0; width: 1px; height: 1px; }
+5 -4
View File
@@ -9,7 +9,7 @@ server {
gzip on; gzip on;
gzip_comp_level 6; 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 "Heatpump";
auth_basic_user_file /etc/nginx/.htpasswd; auth_basic_user_file /etc/nginx/.htpasswd;
@@ -19,9 +19,10 @@ server {
include /etc/nginx/security-headers.conf; include /etc/nginx/security-headers.conf;
} }
# the CSV is mounted separately so it can be replaced without touching the app # data/ is listed as JSON so the UI can discover the CSV files
location = /history.csv { location /data/ {
alias /data/history.csv; autoindex on;
autoindex_format json;
default_type text/csv; default_type text/csv;
include /etc/nginx/security-headers.conf; 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."