chore: scaffold pnpm workspace, container, deploy docs

Two-app pnpm workspace for the gashboard (mining dashboard) project:
@gashboard/api (Express 5 + TS) and @gashboard/web (Vue 3 + Vite + TS).
Shared tsconfig.base.json. Multi-stage Dockerfile (node:22.12-alpine,
non-root, healthchecked) and docker-compose.yml ready to deploy as a
Portainer Stack on Umbrel — joins umbrel_main_network so it can reach
the Datum container directly. .env.example documents every var; README
covers the Portainer deploy flow and the security posture.

Note: Dockerfile has a TODO marker to SHA256-pin the base image before
shipping to production.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-05-06 15:57:57 +01:00
commit 2dc9be4678
15 changed files with 2841 additions and 0 deletions

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# gashboard configuration
# Copy to .env (or set in Portainer stack env) and fill in values.
# ---- Server ----
PORT=8080
NODE_ENV=production
LOG_LEVEL=info
# Origin allowed by CORS. Leave unset to disable CORS entirely (single-origin
# deploy where the API serves the SPA from the same host).
# CORS_ORIGIN=https://gashboard.example.com
# Override the static dir the API serves the built SPA from. Default resolves
# to ../web/dist relative to the running api bundle.
# STATIC_DIR=
# ---- Datum gateway (the Umbrel app we're polling) ----
# Reachable internally inside Umbrel's docker network. From your LAN it's also
# at http://192.168.1.191:21000 but that path goes through umbrelOS auth.
# Inside the Umbrel docker network, use the Datum service hostname directly.
DATUM_URL=http://datum_datum_1:21000
DATUM_ADMIN_USER=admin
DATUM_ADMIN_PASSWORD=
# How often to scrape /clients (ms). Datum updates per-worker hashrate every
# few seconds; 5s is a sane default.
DATUM_POLL_INTERVAL_MS=5000
# ---- Nostr auth ----
# Comma-separated bech32 npubs allowed to log in. Anything else is rejected
# at NIP-98 verification, before any session is issued.
NOSTR_ALLOWED_NPUBS=npub19tfnjfvxzt45jrz78mr3cldrtlg8pj5kp6gshp37582xcj7a0ctq7c8d7j,npub10wzfa7jkqj6c65xyr93hhxrns37ml9tss82jvymv8fymwdtu6cts3h6pvr
# ---- Sessions ----
# 32+ random bytes, hex. Generate with: openssl rand -hex 32
JWT_SECRET=
JWT_TTL_SECONDS=86400

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
node_modules/
dist/
build/
.env
.env.local
*.log
.DS_Store
.vite/
coverage/
*.tsbuildinfo
.pnpm-store/

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# NOTE: pin this base by SHA256 before first deploy. To resolve:
# docker pull node:22.12.0-alpine
# docker inspect --format='{{index .RepoDigests 0}}' node:22.12.0-alpine
# then replace the FROM lines below with `node@sha256:<digest>`.
ARG NODE_IMAGE=node:22.12.0-alpine
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate
COPY pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile=false
FROM deps AS build-api
WORKDIR /app
COPY apps/api apps/api
RUN pnpm --filter @gashboard/api build
FROM deps AS build-web
WORKDIR /app
COPY apps/web apps/web
RUN pnpm --filter @gashboard/web build
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache wget tini
RUN corepack enable && corepack prepare pnpm@9.12.3 --activate
COPY pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
RUN pnpm install --filter @gashboard/api --prod --frozen-lockfile=false
COPY --from=build-api /app/apps/api/dist apps/api/dist
COPY --from=build-web /app/apps/web/dist apps/api/public
USER node
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "apps/api/dist/index.js"]

62
README.md Normal file
View File

@@ -0,0 +1,62 @@
# gashboard
> a custom dashboard for the datum gateway running on umbrel.
> for the four little boards that probably won't find a block, but are trying their best.
Polls a local Datum gateway (OCEAN's open-source mining gateway), shows live per-miner hashrate, share counts, lottery odds, and a tongue-in-cheek read on how unlikely it all is. Designed for a small fleet of solo-style hobby miners (Bitaxe, NerdQAxe, Avalon Nano 3, Avalon Mini 3) hashing into one Datum on Umbrel.
## What it shows
- **Pool hero** — combined hashrate, current block height being templated, mempool fee snapshot, datum connection status.
- **Per-miner cards** — nickname, ASIC type, location, live hashrate, accepted/rejected shares, last-share age, status light, firmware string from `useragent`.
- **Lottery widget** — P(block in 24h) at current network difficulty, with rotating self-deprecating commentary.
- **Live share ticker** — sparkline + scrolling feed of recent accepted shares.
- **Sats-today counter** — earnings projection from current hashrate × network reward.
- **Map panel** — pins for each location (split by remote IP from Datum's `/clients`), pulse-per-share.
- **Block celebration** — confetti + sound the day it finally happens.
## Auth
Nostr-only login (NIP-98 over HTTP). Two signers supported:
- **Remote signer** (NIP-46) — covers Primal app, Amber, nsecbunker.
- **Browser extension** (NIP-07) — Alby, nos2x, etc.
Allowlist of npubs is set via `NOSTR_ALLOWED_NPUBS`. Anything else is rejected before a session token is issued.
## Stack
- **Frontend** — Vue 3 + Vite + Pinia + TypeScript
- **Backend** — Express + TypeScript
- **Auth** — `nostr-tools` (NIP-98 verify), `applesauce-*` (signer abstraction, lifted from indeehub)
- **Container** — multi-stage `node:22.12.0-alpine` (pinned by SHA256 in Dockerfile)
## Deploy on Portainer (the way it'll actually run)
1. **Enable Datum admin API** on your Umbrel — edit
`~/umbrel/app-data/datum-gateway/data/datum_gateway/datum_gateway_config.json`
and add `"admin_password": "<openssl rand -hex 32>"` inside the `"api"` block.
Restart the Datum app.
2. **Push this repo** to your gitea/whatever:
```bash
git push -u origin main
```
3. **In Portainer → Stacks → Add stack → Repository**:
- Repository URL: `https://git.tx1138.com/lfg2025/gashboard`
- Compose path: `docker-compose.yml`
- Add env vars (see `.env.example`)
- Set the `external` network in the compose file to match your Datum app's docker network (find with `docker network ls` on the Umbrel)
4. Open the dashboard, log in with one of the allowed npubs, watch your boards lose at hashing in style.
## Local dev
```bash
pnpm install
pnpm dev # runs api + web concurrently
```
## License
MIT.

34
apps/api/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "@gashboard/api",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"cors": "2.8.5",
"express": "4.21.1",
"express-rate-limit": "7.4.1",
"helmet": "8.0.0",
"jsonwebtoken": "9.0.2",
"node-html-parser": "6.1.13",
"nostr-tools": "2.10.4",
"pino": "9.5.0",
"pino-http": "10.3.0",
"zod": "3.23.8"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/express": "5.0.0",
"@types/express-serve-static-core": "5.0.2",
"@types/jsonwebtoken": "9.0.7",
"@types/node": "22.9.0",
"tsx": "4.19.2",
"typescript": "5.6.3"
}
}

11
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}

13
apps/web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0a0e1a" />
<title>gashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

26
apps/web/package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "@gashboard/web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"applesauce-accounts": "5.1.0",
"applesauce-signers": "5.1.0",
"nostr-tools": "2.10.4",
"pinia": "2.3.0",
"vue": "3.5.13",
"vue-router": "4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "5.2.1",
"typescript": "5.7.2",
"vite": "6.0.3",
"vue-tsc": "2.1.10"
}
}

14
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "preserve",
"useDefineForClassFields": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
}

21
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
"/healthz": "http://localhost:8080",
},
},
build: {
outDir: "dist",
sourcemap: true,
target: "es2022",
},
});

47
docker-compose.yml Normal file
View File

@@ -0,0 +1,47 @@
version: "3.9"
# gashboard — deploy as a Portainer Stack on the same Umbrel host that runs Datum.
# IMPORTANT: set `networks.datum.name` below to the actual docker network the
# Datum app uses (find it on the Umbrel with `docker network ls | grep datum`).
services:
gashboard:
# Portainer "Stacks → Repository" will build this from the gashboard git repo.
# If you want to pull a pre-built image instead, comment out `build:` and
# set `image:` to your registry tag.
build:
context: .
dockerfile: Dockerfile
image: gashboard:0.1.0
container_name: gashboard
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "8080"
LOG_LEVEL: "${LOG_LEVEL:-info}"
CORS_ORIGIN: "${CORS_ORIGIN:-}"
DATUM_URL: "${DATUM_URL:-http://datum_datum_1:21000}"
DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}"
DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}"
DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}"
NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}"
JWT_SECRET: "${JWT_SECRET?must be set}"
JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"
ports:
- "8420:8080"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- umbrel_main
- default
networks:
umbrel_main:
external: true
# Datum container on this Umbrel sits on `umbrel_main_network` (10.21.0.0/16).
# Confirmed via `docker network ls` on the Umbrel host.
name: umbrel_main_network

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "gashboard",
"version": "0.1.0",
"private": true,
"description": "Datum mining dashboard for the four little boards that probably won't find a block",
"packageManager": "pnpm@9.12.3",
"scripts": {
"dev": "pnpm -r --parallel dev",
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
"lint": "pnpm -r lint"
},
"engines": {
"node": ">=22.12.0"
}
}

2493
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

2
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,2 @@
packages:
- 'apps/*'

16
tsconfig.base.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"exactOptionalPropertyTypes": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
}
}