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:
34
.env.example
Normal file
34
.env.example
Normal 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
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite/
|
||||
coverage/
|
||||
*.tsbuildinfo
|
||||
.pnpm-store/
|
||||
41
Dockerfile
Normal file
41
Dockerfile
Normal 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
62
README.md
Normal 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
34
apps/api/package.json
Normal 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
11
apps/api/tsconfig.json
Normal 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
13
apps/web/index.html
Normal 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
26
apps/web/package.json
Normal 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
14
apps/web/tsconfig.json
Normal 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
21
apps/web/vite.config.ts
Normal 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
47
docker-compose.yml
Normal 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
16
package.json
Normal 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
2493
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
16
tsconfig.base.json
Normal file
16
tsconfig.base.json
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user