Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
**/node_modules
**/dist
**/coverage
.git
.github
captures
*.tsbuildinfo
*.local
66 changes: 66 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
name: Container image

# Publishes ghcr.io/libreble/<repo> (linux/amd64 + linux/arm64) with the built-in
# GITHUB_TOKEN — no registry account or secrets. Pull requests build and smoke-test only.
on:
push:
branches: [main]
tags: ['v*']
pull_request:
workflow_dispatch:

permissions:
contents: read
packages: write

concurrency:
group: docker-${{ github.ref }}
cancel-in-progress: true

jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}

# Build for the runner, run it, and check the page, assets, SW, manifest and SPA fallback.
- uses: docker/build-push-action@v6
with:
context: .
load: true
tags: smoke:test
cache-from: type=gha
cache-to: type=gha,mode=max
- run: docker run -d --name smoke -p 8080:8080 smoke:test
- run: ./docker/smoke.sh http://localhost:8080/
- if: failure()
run: docker logs smoke

- if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- if: github.event_name != 'pull_request'
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1
# The Multimeter web app (apps/web) as a static site: build with Node, serve with unprivileged
# nginx on :8080.
# docker build -t multimeter . # served at /
# docker build --build-arg BASE_PATH=/multimeter/ -t multimeter . # served at /multimeter/

FROM node:22-alpine AS build
WORKDIR /app
RUN corepack enable
COPY . .
RUN pnpm install --frozen-lockfile
ARG BASE_PATH=/
RUN BASE_PATH="$BASE_PATH" pnpm --filter web build

FROM nginxinc/nginx-unprivileged:1.29-alpine
ARG BASE_PATH=/
ENV BASE_PATH=$BASE_PATH
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /app/apps/web/dist /usr/share/nginx/html${BASE_PATH}
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO /dev/null http://127.0.0.1:8080/healthz || exit 1
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,50 @@ pnpm lint # eslint across the workspace
> screenshots above. Demo mode needs no Bluetooth, so it runs in any browser (Firefox and
> Safari included).

## Self-host

The hosted app above is the easiest way. If you'd rather run your own copy, it's a static site —
nothing to configure, no backend, no database.

**Docker** — a prebuilt image (linux/amd64 + arm64) is published to the GitHub Container Registry:

```bash
docker run -d --name multimeter -p 8080:8080 --restart unless-stopped ghcr.io/libreble/multimeter
# → http://localhost:8080/
```

```yaml
# compose.yaml
services:
multimeter:
image: ghcr.io/libreble/multimeter:latest
ports: ["8080:8080"]
restart: unless-stopped
```

The image serves the app at `/`. To serve it under a subpath behind your own proxy, build it
yourself: `docker build --build-arg BASE_PATH=/multimeter/ -t multimeter .`

**Build and host it yourself** — any static web server works:

```bash
pnpm install --frozen-lockfile
BASE_PATH=/ pnpm --filter web build # → apps/web/dist/
# upload apps/web/dist/ to nginx, Caddy, Netlify, Cloudflare Pages, a bucket, …
```

Set `BASE_PATH` to the path you serve from (it defaults to `/multimeter/`, the GitHub Pages path).
Serve `index.html` and `sw.js` with `Cache-Control: no-cache` so updates reach installed copies.
[`docker/nginx.conf.template`](docker/nginx.conf.template) is a working nginx example.

> **HTTPS is required.** Web Bluetooth only works in a secure context. `http://localhost` counts,
> so the app works on the machine running it — but `http://192.168.x.x:8080` from your phone
> will load and then refuse to connect. For phones, put it behind TLS: a reverse proxy with a
> real certificate (Caddy does this automatically for a domain), or `tailscale serve`.

Self-hosted copies keep their `<link rel="canonical">` pointing at libreble.github.io, so
search engines don't treat them as duplicates.

## Keyboard shortcuts

| Key | Action | Key | Action |
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ body {
from no CDN. `font-display: swap` so the digits render in the fallback mono until it loads. */
@font-face {
font-family: 'DSEG7';
src: url('/multimeter/fonts/DSEG7Classic-Bold.woff2') format('woff2');
src: url('/fonts/DSEG7Classic-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
Expand Down
91 changes: 50 additions & 41 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vitest/config';
import { loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import { VitePWA } from 'vite-plugin-pwa';
Expand All @@ -10,47 +11,55 @@ import { VitePWA } from 'vite-plugin-pwa';
// Hosted as a GitHub Pages project site at libreble.github.io/multimeter/, so
// `base` is the repo subpath and the service-worker scope + manifest start_url/scope all
// mirror it. (Switch to '/' + a CNAME only if it ever moves to a dedicated custom domain.)
export default defineConfig({
base: '/multimeter/',
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.svg'],
// Generates 192/512 + maskable + apple-touch PNGs from the one SVG master and injects
// the <link rel="apple-touch-icon"> tags and manifest icons array automatically.
pwaAssets: { image: 'public/icon.svg', preset: 'minimal-2023' },
manifest: {
name: 'Multimeter — Bluetooth DMM logger',
short_name: 'Multimeter',
description:
'Live readout, charting, recording and CSV/PNG export for Bluetooth multimeters (UNI-T UT60BT).',
theme_color: '#09090b',
background_color: '#09090b',
display: 'standalone',
orientation: 'any',
start_url: '/multimeter/',
scope: '/multimeter/',
categories: ['utilities', 'productivity'],
// Self-hosters override it: `BASE_PATH=/ pnpm build` (the Docker image does this).
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, '.', '');
const base = `/${(env.BASE_PATH || '/multimeter/').replace(/^\/+|\/+$/g, '')}/`.replace(
'//',
'/',
);
return {
base,
plugins: [
react(),
tailwindcss(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['icon.svg'],
// Generates 192/512 + maskable + apple-touch PNGs from the one SVG master and injects
// the <link rel="apple-touch-icon"> tags and manifest icons array automatically.
pwaAssets: { image: 'public/icon.svg', preset: 'minimal-2023' },
manifest: {
name: 'Multimeter — Bluetooth DMM logger',
short_name: 'Multimeter',
description:
'Live readout, charting, recording and CSV/PNG export for Bluetooth multimeters (UNI-T UT60BT).',
theme_color: '#09090b',
background_color: '#09090b',
display: 'standalone',
orientation: 'any',
start_url: base,
scope: base,
categories: ['utilities', 'productivity'],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// SPA: serve the app shell for any in-scope navigation when offline.
navigateFallback: `${base}index.html`,
},
}),
],
server: { host: true },
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
// Exclude entry point, generated types, and tests.
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'],
},
workbox: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
// SPA: serve the app shell for any in-scope navigation when offline.
navigateFallback: '/multimeter/index.html',
},
}),
],
server: { host: true },
test: {
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.{ts,tsx}'],
// Exclude entry point, generated types, and tests.
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**', 'src/main.tsx', 'src/vite-env.d.ts'],
},
},
};
});
36 changes: 36 additions & 0 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Static server for the built PWA. BASE_PATH is substituted at container start
# (nginx image envsubst; only defined env vars are replaced, so $uri stays as is).
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;

location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}

# Hashed build output: cache forever.
location ${BASE_PATH}assets/ {
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options nosniff;
try_files $uri =404;
}

location ~ \.webmanifest$ {
default_type application/manifest+json;
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff;
}

# index.html, sw.js and everything else: always revalidate so updates land.
# Unknown paths get the app shell (client-side routes, deep links).
location ${BASE_PATH} {
add_header Cache-Control "no-cache";
add_header X-Content-Type-Options nosniff;
try_files $uri $uri/ ${BASE_PATH}index.html;
}
}
23 changes: 23 additions & 0 deletions docker/smoke.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/bin/sh
# Smoke-test a running image: ./docker/smoke.sh http://localhost:8080/
set -eu
url="${1:-http://localhost:8080/}"
origin=$(printf '%s' "$url" | sed -E 's#(https?://[^/]+).*#\1#')
fail() { echo "FAIL: $*" >&2; exit 1; }

for i in $(seq 1 30); do curl -fs "$origin/healthz" >/dev/null && break; sleep 1; done
curl -fsS "$origin/healthz" | grep -q ok || fail healthz

html=$(curl -fsS "$url") || fail "index at $url"
echo "$html" | grep -q '<div id="root"' || fail "no #root in index"

# Every asset the page references must resolve.
for a in $(echo "$html" | grep -oE '(src|href)="[^"]+\.(js|css|svg|png|webmanifest)"' | sed -E 's/^[a-z]+="//; s/"$//' | grep -v '^http'); do
case "$a" in /*) u="$origin$a" ;; *) u="$url$a" ;; esac
curl -fsS -o /dev/null "$u" || fail "asset $u"
done

curl -fsS "${url}sw.js" | grep -q . || fail sw.js
curl -fsSI "${url}manifest.webmanifest" | grep -qi 'content-type: application/manifest+json' || fail "manifest content-type"
curl -fsS "${url}some/deep/link" | grep -q '<div id="root"' || fail "SPA fallback"
echo "smoke OK: $url"
Loading