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
|
.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
|
# 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
|
||||||
nginx/security-headers.conf CSP and other headers, included per location
|
.gitea/workflows/docker.yml CI: build & push image
|
||||||
html/index.html markup
|
data/ CSV files baked into the image
|
||||||
html/style.css styles (light/dark tokens)
|
nginx/default.conf basic auth, gzip, JSON listing of /data/, /healthz
|
||||||
html/i18n.js English / German strings
|
nginx/security-headers.conf CSP and other headers, included per location
|
||||||
html/app.js CSV parsing, statistics, chart, UI wiring
|
html/index.html markup
|
||||||
html/vendor/ Plotly 2.35.2 + German locale
|
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
|
```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`.
|
|
||||||
|
|||||||
@@ -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:
|
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
|
|
||||||
|
|||||||
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';
|
'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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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