feat(apps): add Gashboard with native signing and viewer access

This commit is contained in:
yaya
2026-10-06 06:42:25 +01:00
parent cedfbb2b07
commit 908e366006
100 changed files with 11072 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
.git
.gitignore
node_modules
**/node_modules
dist
**/dist
.vite
**/.vite
.env
.env.*
!.env.example
*.log
coverage
.pnpm-store
*.tsbuildinfo
**/*.tsbuildinfo
.DS_Store
README.md
+36
View File
@@ -0,0 +1,36 @@
# gashboard configuration
# Copy to .env (or set in Portainer stack env) and fill in values.
# ---- Server ----
PORT=1337
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) ----
# Datum's admin UI/API is published by Umbrel on the host at port 21000.
DATUM_URL=http://127.0.0.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
CONTRIBUTION_LEDGER_PATH=/data/contribution-ledger.json
# Sovereign mempool API used for block height, network hashrate, and difficulty.
MEMPOOL_API_URL=https://tx1138.com/api
# ---- 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
+13
View File
@@ -0,0 +1,13 @@
# This vendored app is source, not the repository-wide web build output.
!apps/web/
node_modules/
dist/
build/
.env
.env.local
*.log
.DS_Store
.vite/
coverage/
*.tsbuildinfo
.pnpm-store/
+46
View File
@@ -0,0 +1,46 @@
# TODO(security): pin this base by SHA256 before shipping to production.
# Resolve with:
# docker pull node:22.12.0-alpine
# docker inspect --format='{{index .RepoDigests 0}}' node:22.12.0-alpine
# then replace `node:22.12.0-alpine` below with `node@sha256:<digest>`.
ARG NODE_IMAGE=node:22.12.0-alpine@sha256:51eff88af6dff26f59316b6e356188ffa2c422bd3c3b76f2556a2e7e89d080bd
FROM ${NODE_IMAGE} AS deps
WORKDIR /app
# Avoid Corepack — Node 22 ships a Corepack that strict-validates pnpm
# signatures and breaks behind builders that can't reach the signing host.
RUN npm install -g pnpm@9.12.3 --no-fund --no-audit && npm cache clean --force
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY apps/api/package.json apps/api/
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile
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 \
&& npm install -g pnpm@9.12.3 --no-fund --no-audit \
&& npm cache clean --force
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY apps/api/package.json apps/api/
RUN pnpm install --filter @gashboard/api --prod --frozen-lockfile
COPY --from=build-api /app/apps/api/dist apps/api/dist
COPY --from=build-web /app/apps/web/dist apps/web/dist
COPY nostr-provider.js /app/nostr-provider.js
USER node
EXPOSE 1337
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "apps/api/dist/index.js"]
+68
View File
@@ -0,0 +1,68 @@
# 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`)
- Remove any old `DATUM_URL=http://10.21...`,
`DATUM_URL=http://datum...`, or `DATUM_URL=http://datum_datum_1...` value
from the stack env vars.
- By default the stack uses host networking and polls Datum through Umbrel's
host-published admin port: `DATUM_URL=http://127.0.0.1:21000`.
- Reward contribution accounting is persisted in the Docker volume
`gashboard_data` at `/data/contribution-ledger.json`.
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.
+35
View File
@@ -0,0 +1,35 @@
{
"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",
"test": "tsx --test tests/*.test.ts"
},
"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"
}
}
@@ -0,0 +1,44 @@
import { Router } from "express";
import { nip19 } from "nostr-tools";
import { requireAuth } from "../auth/middleware.js";
import { accessList } from "../nostr/allowlist.js";
import { badRequest, forbidden } from "../errors.js";
export const accessRouter = Router();
accessRouter.use(requireAuth);
accessRouter.get("/", (req, res) => {
const isOwner = accessList.isOwner(req.session!.pubkey);
res.setHeader("Cache-Control", "no-store");
res.json({ isOwner, members: isOwner ? accessList.members() : [] });
});
accessRouter.use((req, _res, next) => {
if (!accessList.isOwner(req.session!.pubkey)) return next(forbidden("owner_required"));
next();
});
function publicKey(value: unknown): string {
if (typeof value !== "string" || value.length > 100) throw badRequest("invalid_npub", "Enter a valid npub public key.");
try {
const decoded = nip19.decode(value.trim());
if (decoded.type === "npub") return decoded.data;
} catch { /* Map decoding failures to a client error. */ }
throw badRequest("invalid_npub", "Enter a valid npub public key.");
}
function update(npub: unknown, enabled: boolean): void {
const pubkey = publicKey(npub);
if (accessList.isOwner(pubkey)) throw badRequest("node_owner", "Manage node owners in Archipelago identities.");
if (enabled && !accessList.isAllowed(pubkey) && accessList.members().filter(m => m.role === "viewer").length >= 500) {
throw badRequest("list_full", "The access list is limited to 500 viewers.");
}
accessList.setViewer(pubkey, enabled);
}
accessRouter.post("/", (req, res) => {
update(req.body?.npub, true);
res.json({ isOwner: true, members: accessList.members() });
});
accessRouter.delete("/:npub", (req, res) => {
update(req.params.npub, false);
res.json({ isOwner: true, members: accessList.members() });
});
@@ -0,0 +1,41 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import { nip19 } from "nostr-tools";
export class AccessList {
private readonly owners: Set<string>;
private viewers: Set<string>;
constructor(private readonly file: string, owners: string[], initialViewers: string[]) {
this.owners = new Set(owners);
try {
const saved: unknown = JSON.parse(readFileSync(file, "utf8"));
if (!Array.isArray(saved) || saved.length > 500 || saved.some(k => typeof k !== "string" || !/^[0-9a-f]{64}$/.test(k))) {
throw new Error("Invalid saved Gashboard access list");
}
this.viewers = new Set(saved as string[]);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
this.viewers = new Set(initialViewers);
}
}
isOwner(pubkey: string): boolean { return this.owners.has(pubkey); }
isAllowed(pubkey: string): boolean { return this.isOwner(pubkey) || this.viewers.has(pubkey); }
keys(): string[] { return [...new Set([...this.owners, ...this.viewers])].sort(); }
members(): Array<{ npub: string; role: "owner" | "viewer" }> {
return this.keys().map(pubkey => ({ npub: nip19.npubEncode(pubkey), role: this.isOwner(pubkey) ? "owner" : "viewer" }));
}
setViewer(pubkey: string, enabled: boolean): void {
if (this.isOwner(pubkey)) throw new Error("Node owners are managed in Archipelago identities");
const next = new Set(this.viewers);
if (enabled) next.add(pubkey); else next.delete(pubkey);
if (next.size > 500) throw new Error("The access list is limited to 500 viewers");
mkdirSync(dirname(this.file), { recursive: true, mode: 0o700 });
// Small synchronous writes serialize mutations; replace before updating memory.
writeFileSync(`${this.file}.tmp`, JSON.stringify([...next].sort()) + "\n", { mode: 0o600 });
renameSync(`${this.file}.tmp`, this.file);
this.viewers = next;
}
}
+39
View File
@@ -0,0 +1,39 @@
import jwt from "jsonwebtoken";
import { nip19 } from "nostr-tools";
import { config } from "../config.js";
export type SessionPayload = {
pubkey: string;
npub: string;
iat: number;
exp: number;
};
export type IssuedSession = {
token: string;
npub: string;
expiresAt: number;
};
export function issueSession(hexPubkey: string): IssuedSession {
const issuedAt = Math.floor(Date.now() / 1000);
const expiresAt = issuedAt + config.jwt.ttlSeconds;
const npub = nip19.npubEncode(hexPubkey);
const token = jwt.sign(
{ pubkey: hexPubkey, npub, iat: issuedAt, exp: expiresAt },
config.jwt.secret,
{ algorithm: "HS256" },
);
return { token, npub, expiresAt };
}
export function verifySession(token: string): SessionPayload | null {
try {
const decoded = jwt.verify(token, config.jwt.secret, { algorithms: ["HS256"] });
if (typeof decoded === "string") return null;
if (typeof decoded.pubkey !== "string" || typeof decoded.npub !== "string") return null;
return decoded as SessionPayload;
} catch {
return null;
}
}
@@ -0,0 +1,22 @@
import type { NextFunction, Request, Response } from "express";
import { unauthorized } from "../errors.js";
import { isPubkeyAllowed } from "../nostr/allowlist.js";
import { verifySession } from "./jwt.js";
declare module "express-serve-static-core" {
interface Request {
session?: { pubkey: string; npub: string };
}
}
export function requireAuth(req: Request, _res: Response, next: NextFunction): void {
const header = req.header("authorization");
const match = header?.match(/^Bearer\s+(.+)$/i);
if (!match) return next(unauthorized("no_session"));
const session = verifySession(match[1]!.trim());
if (!session || !isPubkeyAllowed(session.pubkey)) return next(unauthorized("bad_session"));
req.session = { pubkey: session.pubkey, npub: session.npub };
next();
}
@@ -0,0 +1,54 @@
import { Router } from "express";
import rateLimit from "express-rate-limit";
import { verifyNip98 } from "../nostr/nip98.js";
import { isPubkeyAllowed, allowedPubkeys, isOwner } from "../nostr/allowlist.js";
import { issueSession } from "./jwt.js";
import { requireAuth } from "./middleware.js";
import { unauthorized, forbidden } from "../errors.js";
import { logger } from "../logger.js";
export const authRouter = Router();
const loginLimiter = rateLimit({
windowMs: 60_000,
limit: 10,
standardHeaders: "draft-7",
legacyHeaders: false,
message: { error: { code: "rate_limited", message: "Too many login attempts." } },
});
function fullUrl(req: import("express").Request): string {
const proto = (req.headers["x-forwarded-proto"] as string | undefined)?.split(",")[0] ?? req.protocol;
const host = (req.headers["x-forwarded-host"] as string | undefined) ?? req.headers.host;
return `${proto}://${host}${req.originalUrl}`;
}
authRouter.post("/login", loginLimiter, (req, res, next) => {
const result = verifyNip98({
authHeader: req.headers.authorization,
method: req.method,
fullUrl: fullUrl(req),
});
if (!result.ok) {
logger.warn({ reason: result.reason }, "nip98_rejected");
return next(unauthorized("nip98_failed"));
}
if (!isPubkeyAllowed(result.pubkey)) {
logger.warn({ pub: `${result.pubkey.slice(0, 8)}…` }, "pubkey_not_allowlisted");
return next(forbidden("not_allowlisted"));
}
const session = issueSession(result.pubkey);
res.json({
token: session.token,
npub: session.npub,
expiresAt: session.expiresAt,
});
});
authRouter.get("/me", requireAuth, (req, res) => {
res.json({ npub: req.session!.npub, isOwner: isOwner(req.session!.pubkey) });
});
authRouter.get("/chat-config", requireAuth, (_req, res) => {
res.json({ pubkeys: allowedPubkeys() });
});
@@ -0,0 +1,63 @@
import { Router } from "express";
import { z } from "zod";
import { requireAuth } from "../auth/middleware.js";
import { addMessage, addReaction, listChat, upsertKeyWrap } from "./store.js";
export const chatRouter = Router();
const MessageBody = z.object({
content: z.string().trim().min(1).max(12000),
replyToId: z.string().max(128).optional(),
replyToPubkey: z.string().max(128).optional(),
});
const ReactionBody = z.object({
messageId: z.string().min(1).max(128),
content: z.string().trim().min(1).max(12000),
});
const KeyWrapBody = z.object({
wraps: z.array(z.object({
recipientPubkey: z.string().regex(/^[0-9a-f]{64}$/),
ciphertext: z.string().trim().min(1).max(12000),
})).min(1).max(10),
});
chatRouter.use(requireAuth);
chatRouter.get("/", (_req, res) => {
res.json(listChat());
});
chatRouter.post("/messages", (req, res) => {
const body = MessageBody.parse(req.body);
const message = addMessage({
pubkey: req.session!.pubkey,
content: body.content,
...(body.replyToId ? { replyToId: body.replyToId } : {}),
...(body.replyToPubkey ? { replyToPubkey: body.replyToPubkey } : {}),
});
res.status(201).json(message);
});
chatRouter.post("/reactions", (req, res) => {
const body = ReactionBody.parse(req.body);
const reaction = addReaction({
pubkey: req.session!.pubkey,
messageId: body.messageId,
content: body.content,
});
res.status(201).json(reaction);
});
chatRouter.post("/key-wraps", (req, res) => {
const body = KeyWrapBody.parse(req.body);
const wraps = body.wraps.map((wrap) =>
upsertKeyWrap({
recipientPubkey: wrap.recipientPubkey,
senderPubkey: req.session!.pubkey,
ciphertext: wrap.ciphertext,
}),
);
res.status(201).json({ wraps });
});
@@ -0,0 +1,97 @@
export type StoredChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
};
export type StoredChatReaction = {
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
};
export type StoredChatKeyWrap = {
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
updatedAt: number;
};
const MAX_MESSAGES = 500;
const MAX_REACTIONS = 2000;
const messages: StoredChatMessage[] = [];
const reactions: StoredChatReaction[] = [];
const keyWraps: StoredChatKeyWrap[] = [];
export function listChat(): {
messages: StoredChatMessage[];
reactions: StoredChatReaction[];
keyWraps: StoredChatKeyWrap[];
} {
return { messages: [...messages], reactions: [...reactions], keyWraps: [...keyWraps] };
}
export function addMessage(input: {
pubkey: string;
content: string;
replyToId?: string;
replyToPubkey?: string;
}): StoredChatMessage {
const message: StoredChatMessage = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
replyToId: input.replyToId ?? "",
replyToPubkey: input.replyToPubkey ?? "",
};
messages.push(message);
if (messages.length > MAX_MESSAGES) messages.splice(0, messages.length - MAX_MESSAGES);
return message;
}
export function addReaction(input: {
pubkey: string;
messageId: string;
content: string;
}): StoredChatReaction {
const reaction: StoredChatReaction = {
id: crypto.randomUUID(),
pubkey: input.pubkey,
messageId: input.messageId,
content: input.content,
createdAt: Math.floor(Date.now() / 1000),
};
reactions.push(reaction);
if (reactions.length > MAX_REACTIONS) reactions.splice(0, reactions.length - MAX_REACTIONS);
return reaction;
}
export function upsertKeyWrap(input: {
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
}): StoredChatKeyWrap {
const updatedAt = Math.floor(Date.now() / 1000);
const existing = keyWraps.find((wrap) => wrap.recipientPubkey === input.recipientPubkey);
if (existing) {
existing.senderPubkey = input.senderPubkey;
existing.ciphertext = input.ciphertext;
existing.updatedAt = updatedAt;
return existing;
}
const wrap: StoredChatKeyWrap = {
recipientPubkey: input.recipientPubkey,
senderPubkey: input.senderPubkey,
ciphertext: input.ciphertext,
updatedAt,
};
keyWraps.push(wrap);
return wrap;
}
+84
View File
@@ -0,0 +1,84 @@
import { nip19 } from "nostr-tools";
import { z } from "zod";
const RawEnv = z.object({
PORT: z.coerce.number().int().min(1).max(65535).default(1337),
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
LOG_LEVEL: z
.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"])
.default("info"),
CORS_ORIGIN: z.string().optional(),
STATIC_DIR: z.string().optional(),
DATUM_URL: z.string().url().optional(),
DATUM_ADMIN_USER: z.string().default("admin"),
DATUM_ADMIN_PASSWORD: z.string().min(1),
DATUM_POLL_INTERVAL_MS: z.coerce.number().int().min(1000).default(5000),
CONTRIBUTION_LEDGER_PATH: z.string().min(1).default("/data/contribution-ledger.json"),
MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().default(""),
NOSTR_OWNER_PUBKEYS: z.string().default(""),
ACCESS_LIST_PATH: z.string().default("/data/access.json"),
ARCHIPELAGO_PROVIDER_PATH: z.string().optional(),
JWT_SECRET: z
.string()
.min(32, "JWT_SECRET must be at least 32 chars (use openssl rand -hex 32)"),
JWT_TTL_SECONDS: z.coerce.number().int().min(60).default(86400),
});
function parseAllowedNpubs(input: string): string[] {
const out: string[] = [];
for (const raw of input.split(",").map((s) => s.trim()).filter(Boolean)) {
const decoded = nip19.decode(raw);
if (decoded.type !== "npub") {
throw new Error(`NOSTR_ALLOWED_NPUBS entry is not an npub: ${raw}`);
}
const hex = decoded.data;
if (!/^[0-9a-f]{64}$/.test(hex)) {
throw new Error(`Decoded npub is not 64-char hex: ${raw}`);
}
out.push(hex);
}
return out;
}
const parsed = RawEnv.parse(process.env);
const ownerHexPubkeys = parsed.NOSTR_OWNER_PUBKEYS.split(",").map(k => k.trim()).filter(Boolean);
if (ownerHexPubkeys.some(k => !/^[0-9a-f]{64}$/.test(k))) {
throw new Error("NOSTR_OWNER_PUBKEYS must contain comma-separated 64-character hex public keys");
}
const allowedHexPubkeys = parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS);
if (!ownerHexPubkeys.length && !allowedHexPubkeys.length) {
throw new Error("Configure at least one owner or allowlisted npub before starting Gashboard");
}
export const config = {
port: parsed.PORT,
nodeEnv: parsed.NODE_ENV,
logLevel: parsed.LOG_LEVEL,
corsOrigin: parsed.CORS_ORIGIN,
staticDir: parsed.STATIC_DIR,
providerPath: parsed.ARCHIPELAGO_PROVIDER_PATH,
datum: {
url: (parsed.DATUM_URL ?? "http://127.0.0.1:21000").replace(/\/$/, ""),
adminUser: parsed.DATUM_ADMIN_USER,
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
contributionLedgerPath: parsed.CONTRIBUTION_LEDGER_PATH,
},
mempool: {
url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),
},
nostr: {
allowedHexPubkeys,
ownerHexPubkeys,
accessListPath: parsed.ACCESS_LIST_PATH,
},
jwt: {
secret: parsed.JWT_SECRET,
ttlSeconds: parsed.JWT_TTL_SECONDS,
},
} as const;
@@ -0,0 +1,114 @@
import { createHash, randomBytes } from "node:crypto";
export type DigestCreds = {
username: string;
password: string;
};
export type DigestFetchOptions = {
url: string;
method?: string;
creds: DigestCreds;
signal?: AbortSignal;
headers?: Record<string, string>;
};
function sha256Hex(input: string): string {
return createHash("sha256").update(input).digest("hex");
}
type Challenge = {
realm: string;
nonce: string;
qop: string;
algorithm: string;
opaque?: string;
};
function parseChallenge(header: string): Challenge | null {
if (!/^digest\s/i.test(header)) return null;
const body = header.slice(7);
const out: Record<string, string> = {};
const re = /(\w+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g;
let m: RegExpExecArray | null;
while ((m = re.exec(body)) !== null) {
out[m[1]!.toLowerCase()] = m[2] !== undefined ? m[2] : (m[3] ?? "");
}
if (!out["realm"] || !out["nonce"]) return null;
return {
realm: out["realm"],
nonce: out["nonce"],
qop: out["qop"] ?? "auth",
algorithm: out["algorithm"] ?? "MD5",
...(out["opaque"] !== undefined ? { opaque: out["opaque"] } : {}),
};
}
function buildAuthHeader(args: {
user: string;
password: string;
challenge: Challenge;
uri: string;
method: string;
}): string {
const algoUpper = args.challenge.algorithm.toUpperCase();
if (!algoUpper.includes("SHA-256") && !algoUpper.includes("SHA256")) {
throw new Error(`Unsupported Datum digest algorithm: ${args.challenge.algorithm}`);
}
const nc = "00000001";
const cnonce = randomBytes(8).toString("hex");
const ha1 = sha256Hex(`${args.user}:${args.challenge.realm}:${args.password}`);
const ha2 = sha256Hex(`${args.method}:${args.uri}`);
const response = sha256Hex(
`${ha1}:${args.challenge.nonce}:${nc}:${cnonce}:${args.challenge.qop}:${ha2}`,
);
const parts = [
`username="${args.user}"`,
`realm="${args.challenge.realm}"`,
`nonce="${args.challenge.nonce}"`,
`uri="${args.uri}"`,
`algorithm=${args.challenge.algorithm}`,
`response="${response}"`,
`qop=${args.challenge.qop}`,
`nc=${nc}`,
`cnonce="${cnonce}"`,
];
if (args.challenge.opaque) parts.push(`opaque="${args.challenge.opaque}"`);
return "Digest " + parts.join(", ");
}
export async function digestFetch(opts: DigestFetchOptions): Promise<Response> {
const method = opts.method ?? "GET";
const url = new URL(opts.url);
const uri = url.pathname + url.search;
const baseInit: RequestInit = {
method,
redirect: "manual",
headers: { ...(opts.headers ?? {}) },
...(opts.signal ? { signal: opts.signal } : {}),
};
const first = await fetch(opts.url, baseInit);
if (first.status !== 401) return first;
const challengeHdr = first.headers.get("www-authenticate");
if (!challengeHdr) return first;
const challenge = parseChallenge(challengeHdr);
if (!challenge) return first;
await first.body?.cancel().catch(() => {});
const auth = buildAuthHeader({
user: opts.creds.username,
password: opts.creds.password,
challenge,
uri,
method,
});
return fetch(opts.url, {
...baseInit,
headers: { ...(opts.headers ?? {}), authorization: auth },
});
}
@@ -0,0 +1,162 @@
import { parse, type HTMLElement } from "node-html-parser";
import { MINER_PROFILES } from "./profiles.js";
import type { MinerProfile, MinerStat } from "./types.js";
const UNKNOWN_PROFILE: MinerProfile = {
slug: "unknown",
nickname: "Unknown miner",
model: "Unknown",
ownerLabel: "unknown",
locationLabel: "unknown",
expectedHashrateThs: 0,
watts: 0,
workerNameMatchers: [],
};
export type ThreadRow = {
tid: number;
connections: number;
subscriptions: number;
hashrateThs: number;
};
function num(s: string | undefined | null): number {
if (!s) return 0;
const m = s.replace(/,/g, "").match(/-?\d+(\.\d+)?/);
return m ? Number(m[0]) : 0;
}
function thsFromHashrateField(field: string): number {
const numMatch = field.match(/-?\d+(?:\.\d+)?/);
if (!numMatch) return 0;
const v = Number(numMatch[0]);
if (/Gh\/s/i.test(field)) return v / 1_000;
if (/Mh\/s/i.test(field)) return v / 1_000_000;
if (/Kh\/s/i.test(field)) return v / 1_000_000_000;
return v;
}
function parseAgeS(s: string): number | null {
const m = s.match(/-?\d+(?:\.\d+)?/);
return m ? Number(m[0]) : null;
}
function findTableContaining(html: string, marker: string): HTMLElement | null {
const root = parse(html);
for (const t of root.querySelectorAll("table")) {
if (t.text.includes(marker)) return t;
}
return null;
}
export function matchProfile(userAgent: string, authUsername: string): MinerProfile {
if (/nerdq?axe/i.test(userAgent)) {
const p = MINER_PROFILES.find((x) => x.slug === "nerdqaxe");
if (p) return p;
}
if (/bitaxe/i.test(userAgent)) {
const p = MINER_PROFILES.find((x) => x.slug === "bitaxe");
if (p) return p;
}
const dotIdx = authUsername.indexOf(".");
if (dotIdx >= 0) {
const worker = authUsername.slice(dotIdx + 1).toLowerCase();
for (const p of MINER_PROFILES) {
for (const m of p.workerNameMatchers) {
if (worker.includes(m.toLowerCase())) return p;
}
}
}
return UNKNOWN_PROFILE;
}
export function parseClientsHtml(html: string): MinerStat[] {
const table = findTableContaining(html, "Auth Username");
if (!table) return [];
const out: MinerStat[] = [];
const trs = table.querySelectorAll("tr");
for (let i = 1; i < trs.length; i++) {
const tds = trs[i]!.querySelectorAll("td");
if (tds.length < 11) continue;
const remoteHost = tds[1]!.text.trim();
const authUsername = tds[2]!.text.trim();
const subbedField = tds[3]!.text.trim();
const lastAcceptedField = tds[4]!.text.trim();
const vdiffField = tds[5]!.text.trim();
const diffAField = tds[6]!.text.trim();
const diffRField = tds[7]!.text.trim();
const hashrateField = tds[8]!.text.trim();
const userAgent = tds[10]!.text.trim();
const subscribed = !subbedField.toLowerCase().startsWith("not subscribed");
const lastShareAgeS = lastAcceptedField === "N/A" ? null : parseAgeS(lastAcceptedField);
const vdiff = num(vdiffField);
const diffAMatch = diffAField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)/);
const diffAcceptedSum = diffAMatch ? Number(diffAMatch[1]) : 0;
const diffAcceptedCount = diffAMatch ? Number(diffAMatch[2]) : 0;
const diffRMatch = diffRField.match(/(-?\d+(?:\.\d+)?)\s*\((\d+)\)\s*([\d.]+)%/);
const diffRejectedSum = diffRMatch ? Number(diffRMatch[1]) : 0;
const diffRejectedCount = diffRMatch ? Number(diffRMatch[2]) : 0;
const rejectPct = diffRMatch ? Number(diffRMatch[3]) : 0;
let hashrateThs = 0;
let hashrateAgeS: number | null = null;
if (hashrateField !== "N/A") {
hashrateThs = thsFromHashrateField(hashrateField);
const ageMatch = hashrateField.match(/\(\s*(-?\d+(?:\.\d+)?)\s*s\s*\)/);
hashrateAgeS = ageMatch ? Number(ageMatch[1]) : null;
}
const profile = matchProfile(userAgent, authUsername);
let status: MinerStat["status"];
if (!subscribed) status = "idle";
else if (hashrateThs <= 0) status = "idle";
else if (hashrateAgeS !== null && hashrateAgeS > 180) status = "stale";
else status = "hashing";
out.push({
authUsername,
remoteHost,
nickname: authUsername.split(".").slice(1).join(".") || authUsername || remoteHost || "Unknown miner",
model: profile.model,
location: "unassigned",
expectedHashrateThs: profile.expectedHashrateThs,
watts: profile.watts,
hashrateThs,
hashrateAgeS,
lastShareAgeS,
diffAcceptedSum,
diffAcceptedCount,
diffRejectedSum,
diffRejectedCount,
rejectPct,
vdiff,
userAgent,
subscribed,
status,
});
}
return out;
}
export function parseThreadsHtml(html: string): ThreadRow[] {
const table = findTableContaining(html, "Approx. Hashrate");
if (!table) return [];
const out: ThreadRow[] = [];
const trs = table.querySelectorAll("tr");
for (let i = 1; i < trs.length; i++) {
const tds = trs[i]!.querySelectorAll("td");
if (tds.length < 4) continue;
out.push({
tid: num(tds[0]!.text),
connections: num(tds[1]!.text),
subscriptions: num(tds[2]!.text),
hashrateThs: thsFromHashrateField(tds[3]!.text.trim()),
});
}
return out;
}
@@ -0,0 +1,471 @@
import { config } from "../config.js";
import { logger } from "../logger.js";
import { digestFetch } from "./digest.js";
import { parseClientsHtml, parseThreadsHtml } from "./parse.js";
import type { CurrentJob, DatumSnapshot, MinerStat, NetworkStat, PoolStat } from "./types.js";
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
const DEFAULT_TIMEOUT_MS = 10_000;
const EMPTY_POOL: PoolStat = {
combinedHashrateThs: 0,
totalConnections: 0,
totalSubscriptions: 0,
activeThreads: 0,
sharesAccepted: 0,
sharesRejected: 0,
uptimeSeconds: 0,
connectionStatus: "unknown",
poolHost: "",
poolTag: "",
minerTag: "",
poolDifficulty: 0,
};
const EMPTY_JOB: CurrentJob = {
blockHeight: 0,
blockValueSats: 0,
difficulty: 0,
bits: "",
prevBlock: "",
target: "",
version: "",
weight: 0,
size: 0,
txnCount: 0,
timeInfo: "",
};
const EMPTY_NETWORK: NetworkStat = {
blockHeight: 0,
hashrateEh: 0,
difficulty: 0,
source: "",
};
let lastSnapshot: DatumSnapshot = {
ok: false,
fetchedAt: 0,
pool: EMPTY_POOL,
job: EMPTY_JOB,
network: EMPTY_NETWORK,
miners: [],
error: { code: "NOT_YET_POLLED", message: "Poller has not run yet" },
};
let timer: NodeJS.Timeout | null = null;
let pollInFlight = false;
type ContributionEntry = {
miner: MinerStat;
lastRawAcceptedWork: number;
lastRawAcceptedShares: number;
totalAcceptedWork: number;
totalAcceptedShares: number;
firstSeenAt: number;
lastSeenAt: number;
currentlyConnected: boolean;
};
const contributionLedger = new Map<string, ContributionEntry>();
let ledgerLoaded = false;
let ledgerDirty = false;
let ledgerWriteInFlight: Promise<void> | null = null;
type PersistedContributionLedger = {
version: 1;
updatedAt: number;
entries: Array<[string, ContributionEntry]>;
};
function formatErr(err: unknown): string {
if (!(err instanceof Error)) return String(err);
const cause = (err as Error & { cause?: unknown }).cause;
if (cause instanceof Error) {
const code = (cause as Error & { code?: string }).code;
const hostname = (cause as Error & { hostname?: string }).hostname;
if (code === "ENOTFOUND" && hostname) {
const datumHost = new URL(config.datum.url).hostname;
const hint =
hostname === datumHost
? "; make sure gashboard is attached to Umbrel's Docker network or set DATUM_URL to a hostname/IP reachable from the API container"
: "";
return `${err.message}: DNS could not resolve ${hostname} (${code})${hint}`;
}
return code ? `${err.message}: ${cause.message} (${code})` : `${err.message}: ${cause.message}`;
}
return err.message;
}
function abortableSignal(timeoutMs: number): { signal: AbortSignal; cancel: () => void } {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("upstream_timeout")), timeoutMs);
return { signal: ctrl.signal, cancel: () => clearTimeout(t) };
}
function datumHttpMessage(res: Response, path: string): string {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400) {
return location
? `Datum ${path} redirected to ${location}; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`
: `Datum ${path} redirected; DATUM_URL is probably pointing at the Umbrel web proxy, not the Datum admin API`;
}
return `Datum ${path} returned ${res.status}`;
}
function isUmbrelShellHtml(html: string): boolean {
return /<title>\s*Umbrel\s*<\/title>/i.test(html) || /<div\s+id=["']root["']/i.test(html);
}
type MempoolHashrate = {
currentHashrate?: number;
currentDifficulty?: number;
};
function minerIdentity(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function validContributionEntry(entry: ContributionEntry): boolean {
return (
entry &&
typeof entry === "object" &&
typeof entry.lastRawAcceptedWork === "number" &&
typeof entry.lastRawAcceptedShares === "number" &&
typeof entry.totalAcceptedWork === "number" &&
typeof entry.totalAcceptedShares === "number" &&
typeof entry.firstSeenAt === "number" &&
typeof entry.lastSeenAt === "number" &&
entry.miner &&
typeof entry.miner === "object"
);
}
async function loadContributionLedger(): Promise<void> {
if (ledgerLoaded) return;
ledgerLoaded = true;
try {
const raw = await readFile(config.datum.contributionLedgerPath, "utf8");
const parsed = JSON.parse(raw) as PersistedContributionLedger;
if (parsed.version !== 1 || !Array.isArray(parsed.entries)) {
throw new Error("unsupported contribution ledger format");
}
contributionLedger.clear();
for (const [key, entry] of parsed.entries) {
if (typeof key === "string" && validContributionEntry(entry)) {
contributionLedger.set(key, { ...entry, currentlyConnected: false });
}
}
logger.info(
{ entries: contributionLedger.size, path: config.datum.contributionLedgerPath },
"contribution_ledger_loaded",
);
} catch (err) {
const code = err instanceof Error ? (err as Error & { code?: string }).code : undefined;
if (code === "ENOENT") return;
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_load_failed");
}
}
function scheduleContributionLedgerSave(): void {
ledgerDirty = true;
if (ledgerWriteInFlight) return;
ledgerWriteInFlight = saveContributionLedger()
.catch((err) => {
logger.warn({ reason: formatErr(err), path: config.datum.contributionLedgerPath }, "contribution_ledger_save_failed");
})
.finally(() => {
ledgerWriteInFlight = null;
if (ledgerDirty) scheduleContributionLedgerSave();
});
}
async function saveContributionLedger(): Promise<void> {
if (!ledgerDirty) return;
ledgerDirty = false;
const path = config.datum.contributionLedgerPath;
await mkdir(dirname(path), { recursive: true });
const tmpPath = `${path}.${process.pid}.tmp`;
const payload: PersistedContributionLedger = {
version: 1,
updatedAt: Date.now(),
entries: [...contributionLedger.entries()],
};
await writeFile(tmpPath, `${JSON.stringify(payload)}\n`, "utf8");
await rename(tmpPath, path);
}
async function applyContributionLedger(miners: MinerStat[], fetchedAt: number): Promise<MinerStat[]> {
await loadContributionLedger();
let changed = false;
for (const entry of contributionLedger.values()) {
entry.currentlyConnected = false;
}
const connectedKeys = new Set<string>();
for (const miner of miners) {
const key = minerIdentity(miner);
connectedKeys.add(key);
const existing = contributionLedger.get(key);
if (!existing) {
contributionLedger.set(key, {
miner,
lastRawAcceptedWork: miner.diffAcceptedSum,
lastRawAcceptedShares: miner.diffAcceptedCount,
totalAcceptedWork: miner.diffAcceptedSum,
totalAcceptedShares: miner.diffAcceptedCount,
firstSeenAt: fetchedAt,
lastSeenAt: fetchedAt,
currentlyConnected: true,
});
changed = true;
continue;
}
const workDelta = miner.diffAcceptedSum - existing.lastRawAcceptedWork;
const shareDelta = miner.diffAcceptedCount - existing.lastRawAcceptedShares;
existing.totalAcceptedWork += workDelta >= 0 ? workDelta : miner.diffAcceptedSum;
existing.totalAcceptedShares += shareDelta >= 0 ? shareDelta : miner.diffAcceptedCount;
if (workDelta > 0 || shareDelta > 0 || workDelta < 0 || shareDelta < 0) changed = true;
existing.lastRawAcceptedWork = miner.diffAcceptedSum;
existing.lastRawAcceptedShares = miner.diffAcceptedCount;
existing.lastSeenAt = fetchedAt;
existing.currentlyConnected = true;
existing.miner = miner;
}
if (changed) scheduleContributionLedgerSave();
const totalWork = [...contributionLedger.values()].reduce((sum, entry) => sum + entry.totalAcceptedWork, 0);
const decorate = (miner: MinerStat, entry: ContributionEntry): MinerStat => ({
...miner,
contributionAcceptedWork: entry.totalAcceptedWork,
contributionAcceptedShares: entry.totalAcceptedShares,
contributionPct: totalWork > 0 ? (entry.totalAcceptedWork / totalWork) * 100 : 0,
contributionFirstSeenAt: entry.firstSeenAt,
contributionLastSeenAt: entry.lastSeenAt,
currentlyConnected: entry.currentlyConnected,
});
const live = miners.map((miner) => decorate(miner, contributionLedger.get(minerIdentity(miner))!));
const offline = [...contributionLedger.entries()]
.filter(([key]) => !connectedKeys.has(key))
.map(([, entry]) =>
decorate(
{
...entry.miner,
hashrateThs: 0,
hashrateAgeS: null,
subscribed: false,
status: "idle",
},
entry,
),
);
return [...live, ...offline].sort((a, b) => (b.contributionAcceptedWork ?? 0) - (a.contributionAcceptedWork ?? 0));
}
async function fetchNetworkStats(): Promise<NetworkStat> {
const timeout = abortableSignal(DEFAULT_TIMEOUT_MS);
try {
const [heightRes, hashrateRes] = await Promise.all([
fetch(`${config.mempool.url}/blocks/tip/height`, { signal: timeout.signal }),
fetch(`${config.mempool.url}/v1/mining/hashrate/3d`, { signal: timeout.signal }),
]);
if (!heightRes.ok || !hashrateRes.ok) {
throw new Error(`mempool returned ${heightRes.status}/${hashrateRes.status}`);
}
const [heightText, hashrateJson] = await Promise.all([
heightRes.text(),
hashrateRes.json() as Promise<MempoolHashrate>,
]);
const blockHeight = Number(heightText);
return {
blockHeight: Number.isFinite(blockHeight) ? blockHeight : 0,
hashrateEh: (hashrateJson.currentHashrate ?? 0) / 1e18,
difficulty: hashrateJson.currentDifficulty ?? 0,
source: config.mempool.url,
};
} finally {
timeout.cancel();
}
}
async function pollOnce(): Promise<DatumSnapshot> {
const fetchedAt = Date.now();
const datumUrl = config.datum.url;
// /clients (admin Digest-gated) and /threads (public) in parallel.
const clientsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS);
const threadsTimeout = abortableSignal(DEFAULT_TIMEOUT_MS);
try {
const [clientsResSettled, threadsResSettled] = await Promise.allSettled([
digestFetch({
url: `${datumUrl}/clients`,
creds: { username: config.datum.adminUser, password: config.datum.adminPassword },
signal: clientsTimeout.signal,
}),
fetch(`${datumUrl}/threads`, { signal: threadsTimeout.signal, redirect: "manual" }),
]);
clientsTimeout.cancel();
threadsTimeout.cancel();
if (clientsResSettled.status === "rejected") {
const reason = formatErr(clientsResSettled.reason);
logger.warn({ reason, url: `${datumUrl}/clients` }, "datum_clients_fetch_failed");
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_UNREACHABLE", message: reason },
};
}
const clientsRes = clientsResSettled.value;
if (clientsRes.status === 401) {
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: {
code: "DATUM_AUTH_FAIL",
message: "Datum rejected admin credentials (check DATUM_ADMIN_PASSWORD)",
},
};
}
if (clientsRes.status !== 200) {
const message = datumHttpMessage(clientsRes, "/clients");
logger.warn(
{ status: clientsRes.status, url: `${datumUrl}/clients`, location: clientsRes.headers.get("location") },
"datum_clients_bad_status",
);
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_HTTP", message },
};
}
const clientsHtml = await clientsRes.text();
if (isUmbrelShellHtml(clientsHtml)) {
const message =
"DATUM_URL returned the Umbrel web shell, not Datum /clients; use the Datum container admin API URL";
logger.warn({ url: `${datumUrl}/clients` }, "datum_clients_wrong_html");
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "DATUM_BAD_RESPONSE", message },
};
}
let threadsHtml = "";
if (threadsResSettled.status === "fulfilled" && threadsResSettled.value.ok) {
threadsHtml = await threadsResSettled.value.text();
} else if (
threadsResSettled.status === "fulfilled" &&
threadsResSettled.value.status >= 300 &&
threadsResSettled.value.status < 400
) {
logger.warn(
{
status: threadsResSettled.value.status,
url: `${datumUrl}/threads`,
location: threadsResSettled.value.headers.get("location"),
},
"datum_threads_redirected",
);
} else if (threadsResSettled.status === "rejected") {
logger.warn(
{ reason: formatErr(threadsResSettled.reason) },
"datum_threads_fetch_failed",
);
}
const miners = await applyContributionLedger(parseClientsHtml(clientsHtml), fetchedAt);
const threads = threadsHtml ? parseThreadsHtml(threadsHtml) : [];
if (miners.length === 0) {
logger.warn(
{ url: `${datumUrl}/clients`, bytes: clientsHtml.length },
"datum_clients_no_miners_parsed",
);
}
const combinedHashrateThs = miners.reduce((s, m) => s + m.hashrateThs, 0);
const totalSubscriptions =
threads.reduce((s, t) => s + t.subscriptions, 0) ||
miners.filter((m) => m.subscribed).length;
const totalConnections =
threads.reduce((s, t) => s + t.connections, 0) || miners.length;
const sharesAccepted = miners.reduce((s, m) => s + m.diffAcceptedCount, 0);
const sharesRejected = miners.reduce((s, m) => s + m.diffRejectedCount, 0);
let network = lastSnapshot.network.blockHeight > 0 ? lastSnapshot.network : EMPTY_NETWORK;
try {
network = await fetchNetworkStats();
} catch (err) {
logger.warn({ reason: formatErr(err), url: config.mempool.url }, "mempool_fetch_failed");
}
return {
ok: true,
fetchedAt,
pool: {
...EMPTY_POOL,
combinedHashrateThs,
totalConnections,
totalSubscriptions,
activeThreads: threads.length,
sharesAccepted,
sharesRejected,
connectionStatus: "ok",
},
job: {
...EMPTY_JOB,
blockHeight: network.blockHeight,
difficulty: network.difficulty,
},
network,
miners,
};
} catch (err) {
clientsTimeout.cancel();
threadsTimeout.cancel();
const reason = formatErr(err);
logger.warn({ reason }, "datum_poll_unexpected_error");
return {
...lastSnapshot,
ok: false,
fetchedAt,
error: { code: "POLL_ERROR", message: reason },
};
}
}
export function startPoller(): void {
if (timer) return;
const tick = async (): Promise<void> => {
if (pollInFlight) return;
pollInFlight = true;
try {
lastSnapshot = await pollOnce();
} finally {
pollInFlight = false;
}
};
void tick();
timer = setInterval(() => void tick(), config.datum.pollIntervalMs);
}
export function stopPoller(): void {
if (timer) clearInterval(timer);
timer = null;
}
export function getSnapshot(): DatumSnapshot {
return lastSnapshot;
}
@@ -0,0 +1,68 @@
import type { MinerProfile } from "./types.js";
// Live UserAgent strings observed on this Umbrel (2026-05-06):
// NerdQAxe → "NerdQAxe/BM1370/v1.0.36" (self-identifies)
// Bitaxe → "bitaxe/BM1368/v2.7.1" or "cgminer/4.11.1" depending on firmware
// Avalon Nano 3 / Avalon Mini 3 → "cgminer/4.11.1" (match via worker-name suffix).
export const MINER_PROFILES: readonly MinerProfile[] = [
{
slug: "nerdqaxe",
nickname: "QU4CK",
model: "NerdQAxe+",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 4.5,
watts: 80,
workerNameMatchers: ["nerdqaxe", "qu4ck", "quack"],
},
{
slug: "bitaxe-bigpapa",
nickname: "BigPapa",
model: "Bitaxe",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 1.2,
watts: 18,
workerNameMatchers: ["bigpapa", "big-papa", "big_papa"],
},
{
slug: "bitaxe",
nickname: "P1XEL",
model: "Bitaxe",
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 1.2,
watts: 18,
workerNameMatchers: ["bitaxe", "p1xel", "pixel"],
},
{
slug: "avalon-nano-3",
nickname: "N4N0",
model: "Avalon Canaan Nano 3",
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 4.0,
watts: 140,
workerNameMatchers: ["nano", "nano3", "n4n0"],
},
{
slug: "avalon-mini-3",
nickname: "M1N1",
model: "Avalon Mini 3",
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 37.0,
watts: 800,
workerNameMatchers: ["mini", "mini3", "m1n1"],
},
] as const;
export function profileForWorker(workerName: string): MinerProfile | null {
const lower = workerName.toLowerCase();
for (const p of MINER_PROFILES) {
for (const m of p.workerNameMatchers) {
if (lower.includes(m)) return p;
}
}
return null;
}
@@ -0,0 +1,31 @@
import { Router } from "express";
import { isPubkeyAllowed } from "../nostr/allowlist.js";
import { requireAuth } from "../auth/middleware.js";
import { getSnapshot } from "./poller.js";
import { upstreamUnavailable } from "../errors.js";
export const datumRouter = Router();
datumRouter.use(requireAuth);
datumRouter.get("/stats", (_req, res, next) => {
const snap = getSnapshot();
if (!snap) return next(upstreamUnavailable());
res.json(snap);
});
datumRouter.get("/stream", (req, res) => {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
const send = () => {
if (!isPubkeyAllowed(req.session!.pubkey)) { res.end(); return; }
const snap = getSnapshot();
if (snap) res.write(`data: ${JSON.stringify(snap)}\n\n`);
};
send();
const interval = setInterval(send, 5_000);
res.on("close", () => clearInterval(interval));
});
@@ -0,0 +1,84 @@
export type MinerProfile = {
slug: string;
nickname: string;
model: string;
ownerLabel: string;
locationLabel: string;
expectedHashrateThs: number;
watts: number;
workerNameMatchers: readonly string[];
};
export type MinerStat = {
authUsername: string;
remoteHost: string;
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;
diffAcceptedSum: number;
diffAcceptedCount: number;
diffRejectedSum: number;
diffRejectedCount: number;
rejectPct: number;
vdiff: number;
userAgent: string;
subscribed: boolean;
status: 'hashing' | 'stale' | 'idle';
contributionAcceptedWork?: number;
contributionAcceptedShares?: number;
contributionPct?: number;
contributionFirstSeenAt?: number;
contributionLastSeenAt?: number;
currentlyConnected?: boolean;
};
export type PoolStat = {
combinedHashrateThs: number;
totalConnections: number;
totalSubscriptions: number;
activeThreads: number;
sharesAccepted: number;
sharesRejected: number;
uptimeSeconds: number;
connectionStatus: string;
poolHost: string;
poolTag: string;
minerTag: string;
poolDifficulty: number;
};
export type CurrentJob = {
blockHeight: number;
blockValueSats: number;
difficulty: number;
bits: string;
prevBlock: string;
target: string;
version: string;
weight: number;
size: number;
txnCount: number;
timeInfo: string;
};
export type NetworkStat = {
blockHeight: number;
hashrateEh: number;
difficulty: number;
source: string;
};
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
network: NetworkStat;
miners: MinerStat[];
error?: { code: string; message: string };
};
+31
View File
@@ -0,0 +1,31 @@
import type { Request, Response, NextFunction } from "express";
import { logger } from "./logger.js";
export class AppError extends Error {
readonly status: number;
readonly code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
export const unauthorized = (code = "unauthorized") =>
new AppError(401, code, "Authentication required.");
export const forbidden = (code = "forbidden") =>
new AppError(403, code, "Access denied.");
export const badRequest = (code = "bad_request", message = "Invalid request.") =>
new AppError(400, code, message);
export const upstreamUnavailable = () =>
new AppError(503, "upstream_unavailable", "Upstream temporarily unavailable.");
export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction) {
if (res.headersSent) return;
if (err instanceof AppError) {
res.status(err.status).json({ error: { code: err.code, message: err.message } });
return;
}
logger.error({ err }, "unhandled_error");
res.status(500).json({ error: { code: "internal_error", message: "Something went wrong." } });
}
+17
View File
@@ -0,0 +1,17 @@
import { config } from "./config.js";
import { logger } from "./logger.js";
import { startPoller } from "./datum/poller.js";
import { buildApp } from "./server.js";
startPoller();
buildApp().listen(config.port, () => {
logger.info(
{
port: config.port,
datum: config.datum.url,
allowedNpubs: config.nostr.allowedHexPubkeys.length,
},
"gashboard api listening",
);
});
+142
View File
@@ -0,0 +1,142 @@
import { pino } from "pino";
import { Writable } from "node:stream";
import { config } from "./config.js";
const levelNames: Record<number, string> = {
10: "trace",
20: "debug",
30: "info",
40: "warn",
50: "error",
60: "fatal",
};
let buffered = "";
const logStream = new Writable({
write(chunk, _encoding, callback) {
buffered += chunk.toString();
const lines = buffered.split("\n");
buffered = lines.pop() ?? "";
for (const line of lines) {
if (!line) continue;
process.stdout.write(`${formatLogLine(line)}\n`);
}
callback();
},
});
export const logger = pino({
level: config.logLevel,
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'*.password',
'*.token',
'*.jwt',
'*.sig',
],
censor: "[REDACTED]",
},
base: { app: "gashboard" },
}, logStream);
function formatLogLine(line: string): string {
try {
const record = JSON.parse(line) as Record<string, unknown>;
const handled = new Set([
"level",
"time",
"pid",
"hostname",
"app",
"req",
"res",
"responseTime",
"port",
"datum",
"allowedNpubs",
"staticDir",
"url",
"reason",
"err",
"msg",
]);
const parts: string[] = [
`time=${formatTime(record.time)}`,
`level=${formatLevel(record.level)}`,
];
pushField(parts, "app", record.app);
pushRequestFields(parts, record.req);
pushResponseFields(parts, record.res);
pushField(parts, "responseTime", record.responseTime);
pushField(parts, "port", record.port);
pushField(parts, "datum", record.datum);
pushField(parts, "allowedNpubs", record.allowedNpubs);
pushField(parts, "staticDir", record.staticDir);
pushField(parts, "url", record.url);
pushField(parts, "reason", record.reason);
if (record.err && typeof record.err === "object") {
const err = record.err as Record<string, unknown>;
pushField(parts, "err", err.message ?? err.type ?? record.err);
}
for (const [key, value] of Object.entries(record)) {
if (handled.has(key)) continue;
if (!isScalar(value)) continue;
pushField(parts, key, value);
}
pushField(parts, "msg", record.msg);
return parts.join(" ");
} catch {
return line;
}
}
function formatTime(value: unknown): string {
if (typeof value === "number") return new Date(value).toISOString();
if (typeof value === "string") return value;
return new Date().toISOString();
}
function formatLevel(value: unknown): string {
if (typeof value === "number") return levelNames[value] ?? String(value);
if (typeof value === "string") return value;
return "info";
}
function pushRequestFields(parts: string[], value: unknown): void {
if (!value || typeof value !== "object") return;
const req = value as Record<string, unknown>;
pushField(parts, "method", req.method);
pushField(parts, "path", req.url);
pushField(parts, "remote", req.remoteAddress);
}
function pushResponseFields(parts: string[], value: unknown): void {
if (!value || typeof value !== "object") return;
const res = value as Record<string, unknown>;
pushField(parts, "status", res.statusCode);
}
function pushField(parts: string[], key: string, value: unknown): void {
if (value === undefined || value === null || value === "") return;
parts.push(`${key}=${formatValue(value)}`);
}
function isScalar(value: unknown): value is string | number | boolean {
return typeof value === "string" || typeof value === "number" || typeof value === "boolean";
}
function formatValue(value: unknown): string {
const raw = typeof value === "string" ? value : JSON.stringify(value);
if (!raw) return '""';
if (/^[A-Za-z0-9_./:@+-]+$/.test(raw)) return raw;
return JSON.stringify(raw);
}
@@ -0,0 +1,7 @@
import { config } from "../config.js";
import { AccessList } from "../access/store.js";
export const accessList = new AccessList(config.nostr.accessListPath, config.nostr.ownerHexPubkeys, config.nostr.allowedHexPubkeys);
export const isPubkeyAllowed = (pubkey: string): boolean => accessList.isAllowed(pubkey);
export const isOwner = (pubkey: string): boolean => accessList.isOwner(pubkey);
export const allowedPubkeys = (): string[] => accessList.keys();
@@ -0,0 +1,69 @@
import { verifyEvent, type Event as NostrEvent } from "nostr-tools";
export type Nip98Input = {
authHeader: string | undefined;
method: string;
fullUrl: string;
};
export type Nip98Result =
| { ok: true; pubkey: string }
| { ok: false; reason: string };
const KIND_HTTP_AUTH = 27235;
const FRESHNESS_WINDOW_S = 120;
function findTag(event: NostrEvent, name: string): string | undefined {
for (const tag of event.tags) {
if (tag[0] === name && typeof tag[1] === "string") return tag[1];
}
return undefined;
}
function timingSafeEqualStr(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
return diff === 0;
}
export function verifyNip98(input: Nip98Input): Nip98Result {
const { authHeader, method, fullUrl } = input;
if (!authHeader) return { ok: false, reason: "missing_authorization" };
const match = authHeader.match(/^Nostr\s+(.+)$/i);
if (!match) return { ok: false, reason: "wrong_scheme" };
const b64 = match[1]!.trim();
let event: NostrEvent;
try {
const json = Buffer.from(b64, "base64").toString("utf8");
event = JSON.parse(json) as NostrEvent;
} catch {
return { ok: false, reason: "decode_failed" };
}
if (event.kind !== KIND_HTTP_AUTH) return { ok: false, reason: "wrong_kind" };
if (!verifyEvent(event)) return { ok: false, reason: "bad_signature" };
const nowS = Math.floor(Date.now() / 1000);
if (Math.abs(nowS - event.created_at) > FRESHNESS_WINDOW_S) {
return { ok: false, reason: "stale" };
}
const tagMethod = findTag(event, "method");
const tagUrl = findTag(event, "u");
if (!tagMethod || !tagUrl) return { ok: false, reason: "missing_tags" };
if (!timingSafeEqualStr(tagMethod.toUpperCase(), method.toUpperCase())) {
return { ok: false, reason: "method_mismatch" };
}
if (!timingSafeEqualStr(tagUrl, fullUrl)) {
return { ok: false, reason: "url_mismatch" };
}
if (!/^[0-9a-f]{64}$/.test(event.pubkey)) {
return { ok: false, reason: "malformed_pubkey" };
}
return { ok: true, pubkey: event.pubkey };
}
+124
View File
@@ -0,0 +1,124 @@
import express from "express";
import helmet from "helmet";
import cors from "cors";
import { pinoHttp } from "pino-http";
import path from "node:path";
import { fileURLToPath } from "node:url";
import fs from "node:fs";
import { accessRouter } from "./access/routes.js";
import { config } from "./config.js";
import { logger } from "./logger.js";
import { authRouter } from "./auth/routes.js";
import { chatRouter } from "./chat/routes.js";
import { datumRouter } from "./datum/routes.js";
import { errorHandler } from "./errors.js";
export function buildApp() {
const app = express();
app.disable("x-powered-by");
app.set("trust proxy", 1);
app.use(
helmet({
contentSecurityPolicy: {
useDefaults: true,
directives: {
"default-src": ["'self'"],
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:", "https:"],
"connect-src": ["'self'", "wss://relay.primal.net"],
"font-src": ["'self'", "data:"],
"manifest-src": ["'self'"],
"worker-src": ["'self'"],
"frame-ancestors": ["'none'"],
// The canonical provider validates the same-host dashboard broker.
"frame-src": config.providerPath ? ["'self'", "http:", "https:"] : ["'self'"],
"upgrade-insecure-requests": null,
},
},
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
originAgentCluster: false,
}),
);
if (config.corsOrigin) {
app.use(cors({ origin: config.corsOrigin, credentials: false }));
}
app.use(express.json({ limit: "32kb" }));
app.use(
pinoHttp({
logger,
autoLogging: {
ignore: (req) =>
req.url === "/healthz" ||
req.url === "/favicon.ico" ||
req.url.startsWith("/assets/"),
},
}),
);
app.get("/healthz", (_req, res) => {
res.json({ ok: true });
});
app.use("/api/auth", authRouter);
app.use("/api/access", accessRouter);
app.use("/api/chat", chatRouter);
app.use("/api/datum", datumRouter);
app.get("/nostr-provider.js", (_req, res) => {
res.setHeader("Cache-Control", "no-cache, no-store");
const provider = config.providerPath && (fs.existsSync(config.providerPath) ? config.providerPath : "/app/nostr-provider.js");
if (!provider || !fs.existsSync(provider)) {
res.status(503).type("application/javascript").send("/* Archipelago signer is not installed. */");
return;
}
res.type("application/javascript").send("if (!window.nostr) {\n" + fs.readFileSync(provider, "utf8") + "\n}");
});
// Unknown API paths must never become a successful HTML SPA response.
app.use("/api", (_req, res) => { res.status(404).json({ error: { code: "not_found" } }); });
const staticDir = config.staticDir
? config.staticDir
: resolveDefaultStaticDir();
if (staticDir && fs.existsSync(staticDir)) {
logger.info({ staticDir }, "serving web assets");
app.use(
express.static(staticDir, {
index: false,
maxAge: "1h",
setHeaders: (res, filePath) => {
if (filePath.endsWith("sw.js") || filePath.endsWith("manifest.webmanifest")) {
res.setHeader("Cache-Control", "no-cache");
}
},
}),
);
app.get(/.*/, (_req, res, next) => {
const indexFile = path.join(staticDir, "index.html");
if (!fs.existsSync(indexFile)) return next();
res.setHeader("Cache-Control", "no-cache");
if (config.providerPath) {
const html = fs.readFileSync(indexFile, "utf8");
res.type("html").send(html.replace("</head>",
'<script src="/nostr-provider.js?v=archipelago-v1" data-app-id="gashboard" data-no-nip98></script></head>'));
} else {
res.sendFile(indexFile);
}
});
} else {
logger.warn({ staticDir }, "web assets directory not found");
}
app.use(errorHandler);
return app;
}
function resolveDefaultStaticDir(): string {
const here = path.dirname(fileURLToPath(import.meta.url));
return path.resolve(here, "../../web/dist");
}
@@ -0,0 +1,88 @@
import { after, test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { once } from "node:events";
import { finalizeEvent, generateSecretKey, getPublicKey, nip19 } from "nostr-tools";
import { AccessList } from "../src/access/store.js";
const dir = mkdtempSync(join(tmpdir(), "gashboard-access-"));
after(() => rmSync(dir, { recursive: true, force: true }));
const owner = generateSecretKey();
const viewer = generateSecretKey();
const outsider = generateSecretKey();
const ownerHex = getPublicKey(owner);
const viewerHex = getPublicKey(viewer);
const viewerNpub = nip19.npubEncode(viewerHex);
test("membership survives restart; owners cannot be removed and corruption fails closed", () => {
const file = join(dir, "store.json");
const store = new AccessList(file, [ownerHex], []);
store.setViewer(viewerHex, true);
assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), true);
assert.throws(() => store.setViewer(ownerHex, false));
store.setViewer(viewerHex, false);
assert.equal(new AccessList(file, [ownerHex], []).isAllowed(viewerHex), false);
// Removed platform owners are not retained in the persisted viewer list.
assert.equal(new AccessList(file, [], []).isAllowed(ownerHex), false);
writeFileSync(file, "{}");
assert.throws(() => new AccessList(file, [ownerHex], []));
});
test("signed logins, owner-only invitations, immediate revocation, and provider cache policy", async () => {
process.env.JWT_SECRET = "local-test-only-secret-32-characters-long";
process.env.DATUM_ADMIN_PASSWORD = "local-test-only";
process.env.NOSTR_OWNER_PUBKEYS = ownerHex;
process.env.ACCESS_LIST_PATH = join(dir, "api-access.json");
process.env.ARCHIPELAGO_PROVIDER_PATH = join(dir, "provider.js");
process.env.LOG_LEVEL = "silent";
writeFileSync(process.env.ARCHIPELAGO_PROVIDER_PATH, "window.nostr = { test: true };");
const { buildApp } = await import("../src/server.js");
const server = buildApp().listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address();
assert.ok(address && typeof address !== "string");
const base = `http://127.0.0.1:${address.port}`;
const call = (path: string, token = "", method = "GET", body?: unknown) => fetch(base + path, {
method,
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
...(body ? { body: JSON.stringify(body) } : {}),
});
async function login(key: Uint8Array) {
const event = finalizeEvent({ kind: 27235, created_at: Math.floor(Date.now() / 1000), tags: [["u", base + "/api/auth/login"], ["method", "POST"]], content: "" }, key);
return fetch(base + "/api/auth/login", { method: "POST", headers: { authorization: "Nostr " + Buffer.from(JSON.stringify(event)).toString("base64") } });
}
try {
assert.equal((await call("/api/access")).status, 401);
assert.equal((await login(outsider)).status, 403);
const ownerLogin = await login(owner);
assert.equal(ownerLogin.status, 200);
const ownerToken = (await ownerLogin.json()).token as string;
assert.equal((await call("/api/access", ownerToken, "POST", { npub: "not-a-key" })).status, 400);
assert.equal((await call("/api/access", ownerToken, "POST", { npub: viewerNpub })).status, 200);
const viewerLogin = await login(viewer);
assert.equal(viewerLogin.status, 200);
const viewerToken = (await viewerLogin.json()).token as string;
assert.equal((await call("/api/datum/stats", viewerToken)).status, 200);
assert.equal((await call("/api/access", viewerToken, "POST", { npub: nip19.npubEncode(getPublicKey(outsider)) })).status, 403);
assert.deepEqual(await (await call("/api/access", viewerToken)).json(), { isOwner: false, members: [] });
assert.equal((await call(`/api/access/${nip19.npubEncode(ownerHex)}`, ownerToken, "DELETE")).status, 400);
const stream = await call("/api/datum/stream", viewerToken);
const reader = stream.body!.getReader();
assert.equal((await reader.read()).done, false);
assert.equal((await call(`/api/access/${viewerNpub}`, ownerToken, "DELETE")).status, 200);
assert.equal((await reader.read()).done, true);
assert.equal((await call("/api/datum/stats", viewerToken)).status, 401);
assert.equal((await call("/api/chat", viewerToken)).status, 401);
assert.equal((await login(viewer)).status, 403);
assert.deepEqual(JSON.parse(readFileSync(process.env.ACCESS_LIST_PATH, "utf8")), []);
const provider = await call("/nostr-provider.js");
assert.equal(provider.headers.get("cache-control"), "no-cache, no-store");
assert.match(await provider.text(), /if \(!window.nostr\)/);
assert.equal((await call("/api/not-real")).status, 404);
} finally {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
}
});
@@ -0,0 +1,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseClientsHtml } from "../src/datum/parse.js";
test("multiple miners of one model keep their worker names and full identities", () => {
const row = (username: string, agent: string) => "<tr>" +
["0", "192.0.2.3", username, "Subscribed", "2 s", "16384", "32768 (2)", "0 (0) 0%", "1.2 Th/s (1 s)", "", agent]
.map(value => `<td>${value}</td>`).join("") + "</tr>";
const miners = parseClientsHtml('<table><tr><th>Auth Username</th></tr>' +
row("addressA.kitchen", "bitaxe/BM1368") + row("addressA.garage", "bitaxe/BM1368") + row("addressB.kitchen", "unknown") + '</table>');
assert.equal(miners.length, 3);
assert.deepEqual(miners.map(m => m.nickname), ["kitchen", "garage", "kitchen"]);
assert.equal(new Set(miners.map(m => m.authUsername)).size, 3);
assert.ok(miners.every(m => m.location === "unassigned"));
});
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": false,
"sourceMap": true
},
"include": ["src/**/*.ts"]
}
+20
View File
@@ -0,0 +1,20 @@
<!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" />
<meta name="application-name" content="gashboard" />
<meta name="apple-mobile-web-app-title" content="gashboard" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon.svg" />
<title>gashboard</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+26
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"
}
}
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
<rect width="512" height="512" fill="#07090f"/>
<rect x="34" y="34" width="444" height="444" fill="#0c1120" stroke="#29ffe6" stroke-width="12"/>
<path d="M98 314h68v74H98zm92-118h68v192h-68zm92 54h68v138h-68zm92-126h68v264h-68z" fill="#ff3df0"/>
<path d="M80 256h64l34-82 62 178 58-132 44 70h90" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
<text x="256" y="104" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="54" font-weight="700">G</text>
</svg>

After

Width:  |  Height:  |  Size: 630 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="gashboard">
<rect width="512" height="512" fill="#07090f"/>
<circle cx="256" cy="256" r="188" fill="#0c1120" stroke="#29ffe6" stroke-width="14"/>
<path d="M126 316h62v72h-62zm84-118h62v190h-62zm84 52h62v138h-62zm84-126h62v264h-62z" fill="#ff3df0"/>
<path d="M104 258h66l32-82 60 176 58-132 44 70h54" fill="none" stroke="#29ffe6" stroke-width="18" stroke-linejoin="round" stroke-linecap="round"/>
<text x="256" y="112" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="52" font-weight="700">G</text>
</svg>

After

Width:  |  Height:  |  Size: 622 B

@@ -0,0 +1,34 @@
{
"name": "gashboard",
"short_name": "gashboard",
"description": "Solo mining dashboard for Datum miners.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait-primary",
"background_color": "#07090f",
"theme_color": "#0a0e1a",
"categories": ["utilities", "finance"],
"icons": [
{
"src": "/icons/icon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any"
},
{
"src": "/icons/maskable.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Graphs",
"short_name": "Graphs",
"url": "/graphs",
"description": "Open telemetry graphs"
}
]
}
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 420 220" role="img" aria-label="Avalon Mini 3 miner">
<rect width="420" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="44" y="56" width="332" height="110" rx="16" fill="#1f2937" stroke="#ff3df0" stroke-width="4"/>
<rect x="70" y="78" width="210" height="66" rx="8" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
<path d="M92 96h166M92 112h166M92 128h166" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
<circle cx="326" cy="111" r="34" fill="#07090f" stroke="#ffd84a" stroke-width="5"/>
<path d="M326 78v66M293 111h66M303 88l46 46M349 88l-46 46" stroke="#6cff8c" stroke-width="4"/>
<path d="M76 166h268M104 184h212" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
<circle cx="62" cy="74" r="6" fill="#29ffe6"/>
<circle cx="360" cy="74" r="6" fill="#ff3df0"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="9" stdDeviation="9" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1023 B

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Avalon Nano 3 miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="76" y="44" width="168" height="128" rx="20" fill="#e7edf5" stroke="#29ffe6" stroke-width="4"/>
<rect x="96" y="64" width="128" height="88" rx="14" fill="#111827"/>
<circle cx="160" cy="108" r="42" fill="#07090f" stroke="#ff3df0" stroke-width="6"/>
<path d="M160 68c16 22 16 58 0 80M120 108c22-16 58-16 80 0M132 80c30 4 52 26 56 56M188 80c-4 30-26 52-56 56" stroke="#29ffe6" stroke-width="4" stroke-linecap="round"/>
<rect x="112" y="166" width="96" height="12" rx="6" fill="#475569"/>
<path d="M64 112h28M228 112h28" stroke="#ffd84a" stroke-width="8" stroke-linecap="round"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".45"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 925 B

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="BigPapa Bitaxe miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#shadow)">
<rect x="54" y="62" width="212" height="96" rx="10" fill="#16121f" stroke="#ff3df0" stroke-width="5"/>
<rect x="76" y="84" width="66" height="52" rx="6" fill="#07090f" stroke="#ffd84a" stroke-width="4"/>
<circle cx="109" cy="110" r="20" fill="#0b0f19" stroke="#29ffe6" stroke-width="5"/>
<path d="M109 88v44M87 110h44" stroke="#ff4f78" stroke-width="4"/>
<path d="M178 81h50l14 17-14 17h-50l-14-17z" fill="#ff3df0" opacity=".9"/>
<text x="203" y="104" text-anchor="middle" fill="#07090f" font-family="monospace" font-size="18" font-weight="700">BIG</text>
<rect x="166" y="124" width="76" height="13" rx="4" fill="#29ffe6" opacity=".8"/>
<path d="M74 160h172M96 177h128" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
<path d="M62 56l28-24h34l-12 30zM258 56l-28-24h-34l12 30z" fill="#ffd84a" stroke="#ff3df0" stroke-width="3"/>
<circle cx="61" cy="72" r="6" fill="#6cff8c"/>
<circle cx="259" cy="72" r="6" fill="#6cff8c"/>
</g>
<text x="160" y="207" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">BIGPAPA BITAXE</text>
<defs>
<filter id="shadow">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".6"/>
</filter>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Bitaxe miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="72" y="70" width="176" height="84" rx="12" fill="#152033" stroke="#6cff8c" stroke-width="4"/>
<rect x="94" y="88" width="56" height="48" rx="6" fill="#0b0f19" stroke="#29ffe6" stroke-width="3"/>
<circle cx="122" cy="112" r="18" fill="#07090f" stroke="#ff3df0" stroke-width="5"/>
<path d="M122 94v36M104 112h36" stroke="#ffd84a" stroke-width="3"/>
<rect x="166" y="90" width="54" height="18" rx="3" fill="#29ffe6" opacity=".85"/>
<rect x="166" y="118" width="38" height="10" rx="3" fill="#ff3df0" opacity=".85"/>
<path d="M86 154h148M104 170h112" stroke="#475569" stroke-width="7" stroke-linecap="round"/>
<circle cx="80" cy="80" r="5" fill="#ffd84a"/>
<circle cx="240" cy="80" r="5" fill="#ffd84a"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="Boomer Heater">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="72" y="48" width="176" height="132" rx="14" fill="#3b2f2f" stroke="#ff4f78" stroke-width="4"/>
<rect x="92" y="70" width="136" height="84" rx="8" fill="#1f1717"/>
<path d="M116 142c-18-28 18-36 0-64M160 142c-18-28 18-36 0-64M204 142c-18-28 18-36 0-64" fill="none" stroke="#ff8a4f" stroke-width="8" stroke-linecap="round"/>
<path d="M90 180h140" stroke="#475569" stroke-width="10" stroke-linecap="round"/>
<circle cx="224" cy="58" r="9" fill="#6a7aa0"/>
<text x="160" y="205" text-anchor="middle" fill="#6a7aa0" font-family="monospace" font-size="15" font-weight="700">NO HASHES. JUST GHEI.</text>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 940 B

@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 220" role="img" aria-label="NerdQAxe miner">
<rect width="320" height="220" fill="none"/>
<g filter="url(#g)">
<rect x="58" y="54" width="204" height="112" rx="10" fill="#111827" stroke="#29ffe6" stroke-width="4"/>
<rect x="78" y="74" width="74" height="70" rx="6" fill="#2a102b" stroke="#ff3df0" stroke-width="3"/>
<circle cx="115" cy="109" r="28" fill="#07090f" stroke="#29ffe6" stroke-width="5"/>
<path d="M115 82v54M88 109h54M96 90l38 38M134 90l-38 38" stroke="#6cff8c" stroke-width="3"/>
<rect x="170" y="76" width="70" height="28" rx="4" fill="#05070c" stroke="#ffd84a" stroke-width="3"/>
<path d="M180 92h50" stroke="#ffd84a" stroke-width="4"/>
<path d="M72 166h176M88 184h144" stroke="#475569" stroke-width="8" stroke-linecap="round"/>
<circle cx="81" cy="58" r="8" fill="#ff3df0"/>
<circle cx="239" cy="58" r="8" fill="#29ffe6"/>
</g>
<defs><filter id="g"><feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/></filter></defs>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#0a0d17"/>
<path d="M46 54h164v128H46z" fill="#151b2b" stroke="#29ffe6" stroke-width="4"/>
<circle cx="99" cy="105" r="26" fill="#e8edff"/>
<circle cx="157" cy="105" r="26" fill="#e8edff"/>
<path d="M77 159c25 20 77 20 102 0" fill="none" stroke="#ff3df0" stroke-width="8"/>
<path d="M65 62l126 126M191 62L65 188" stroke="#1f2a4a" stroke-width="5"/>
<text x="128" y="222" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">ANON FOUND HASHES</text>
</svg>

After

Width:  |  Height:  |  Size: 610 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#07090f"/>
<rect x="54" y="42" width="148" height="148" fill="#131a2e" stroke="#29ffe6" stroke-width="5"/>
<path d="M76 76l104 104M180 76L76 180" stroke="#ff4f78" stroke-width="15"/>
<text x="128" y="118" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">BLOCK</text>
<text x="128" y="142" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">DENIED</text>
<text x="128" y="224" text-anchor="middle" fill="#b6c3e0" font-family="monospace" font-size="13">TRY ANOTHER 2 BILLION TIMES</text>
</svg>

After

Width:  |  Height:  |  Size: 691 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#07090f"/>
<g>
<animateTransform attributeName="transform" type="translate" values="0 0;0 -10;0 0" dur=".8s" repeatCount="indefinite"/>
<rect x="74" y="52" width="108" height="108" fill="#131a2e" stroke="#29ffe6" stroke-width="5"/>
<text x="128" y="113" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="24" font-weight="700">?</text>
</g>
<path d="M82 185c24 13 68 13 92 0" fill="none" stroke="#ff3df0" stroke-width="8"/>
<text x="128" y="224" text-anchor="middle" fill="#b6c3e0" font-family="monospace" font-size="14">THIS ONE IS OURS</text>
</svg>

After

Width:  |  Height:  |  Size: 694 B

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#0a0d17"/>
<rect x="65" y="48" width="126" height="145" rx="8" fill="#1b1f2b" stroke="#ff4f78" stroke-width="5"/>
<path d="M89 74h78M89 98h78M89 122h78M89 146h78" stroke="#6a7aa0" stroke-width="7"/>
<path d="M83 181h90" stroke="#ffd84a" stroke-width="8"/>
<text x="128" y="222" text-anchor="middle" fill="#ff4f78" font-family="monospace" font-size="18" font-weight="700">0 H/s AND PROUD</text>
</svg>

After

Width:  |  Height:  |  Size: 511 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#07090f"/>
<path d="M83 72l28-30h50l24 35-12 75-45 36-45-36z" fill="#d9d3c7" stroke="#29ffe6" stroke-width="5"/>
<path d="M86 76h87l-16-30h-45z" fill="#ff3df0"/>
<path d="M93 108h28M143 108h28" stroke="#07090f" stroke-width="7"/>
<path d="M101 145c23 13 49 13 72 0" fill="none" stroke="#07090f" stroke-width="7"/>
<text x="128" y="220" text-anchor="middle" fill="#6cff8c" font-family="monospace" font-size="18" font-weight="700">HASHING. UNBOTHERED.</text>
</svg>

After

Width:  |  Height:  |  Size: 576 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#07090f"/>
<rect x="34" y="47" width="188" height="139" fill="#101624" stroke="#2c3a66" stroke-width="4"/>
<path d="M52 164h154M52 127h154M52 90h154" stroke="#1f2a4a" stroke-width="3"/>
<path d="M55 156l32-2 22-47 31 42 18-9 17-60 31 3" fill="none" stroke="#29ffe6" stroke-width="7"/>
<circle cx="175" cy="80" r="9" fill="#ff4f78"/>
<text x="128" y="222" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">CHART CRIME</text>
</svg>

After

Width:  |  Height:  |  Size: 586 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#07090f"/>
<circle cx="128" cy="112" r="68" fill="#101624" stroke="#29ffe6" stroke-width="5"/>
<g transform-origin="128 112">
<animateTransform attributeName="transform" type="rotate" from="0 128 112" to="360 128 112" dur=".45s" repeatCount="indefinite"/>
<path d="M128 112c38-44 75-21 45 11-16 17-38 7-45-11zM128 112c-38 44-75 21-45-11 16-17 38-7 45 11zM128 112c44 38 21 75-11 45-17-16-7-38 11-45zM128 112c-44-38-21-75 11-45 17 16 7 38-11 45z" fill="#ff3df0"/>
</g>
<circle cx="128" cy="112" r="13" fill="#ffd84a"/>
<text x="128" y="221" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="18" font-weight="700">FAN PANIC</text>
</svg>

After

Width:  |  Height:  |  Size: 776 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#080a10"/>
<circle cx="128" cy="93" r="58" fill="#8ea0c4" stroke="#2c3a66" stroke-width="5"/>
<circle cx="105" cy="86" r="9" fill="#0a0d17"/>
<circle cx="151" cy="86" r="9" fill="#0a0d17"/>
<path d="M103 123h50" stroke="#0a0d17" stroke-width="7"/>
<rect x="45" y="160" width="166" height="42" fill="#131a2e" stroke="#ff4f78" stroke-width="4"/>
<text x="128" y="187" text-anchor="middle" fill="#ff4f78" font-family="monospace" font-size="17" font-weight="700">I TRUST BANKS</text>
<text x="128" y="225" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="14">NORMAL HEATER USER</text>
</svg>

After

Width:  |  Height:  |  Size: 726 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#080a10"/>
<g>
<animateTransform attributeName="transform" type="scale" values="1;1.06;1" dur=".7s" repeatCount="indefinite" additive="sum"/>
<path d="M128 35c38 43 59 75 59 111 0 37-26 68-59 68s-59-31-59-68c0-36 21-68 59-111z" fill="#ff4f78" stroke="#ffd84a" stroke-width="5"/>
</g>
<path d="M106 144h44M105 166h46" stroke="#0a0d17" stroke-width="7"/>
<text x="128" y="232" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="15" font-weight="700">PROOF OF WARMTH</text>
</svg>

After

Width:  |  Height:  |  Size: 616 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#0c1120"/>
<rect x="45" y="56" width="166" height="82" fill="#151b2b" stroke="#ff4f78" stroke-width="5"/>
<rect x="64" y="118" width="128" height="62" fill="#101624" stroke="#29ffe6" stroke-width="4"/>
<path d="M79 151h98" stroke="#ffd84a" stroke-width="7" stroke-dasharray="10 8"/>
<text x="128" y="103" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="26" font-weight="700">BRRR</text>
<text x="128" y="218" text-anchor="middle" fill="#ff3df0" font-family="monospace" font-size="17" font-weight="700">FIAT PATCH NOTES</text>
</svg>

After

Width:  |  Height:  |  Size: 668 B

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#080a10"/>
<path d="M128 31l53 82H75z" fill="#131a2e" stroke="#ff3df0" stroke-width="5"/>
<circle cx="128" cy="134" r="52" fill="#151b2b" stroke="#29ffe6" stroke-width="5"/>
<circle cx="108" cy="126" r="7" fill="#ffd84a"/>
<circle cx="148" cy="126" r="7" fill="#ffd84a"/>
<path d="M102 157h52" stroke="#ff4f78" stroke-width="6"/>
<text x="128" y="220" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="16" font-weight="700">THE ODDS SAID LOL</text>
</svg>

After

Width:  |  Height:  |  Size: 591 B

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#0c1120"/>
<rect x="48" y="54" width="160" height="72" fill="#151b2b" stroke="#ff4f78" stroke-width="5"/>
<g>
<animateTransform attributeName="transform" type="translate" values="0 -22;0 28;0 -22" dur="1s" repeatCount="indefinite"/>
<rect x="76" y="126" width="104" height="42" fill="#6cff8c" stroke="#0a0d17" stroke-width="4"/>
<text x="128" y="153" text-anchor="middle" fill="#0a0d17" font-family="monospace" font-size="19" font-weight="700">FIAT</text>
</g>
<text x="128" y="101" text-anchor="middle" fill="#ffd84a" font-family="monospace" font-size="24" font-weight="700">BRRR</text>
<text x="128" y="222" text-anchor="middle" fill="#ff3df0" font-family="monospace" font-size="15" font-weight="700">INFINITE PATCH</text>
</svg>

After

Width:  |  Height:  |  Size: 856 B

+95
View File
@@ -0,0 +1,95 @@
const CACHE_VERSION = "gashboard-pwa-v4";
const APP_SHELL = ["/", "/manifest.webmanifest", "/icons/icon.svg", "/icons/maskable.svg"];
const MINER_ASSETS = [
"/miners/nerdqaxe.svg",
"/miners/bitaxe.svg",
"/miners/bigpapa-bitaxe.svg",
"/miners/avalon-nano-3.svg",
"/miners/avalon-mini-3.svg",
"/miners/boomer-heater.svg",
];
const STICKER_ASSETS = [
"/stickers/anon-miner.svg",
"/stickers/block-denied.svg",
"/stickers/block-hopium.svg",
"/stickers/boomer-unit.svg",
"/stickers/chad-hash.svg",
"/stickers/chart-crime.svg",
"/stickers/fan-panic.svg",
"/stickers/fiat-npc.svg",
"/stickers/heat-death.svg",
"/stickers/money-printer.svg",
"/stickers/odds-wizard.svg",
"/stickers/printer-loop.svg",
];
self.addEventListener("install", (event) => {
event.waitUntil(caches.open(CACHE_VERSION).then((cache) => cache.addAll([...APP_SHELL, ...MINER_ASSETS, ...STICKER_ASSETS])));
self.skipWaiting();
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((key) => key !== CACHE_VERSION).map((key) => caches.delete(key))))
.then(() => self.clients.claim()),
);
});
self.addEventListener("fetch", (event) => {
const req = event.request;
if (req.method !== "GET") return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (url.pathname === "/nostr-provider.js" || url.pathname === "/nostr-signer") return;
if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") return;
if (req.mode === "navigate") {
event.respondWith(networkFirst(req, "/"));
return;
}
if (
url.pathname.startsWith("/assets/")
|| url.pathname.startsWith("/icons/")
|| url.pathname.startsWith("/miners/")
|| url.pathname.startsWith("/stickers/")
) {
event.respondWith(cacheFirst(req));
return;
}
event.respondWith(networkFirst(req));
});
self.addEventListener("message", (event) => {
if (event.data?.type === "SKIP_WAITING") self.skipWaiting();
});
async function cacheFirst(req) {
const cache = await caches.open(CACHE_VERSION);
const cached = await cache.match(req);
if (cached) return cached;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
}
async function networkFirst(req, fallbackPath) {
const cache = await caches.open(CACHE_VERSION);
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const cached = await cache.match(req);
if (cached) return cached;
if (fallbackPath) {
const fallback = await cache.match(fallbackPath);
if (fallback) return fallback;
}
throw new Error("offline");
}
}
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
import { useStatsStore } from "./stores/stats";
import ChatDrawer from "./components/ChatDrawer.vue";
import ReleaseNotesDrawer from "./components/ReleaseNotesDrawer.vue";
const auth = useAuthStore();
const stats = useStatsStore();
const route = useRoute();
const crt = ref(false);
const chatOpen = ref(false);
const releaseOpen = ref(false);
const LOW_HASHRATE_THS = 10;
const TOP_HASHRATE_THS = 70;
const heatLevel = computed(() => {
const total = stats.snapshot?.pool.combinedHashrateThs ?? 0;
return Math.max(0, Math.min(1, (total - LOW_HASHRATE_THS) / (TOP_HASHRATE_THS - LOW_HASHRATE_THS)));
});
onMounted(() => {
const stored = localStorage.getItem("gashboard.crt") === "1";
crt.value = stored;
if (stored) document.body.classList.add("crt");
});
function toggleCrt(): void {
crt.value = !crt.value;
document.body.classList.toggle("crt", crt.value);
localStorage.setItem("gashboard.crt", crt.value ? "1" : "0");
}
function shortNpub(n: string): string {
return n.length > 16 ? `${n.slice(0, 8)}…${n.slice(-4)}` : n;
}
watch(
heatLevel,
(level) => {
document.documentElement.style.setProperty("--heat", level.toFixed(3));
document.documentElement.style.setProperty("--heat-color", level > 0.75 ? "#ff4f78" : "#ff3df0");
document.body.classList.toggle("hot-hash", level > 0.75);
},
{ immediate: true },
);
</script>
<template>
<header class="topbar">
<div class="brand glow-magenta flicker">
⌬ GASHBOARD
<span class="muted">// solo lottery dashboard</span>
</div>
<nav v-if="auth.isLoggedIn" class="nav">
<RouterLink to="/">status</RouterLink>
<RouterLink to="/graphs">graphs</RouterLink>
<RouterLink to="/access">access</RouterLink>
</nav>
<div class="actions">
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
<button class="thin" @click="releaseOpen = !releaseOpen">notes</button>
<template v-if="auth.isLoggedIn">
<button class="thin" @click="chatOpen = !chatOpen">chat</button>
<span class="muted">{{ auth.npub ? shortNpub(auth.npub) : "" }}</span>
<button class="thin" @click="auth.logout()">logout</button>
</template>
</div>
</header>
<nav v-if="auth.isLoggedIn" class="mobile-tabbar" aria-label="mobile navigation">
<RouterLink to="/">status</RouterLink>
<RouterLink to="/graphs">graphs</RouterLink>
<RouterLink to="/access">access</RouterLink>
<button type="button" @click="releaseOpen = !releaseOpen">notes</button>
<button type="button" @click="chatOpen = !chatOpen">chat</button>
</nav>
<main :class="{ shellLogin: route.name === 'login' }">
<RouterView />
</main>
<footer class="footbar muted">
P(block) is a lifestyle, not a number · gashboard v0.1 · {{ new Date().getFullYear() }}
</footer>
<ChatDrawer v-if="auth.isLoggedIn" :open="chatOpen" @close="chatOpen = false" />
<ReleaseNotesDrawer :open="releaseOpen" @close="releaseOpen = false" />
</template>
<style scoped>
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 24px;
border-bottom: 1px solid var(--line);
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(6px);
position: sticky;
top: 0;
z-index: 10;
}
.brand {
font-weight: 700;
letter-spacing: 0.18em;
font-size: 15px;
}
.brand .muted {
margin-left: 12px;
letter-spacing: 0.04em;
font-weight: 400;
font-size: 11px;
}
.nav {
display: flex;
gap: 8px;
align-items: center;
margin-left: auto;
margin-right: 16px;
}
.nav a {
color: var(--fg-1);
border: 1px solid var(--line);
padding: 4px 10px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.nav a:hover,
.nav a.router-link-active {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
.mobile-tabbar {
display: none;
}
.actions {
display: flex;
gap: 12px;
align-items: center;
}
button.thin {
padding: 4px 10px;
font-size: 11px;
}
main {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
main.shellLogin {
display: grid;
place-items: center;
min-height: calc(100vh - 110px);
}
.footbar {
padding: 14px 24px;
border-top: 1px solid var(--line);
font-size: 11px;
text-align: center;
letter-spacing: 0.08em;
}
@media (max-width: 760px) {
.topbar {
align-items: center;
padding: 10px 14px;
}
.brand .muted,
.nav {
display: none;
}
.actions {
width: auto;
justify-content: flex-end;
flex-wrap: wrap;
gap: 8px;
}
.actions button:nth-of-type(n + 2) {
display: none;
}
.actions .muted {
display: none;
}
.mobile-tabbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 12;
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background: rgba(7, 9, 15, 0.94);
backdrop-filter: blur(6px);
}
.mobile-tabbar a,
.mobile-tabbar button {
min-width: 0;
border: 0;
border-right: 1px solid var(--line);
color: var(--fg-1);
padding: 10px 4px;
text-align: center;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
background: transparent;
}
.mobile-tabbar a:last-child,
.mobile-tabbar button:last-child {
border-right: 0;
}
.mobile-tabbar a.router-link-active,
.mobile-tabbar button:hover {
color: var(--neon-cyan);
box-shadow: inset 0 -1px 0 var(--neon-cyan), var(--shadow-cyan);
}
main {
padding: 16px 16px calc(76px + env(safe-area-inset-bottom));
}
.footbar {
padding-bottom: calc(76px + env(safe-area-inset-bottom));
}
}
</style>
@@ -0,0 +1,143 @@
export type ChatSticker = {
label: string;
text: string;
kind?: "sticker" | "gif";
image?: string;
};
export const STICKER_PREFIX = "[gashboard sticker]";
const GIF_MARKER = "[gif]";
const IMAGE_MARKER = "[img:";
export const CHAT_STICKERS: ChatSticker[] = [
{ label: "fiat heater", text: "FIAT HEATER DETECTED: warm room, cold wallet." },
{ label: "no hashes", text: "NO HASHES. JUST GHEI. just fiat and a pension." },
{ label: "cope block", text: "BLOCK FOUND: not by us, obviously. carry on pretending." },
{ label: "desk miner", text: "DESK MINER ENERGY: tiny fan, enormous delusion." },
{ label: "pool bully", text: "BIG POOL ENTERED CHAT: scaled up to not shame the children." },
{ label: "paper hands", text: "PAPER HANDS, PLASTIC HEATER, ZERO SATS." },
{ label: "nonce cult", text: "NONCE CULT MEETING: attendance low, conviction insane." },
{ label: "fiat friend", text: "YOUR FIAT FRIEND THINKS THE HEATER IS AN INVESTMENT." },
{ label: "warm tonight", text: "SOMEONE'S GETTING WARMED TONIGHT. maybe not paid." },
{ label: "difficulty", text: "DIFFICULTY ADJUSTMENT: reality filed another complaint." },
{ label: "best calc", text: "BEST CALC: almost important, spiritually devastating." },
{ label: "hash high", text: "HASHRATE UP. ODDS STILL NEED A TELESCOPE." },
{ label: "pension", text: "JUST FIAT AND A PENSION: the saddest proof-of-warmth." },
{ label: "sovereign", text: "SOVEREIGN MINER: no block, no boss, no apology." },
{ label: "amber", text: "SIGNED BY A REAL SIGNER, UNLIKE YOUR BOOMER'S DIRECT DEBIT." },
{ label: "mempool", text: "MEMPOOL SAYS NO. DASHBOARD SAYS LOL." },
{ label: "reject", text: "REJECTED SHARE: even the server had standards." },
{ label: "bigpapa", text: "BIGPAPA IS WATCHING. probably collecting fuck all." },
{ label: "lottery", text: "SOLO LOTTERY TICKET PURCHASED WITH ELECTRICITY AND DENIAL." },
{ label: "fiat people", text: "FIAT PEOPLE SEE A HEATER. MINERS SEE A COWARD." },
{ label: "gif cope", kind: "gif", text: "LIVE FOOTAGE OF ME PRETENDING THE ODDS ARE FINE." },
{ label: "gif panic", kind: "gif", text: "THE HASHRATE MOVED 0.01 TH/S AND I HAVE BECOME UNBEARABLE." },
{ label: "gif refresh", kind: "gif", text: "REFRESHING THE DASHBOARD LIKE IT OWES ME MONEY." },
{ label: "gif chart", kind: "gif", text: "GRAPH GOES UP. BRAIN GOES OFF." },
{ label: "gif no block", kind: "gif", text: "NO BLOCK? MUST BE A CONSPIRACY AGAINST MY USB PORT." },
{ label: "gif office", kind: "gif", text: "CORPORATE NEEDS YOU TO FIND THE DIFFERENCE: HEATER / MINER." },
{ label: "gif maths", kind: "gif", text: "ME EXPLAINING 1 IN 2 BILLION LIKE IT IS A PLAN." },
{ label: "gif deploy", kind: "gif", text: "DEPLOYED TO PRODUCTION. MAY GOD REVIEW THE DIFF." },
{ label: "gif smug", kind: "gif", text: "I MINE SOLO, WHICH IS HOW YOU KNOW I AM DIFFICULT AT DINNER." },
{ label: "gif invoice", kind: "gif", text: "ELECTRIC BILL ARRIVED. IT IS NOW A WHITEPAPER." },
{ label: "ngmi heater", text: "NGMI: NOT GENERATING MINING INCOME." },
{ label: "printer money", text: "FIAT PRINTER WENT BRRR. MINER WENT PLEASE NOTICE ME." },
{ label: "normie mode", text: "NORMIE MODE: PAYING FOR HEAT WITHOUT EVEN ATTEMPTING A BLOCK." },
{ label: "bullish fan", text: "LOUD FAN, QUIET BALANCE, MAXIMUM CONVICTION." },
{ label: "chart crime", text: "THIS CHART IS A CRIME SCENE WITH AXES." },
{ label: "share cope", text: "ACCEPTED SHARE: A PARTICIPATION TROPHY WITH VOLTAGE." },
{ label: "stack sats", text: "STACKING SATS? NO. STACKING RECEIPTS FROM THE POWER COMPANY." },
{ label: "main character", text: "MAIN CHARACTER ENERGY, BACKGROUND HASHRATE." },
{ label: "block soon", text: "BLOCK SOON. SOURCE: THE SAME PART OF MY BRAIN THAT BUYS TOPS." },
{ label: "heater max", text: "HEATER ON MAX. HASHES ON VACATION." },
{
label: "anon miner",
text: "ANON DISCOVERS SOLO MINING AND IMMEDIATELY BECOMES A PROBLEM.",
image: "/stickers/anon-miner.svg",
},
{
label: "fiat npc",
text: "I JUST USE A NORMAL HEATER BECAUSE THE BANK SAID IT WAS FINE.",
image: "/stickers/fiat-npc.svg",
},
{
label: "chad hash",
text: "LOUD, WARM, WRONG, AND STILL MORE SOVEREIGN THAN YOUR SAVINGS ACCOUNT.",
image: "/stickers/chad-hash.svg",
},
{
label: "money printer",
text: "CENTRAL BANK MONETARY POLICY, ARTIST'S IMPRESSION.",
image: "/stickers/money-printer.svg",
},
{
label: "block denied",
text: "THE NETWORK REVIEWED YOUR NONCE AND CHOSE VIOLENCE.",
image: "/stickers/block-denied.svg",
},
{
label: "boomer unit",
text: "LEGACY HEATER ACHIEVES ZERO HASHES PER RETIREMENT.",
image: "/stickers/boomer-unit.svg",
},
{
label: "chart crime",
text: "TECHNICAL ANALYSIS CONDUCTED BY A FAN WITH A WALLET.",
image: "/stickers/chart-crime.svg",
},
{
label: "odds wizard",
text: "I HAVE CONSULTED THE ODDS AND THEY SAID LOL.",
image: "/stickers/odds-wizard.svg",
},
{
label: "gif fan panic",
kind: "gif",
text: "LIVE FAN FOOTAGE AFTER HASHRATE MOVES BY A DECIMAL.",
image: "/stickers/fan-panic.svg",
},
{
label: "gif printer",
kind: "gif",
text: "FIAT POLICY IN ONE LOOP.",
image: "/stickers/printer-loop.svg",
},
{
label: "gif block hopium",
kind: "gif",
text: "SOLO MINER WATCHING EVERY NEW BLOCK LIKE IT OWES HIM MONEY.",
image: "/stickers/block-hopium.svg",
},
{
label: "gif heat death",
kind: "gif",
text: "THE ROOM IS WARMER THAN THE INVESTMENT THESIS.",
image: "/stickers/heat-death.svg",
},
];
export function stickerContent(sticker: ChatSticker): string {
const image = sticker.image ? `${IMAGE_MARKER}${sticker.image}] ` : "";
return `${STICKER_PREFIX} ${image}${sticker.kind === "gif" ? `${GIF_MARKER} ` : ""}${sticker.text}`;
}
export function unsticker(
content: string,
): { text: string; kind: "sticker" | "gif"; image?: string } | null {
if (!content.startsWith(`${STICKER_PREFIX} `)) return null;
let body = content.slice(STICKER_PREFIX.length + 1);
let image: string | undefined;
if (body.startsWith(IMAGE_MARKER)) {
const end = body.indexOf("] ");
if (end > IMAGE_MARKER.length) {
image = body.slice(IMAGE_MARKER.length, end);
body = body.slice(end + 2);
}
}
if (body.startsWith(`${GIF_MARKER} `)) {
const parsed = { text: body.slice(GIF_MARKER.length + 1), kind: "gif" as const };
return image ? { ...parsed, image } : parsed;
}
const parsed = { text: body, kind: "sticker" as const };
return image ? { ...parsed, image } : parsed;
}
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
import { BLOCK_CELEBRATION_LINES } from "../strings";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const showing = ref(false);
const message = ref("");
let lastBlockFoundAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap) return;
// Block-found field not yet wired — placeholder for when the / homepage
// parser surfaces lastBlockFoundAt. For now this is dormant; trigger via
// window.gashboardCelebrate() in the console for a preview.
void lastBlockFoundAt; // silence unused
},
);
if (typeof window !== "undefined") {
(window as unknown as { gashboardCelebrate?: () => void }).gashboardCelebrate = () => {
const idx = Math.floor(Math.random() * BLOCK_CELEBRATION_LINES.length);
message.value = BLOCK_CELEBRATION_LINES[idx] ?? "";
showing.value = true;
setTimeout(() => (showing.value = false), 8000);
};
}
</script>
<template>
<Transition name="fade">
<div v-if="showing" class="overlay">
<div class="card glow-magenta">
<div class="title">★ B L O C K F O U N D ★</div>
<div class="msg">{{ message }}</div>
<button @click="showing = false">acknowledge</button>
</div>
</div>
</Transition>
</template>
<style scoped>
.overlay {
position: fixed; inset: 0;
background: rgba(7, 9, 15, 0.85);
backdrop-filter: blur(8px);
display: grid;
place-items: center;
z-index: 1000;
}
.card {
text-align: center;
padding: 40px 50px;
border: 2px solid var(--neon-magenta);
background: var(--bg-1);
box-shadow: 0 0 40px var(--neon-magenta);
display: flex;
flex-direction: column;
gap: 18px;
max-width: 520px;
}
.title {
font-size: 24px;
letter-spacing: 0.4em;
font-weight: 700;
}
.msg {
font-size: 14px;
font-style: italic;
line-height: 1.5;
color: var(--fg-1);
}
.fade-enter-active, .fade-leave-active { transition: opacity 0.4s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -0,0 +1,768 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { useAuthStore } from "../stores/auth";
import * as chat from "../services/chat";
import type { ChatMessage, ChatProfile, ChatReaction } from "../services/chat";
import { CHAT_STICKERS, stickerContent, unsticker, type ChatSticker } from "../chatStickers";
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
const auth = useAuthStore();
const messages = ref<ChatMessage[]>([]);
const reactions = ref<Record<string, Record<string, string[]>>>({});
const profiles = ref<Record<string, ChatProfile>>({});
const draft = ref("");
const error = ref("");
const loading = ref(false);
const showStickers = ref(false);
const replyTarget = ref<ChatMessage | null>(null);
const listEl = ref<HTMLElement | null>(null);
let sub: { close: () => void } | null = null;
const ownPubkey = computed(() => chat.pubkeyFromNpub(auth.npub));
const canSend = computed(() => auth.hasActiveSigner);
const mediaStickers = computed(() => CHAT_STICKERS.filter((sticker) => sticker.image));
const textStickers = computed(() => CHAT_STICKERS.filter((sticker) => !sticker.image));
onMounted(() => {
if (auth.isLoggedIn) void start();
});
onUnmounted(() => {
sub?.close();
});
watch(
() => auth.isLoggedIn,
(loggedIn) => {
if (loggedIn) void start();
else {
sub?.close();
sub = null;
messages.value = [];
profiles.value = {};
reactions.value = {};
replyTarget.value = null;
}
},
);
watch(
() => props.open,
(open) => {
if (open) {
if (!canSend.value) void auth.restoreSavedSigner();
void scrollBottom();
}
},
);
async function start(): Promise<void> {
if (sub) return;
loading.value = true;
error.value = "";
try {
messages.value = await chat.loadChat();
await loadReactions();
await loadMissingProfiles();
sub = chat.subscribeChat(
(message) => {
if (!messages.value.some((m) => m.id === message.id)) {
messages.value.push(message);
messages.value.sort((a, b) => a.createdAt - b.createdAt);
void loadMissingProfiles();
void scrollBottom();
}
},
(reaction) => addReaction(reaction),
);
await scrollBottom();
} catch (e) {
error.value = e instanceof Error ? e.message : "Chat failed";
} finally {
loading.value = false;
}
}
async function loadReactions(): Promise<void> {
const loaded = await chat.loadReactions(messages.value.map((m) => m.id));
reactions.value = {};
for (const reaction of loaded) addReaction(reaction);
}
async function loadMissingProfiles(): Promise<void> {
const authors = messages.value.map((m) => m.pubkey);
if (ownPubkey.value) authors.push(ownPubkey.value);
const missing = [...new Set(authors)].filter((p) => !profiles.value[p]);
if (!missing.length) return;
profiles.value = { ...profiles.value, ...(await chat.loadProfiles(missing)) };
}
async function send(): Promise<void> {
const content = draft.value.trim();
if (!content) return;
await sendContent(content, replyTarget.value ?? undefined);
draft.value = "";
replyTarget.value = null;
}
async function sendSticker(sticker: ChatSticker): Promise<void> {
await sendContent(stickerContent(sticker), replyTarget.value ?? undefined);
replyTarget.value = null;
showStickers.value = false;
}
async function sendContent(content: string, replyTo?: ChatMessage): Promise<void> {
error.value = "";
try {
const message = await chat.sendChat(content, replyTo ? { id: replyTo.id, pubkey: replyTo.pubkey } : undefined);
if (!messages.value.some((m) => m.id === message.id)) messages.value.push(message);
await loadMissingProfiles();
await scrollBottom();
} catch (e) {
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
await auth.restoreSavedSigner();
}
error.value = e instanceof Error ? e.message : "Could not send";
}
}
async function react(message: ChatMessage, content: string): Promise<void> {
error.value = "";
try {
addReaction(await chat.sendReaction(message, content));
} catch (e) {
if (e instanceof Error && /connect|signer|auth|missing remote|aborted/i.test(e.message)) {
await auth.restoreSavedSigner();
}
error.value = e instanceof Error ? e.message : "Could not react";
}
}
function addReaction(reaction: ChatReaction): void {
if (!reaction.messageId) return;
const existing = reactions.value[reaction.messageId]?.[reaction.content] ?? [];
if (existing.includes(reaction.pubkey)) return;
reactions.value = {
...reactions.value,
[reaction.messageId]: {
...(reactions.value[reaction.messageId] ?? {}),
[reaction.content]: [...existing, reaction.pubkey],
},
};
}
function profile(pubkey: string): ChatProfile {
return profiles.value[pubkey] ?? { pubkey, name: chat.shortPubkey(pubkey), picture: "" };
}
function timeLabel(ts: number): string {
return new Date(ts * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function stickerText(content: string): string | null {
return unsticker(content)?.text ?? null;
}
function stickerKind(content: string): "sticker" | "gif" | null {
return unsticker(content)?.kind ?? null;
}
function stickerImage(content: string): string | null {
return unsticker(content)?.image ?? null;
}
function replyPreview(message: ChatMessage): ChatMessage | null {
if (!message.replyToId) return null;
return messages.value.find((m) => m.id === message.replyToId) ?? null;
}
function shortText(content: string): string {
const sticker = unsticker(content);
const text = sticker?.text ?? content;
return text.length > 78 ? `${text.slice(0, 78)}...` : text;
}
function reactionEntries(message: ChatMessage): Array<{ content: string; count: number; mine: boolean }> {
const own = ownPubkey.value;
return Object.entries(reactions.value[message.id] ?? {}).map(([content, pubkeys]) => ({
content,
count: pubkeys.length,
mine: !!own && pubkeys.includes(own),
}));
}
async function scrollBottom(): Promise<void> {
await nextTick();
if (listEl.value) listEl.value.scrollTop = listEl.value.scrollHeight;
}
</script>
<template>
<div v-if="open" class="chat-backdrop" @click="emit('close')" />
<aside :class="['chat-drawer', { open }]">
<header>
<div>
<div class="label">private nostr-login chat</div>
<h2 class="glow-cyan">mining desk heckle box</h2>
</div>
<button class="thin" aria-label="close chat" @click="emit('close')">×</button>
</header>
<div v-if="!canSend" class="notice reconnect">
<span>
Logged in, but the signer is not active in this tab. Reading works; sending needs a restored signer.
</span>
<button class="thin" :disabled="auth.busy" @click="auth.reconnectRemoteApp()">
{{ auth.busy ? "opening" : "reconnect" }}
</button>
</div>
<div v-if="error" class="notice err">{{ error }}</div>
<div ref="listEl" class="messages">
<div v-if="loading" class="empty muted">loading relay gossip...</div>
<div v-else-if="!messages.length" class="empty muted">no chat yet. terrifying discipline.</div>
<article
v-for="message in messages"
:key="message.id"
:class="['msg', { own: message.pubkey === ownPubkey }]"
>
<img v-if="profile(message.pubkey).picture" :src="profile(message.pubkey).picture" alt="" />
<div v-else class="avatar">{{ profile(message.pubkey).name.slice(0, 2).toUpperCase() }}</div>
<div class="bubble">
<div class="meta">
<strong>{{ profile(message.pubkey).name }}</strong>
<span>{{ timeLabel(message.createdAt) }}</span>
</div>
<button
v-if="replyPreview(message)"
type="button"
class="reply-quote"
@click="replyTarget = replyPreview(message)"
>
<strong>{{ profile(replyPreview(message)?.pubkey || '').name }}</strong>
<span>{{ shortText(replyPreview(message)?.content || '') }}</span>
</button>
<p v-if="!stickerText(message.content)">{{ message.content }}</p>
<div v-else :class="['sticker', stickerKind(message.content)]">
<img v-if="stickerImage(message.content)" :src="stickerImage(message.content) || ''" alt="" />
<span>{{ stickerText(message.content) }}</span>
</div>
<div class="message-actions">
<button type="button" @click="replyTarget = message">reply</button>
<button type="button" @click="react(message, '+')">+</button>
<button type="button" @click="react(message, '🔥')">fire</button>
<button type="button" @click="react(message, '😂')">lol</button>
</div>
<div v-if="reactionEntries(message).length" class="reactions">
<button
v-for="entry in reactionEntries(message)"
:key="entry.content"
type="button"
:class="{ mine: entry.mine }"
@click="react(message, entry.content)"
>
{{ entry.content }} {{ entry.count }}
</button>
</div>
</div>
</article>
</div>
<div v-if="showStickers" class="sticker-modal" @click.self="showStickers = false">
<section class="sticker-dialog" aria-label="chat stickers">
<div class="sticker-head">
<div>
<div class="label">stickers</div>
<strong class="glow-amber">{{ CHAT_STICKERS.length }} signed insults</strong>
</div>
<button type="button" class="thin" aria-label="close stickers" @click="showStickers = false">×</button>
</div>
<div class="sticker-tray">
<div class="sticker-section">
<div class="section-title">image / gif stickers</div>
<div class="sticker-grid media-grid">
<button
v-for="sticker in mediaStickers"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
<img :src="sticker.image || ''" alt="" />
<span>{{ sticker.label }}</span>
<small>{{ sticker.text }}</small>
</button>
</div>
</div>
<div class="sticker-section">
<div class="section-title">text stickers</div>
<div class="sticker-grid text-grid">
<button
v-for="sticker in textStickers"
:key="sticker.label"
type="button"
:disabled="!canSend"
@click="sendSticker(sticker)"
>
<span>{{ sticker.label }}</span>
<small>{{ sticker.text }}</small>
</button>
</div>
</div>
</div>
</section>
</div>
<form class="composer" @submit.prevent="send">
<div v-if="replyTarget" class="replying">
<div>
<span class="label">replying to {{ profile(replyTarget.pubkey).name }}</span>
<p>{{ shortText(replyTarget.content) }}</p>
</div>
<button type="button" class="thin" @click="replyTarget = null">×</button>
</div>
<div class="composer-tools">
<button
type="button"
class="sticker-toggle"
:disabled="!canSend"
@click="showStickers = !showStickers"
>
stickers
</button>
</div>
<textarea
v-model="draft"
:disabled="!canSend"
rows="2"
maxlength="500"
placeholder="type something irresponsible but signed..."
/>
<button class="primary" :disabled="!canSend || !draft.trim()">send</button>
</form>
</aside>
</template>
<style scoped>
.chat-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.2);
}
.chat-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 20;
width: min(430px, 92vw);
height: 100dvh;
--chat-pad: 16px;
--composer-bottom: 24px;
--composer-height: 70px;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
padding: var(--chat-pad);
background: rgba(7, 9, 15, 0.97);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.45);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.chat-drawer.open {
transform: translateX(0);
}
header {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notice {
border: 1px solid var(--line);
color: var(--fg-1);
padding: 9px 10px;
font-size: 11px;
line-height: 1.35;
}
.notice.err {
border-color: var(--neon-red);
color: var(--neon-red);
}
.notice.reconnect {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: center;
}
.notice.reconnect span {
min-width: 0;
}
.notice.reconnect button {
flex: 0 0 auto;
}
.messages {
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
padding-bottom: calc(var(--composer-height) + var(--composer-bottom) + 18px);
}
.empty {
padding: 24px 0;
text-align: center;
font-size: 12px;
}
.msg {
display: grid;
grid-template-columns: 34px 1fr;
gap: 8px;
align-items: start;
}
.msg.own {
grid-template-columns: 1fr 34px;
}
.msg.own img,
.msg.own .avatar {
grid-column: 2;
grid-row: 1;
}
.msg.own .bubble {
grid-column: 1;
grid-row: 1;
}
img,
.avatar {
width: 34px;
height: 34px;
border: 1px solid var(--line-bright);
object-fit: cover;
}
.avatar {
display: grid;
place-items: center;
color: var(--neon-cyan);
background: var(--bg-2);
font-size: 10px;
font-weight: 700;
}
.bubble {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 8px 10px;
}
.own .bubble {
border-color: var(--neon-cyan);
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
color: var(--fg-2);
font-size: 10px;
}
.meta strong {
color: var(--fg-1);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reply-quote {
width: 100%;
margin-top: 7px;
display: flex;
flex-direction: column;
gap: 2px;
padding: 6px 8px;
border-color: var(--line);
color: var(--fg-2);
text-align: left;
text-transform: none;
letter-spacing: 0.02em;
font-size: 10px;
}
.reply-quote strong {
color: var(--neon-cyan);
}
.reply-quote span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
p {
margin: 5px 0 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-size: 12px;
}
.message-actions,
.reactions {
display: flex;
flex-wrap: wrap;
gap: 5px;
margin-top: 8px;
}
.message-actions button,
.reactions button {
min-height: 22px;
padding: 3px 6px;
color: var(--fg-2);
border-color: var(--line);
font-size: 9px;
}
.reactions button {
color: var(--neon-amber);
background: rgba(255, 216, 74, 0.04);
}
.reactions button.mine {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
}
.sticker {
border: 1px solid var(--neon-magenta);
color: var(--neon-amber);
padding: 10px;
font-size: 13px;
line-height: 1.35;
text-transform: uppercase;
box-shadow: inset 0 0 18px rgba(255, 61, 240, 0.08);
}
.sticker img {
display: block;
width: min(220px, 100%);
aspect-ratio: 1 / 1;
height: auto;
margin-bottom: 8px;
border: 1px solid var(--line-bright);
object-fit: contain;
background: var(--bg-0);
}
.sticker span {
display: block;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.sticker.gif {
animation: sticker-bounce 1.4s steps(2, end) infinite;
background:
linear-gradient(90deg, rgba(255, 61, 240, 0.12), rgba(41, 255, 230, 0.08)),
rgba(255, 255, 255, 0.025);
}
.composer {
position: fixed;
right: var(--chat-pad);
bottom: var(--composer-bottom);
z-index: 4;
width: calc(min(430px, 92vw) - (var(--chat-pad) * 2));
min-height: var(--composer-height);
display: grid;
grid-template-columns: 78px minmax(0, 1fr) 64px;
gap: 8px;
align-items: start;
padding-top: 10px;
border-top: 1px solid var(--line);
background: rgba(7, 9, 15, 0.97);
}
.replying {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: start;
padding: 8px;
border: 1px solid var(--line);
background: var(--bg-1);
}
.replying p {
margin: 3px 0 0;
color: var(--fg-1);
font-size: 11px;
line-height: 1.3;
}
.replying button {
height: 26px;
padding: 0 8px;
}
.composer-tools {
min-width: 0;
}
.sticker-modal {
position: absolute;
inset: 0;
z-index: 3;
display: grid;
align-items: end;
padding: 16px;
background: var(--bg-0);
}
.sticker-dialog {
min-height: 0;
max-height: min(520px, calc(100% - 24px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 10px;
padding: 12px;
border: 1px solid var(--neon-magenta);
background: var(--bg-1);
box-shadow: var(--shadow-magenta);
}
.sticker-head {
display: flex;
justify-content: space-between;
align-items: start;
gap: 12px;
}
.sticker-head strong {
display: block;
margin-top: 2px;
font-size: 14px;
}
.sticker-tray {
min-height: 0;
overflow: auto;
padding-right: 2px;
}
.sticker-section + .sticker-section {
margin-top: 14px;
}
.section-title {
margin-bottom: 8px;
color: var(--fg-2);
font-size: 10px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.sticker-grid {
display: grid;
gap: 8px;
}
.media-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.text-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sticker-grid button {
min-width: 0;
min-height: 138px;
display: flex;
flex-direction: column;
gap: 5px;
align-items: flex-start;
justify-content: flex-start;
padding: 9px 10px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
text-align: left;
overflow-wrap: anywhere;
}
.text-grid button {
min-height: 74px;
}
.sticker-grid button img {
width: 100%;
aspect-ratio: 1 / 1;
min-height: 120px;
max-height: 156px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--bg-0);
}
.sticker-grid button span {
color: var(--neon-cyan);
}
.sticker-grid button small {
color: var(--fg-1);
font-size: 10px;
line-height: 1.25;
text-transform: none;
letter-spacing: 0.02em;
}
.sticker-toggle {
width: 100%;
height: 58px;
padding: 0 6px;
color: var(--neon-amber);
border-color: var(--line-bright);
font-size: 10px;
}
textarea {
height: 58px;
min-height: 58px;
max-height: 92px;
resize: none;
line-height: 1.3;
}
.composer button {
align-self: start;
height: 58px;
padding: 0 8px;
}
@keyframes sticker-bounce {
0%,
100% {
transform: translate(0, 0);
filter: hue-rotate(0deg);
}
50% {
transform: translate(1px, -1px);
filter: hue-rotate(22deg);
}
}
@media (max-width: 760px) {
.chat-backdrop {
background: rgba(0, 0, 0, 0.44);
}
.chat-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 100svh;
max-height: none;
--chat-pad: 14px;
grid-template-rows: auto auto minmax(0, 1fr) auto;
padding: 14px 14px calc(72px + env(safe-area-inset-bottom));
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.chat-drawer.open {
transform: translateY(0);
}
.messages {
gap: 10px;
padding-bottom: 0;
}
.composer {
position: static;
width: auto;
grid-template-columns: 72px minmax(0, 1fr) 58px;
gap: 6px;
}
.sticker-modal {
padding: 10px;
}
.sticker-dialog {
max-height: calc(100% - 12px);
}
.sticker-tray {
padding-right: 0;
}
.media-grid,
.text-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,105 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { LOTTERY_LINES } from "../strings";
const props = defineProps<{ totalThs: number; networkEh: number }>();
const FALLBACK_NETWORK_EH_S = 720;
const SECONDS_PER_BLOCK = 600;
const SECONDS_PER_DAY = 86400;
const activeNetworkEh = computed(() => props.networkEh > 0 ? props.networkEh : FALLBACK_NETWORK_EH_S);
const probPerBlock = computed(() => {
if (props.totalThs <= 0) return 0;
// Th/s vs EH/s = 1e6 conversion: 1 EH/s = 1e6 Th/s
return props.totalThs / (activeNetworkEh.value * 1e6);
});
const probPerDay = computed(() => {
// Probability of NOT finding in any of (86400/600) = 144 blocks per day
const blocksPerDay = SECONDS_PER_DAY / SECONDS_PER_BLOCK;
const p = probPerBlock.value;
return 1 - Math.pow(1 - p, blocksPerDay);
});
const lineIndex = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
lineIndex.value = (lineIndex.value + 1) % LOTTERY_LINES.length;
}, 10_000);
});
onUnmounted(() => {
if (timer) clearInterval(timer);
});
const currentLine = computed(() => {
const fn = LOTTERY_LINES[lineIndex.value];
if (!fn) return "";
return fn(probPerDay.value);
});
</script>
<template>
<section class="lottery panel">
<div class="label">solo lottery odds</div>
<div class="source muted">
calculated against {{ activeNetworkEh.toFixed(0) }} EH/s network hashrate
</div>
<div class="line glow-amber">
<span class="prefix">$</span>
<span class="text">{{ currentLine }}</span>
</div>
<div class="dots">
<span
v-for="(_, i) in LOTTERY_LINES"
:key="i"
:class="['dot', { active: i === lineIndex }]"
/>
</div>
</section>
</template>
<style scoped>
.lottery {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.source {
font-size: 10px;
overflow-wrap: anywhere;
}
.line {
font-size: 13px;
display: flex;
gap: 10px;
align-items: baseline;
min-height: 32px;
min-width: 0;
}
.prefix { color: var(--neon-amber); font-weight: 700; }
.text {
line-height: 1.5;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.dots {
display: flex;
gap: 5px;
margin-top: 4px;
flex-wrap: wrap;
}
.dot {
width: 5px; height: 5px;
border-radius: 50%;
background: var(--line-bright);
transition: background 0.3s, box-shadow 0.3s;
}
.dot.active {
background: var(--neon-amber);
box-shadow: 0 0 8px var(--neon-amber);
}
</style>
@@ -0,0 +1,231 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MinerStat } from "../types";
import { ROASTS, STATUS_LINES, STALE_LINES } from "../strings";
const props = defineProps<{ miner: MinerStat; bestWork: number | undefined }>();
const isBoomerHeater = computed(() => props.miner.nickname === "Boomer Heater");
const slug = computed(() => {
// Map nickname → roast key
const map: Record<string, string> = {
QU4CK: "nerdqaxe",
P1XEL: "bitaxe",
BigPapa: "bitaxe",
N4N0: "avalon-nano-3",
M1N1: "avalon-mini-3",
};
return map[props.miner.nickname] ?? "unknown";
});
const roast = computed(() =>
isBoomerHeater.value ? "The dumb shit your fiat friends have." : (ROASTS[slug.value] ?? ROASTS["unknown"]),
);
const statusLabel = computed(() => STATUS_LINES[props.miner.status]);
const displayStatusLabel = computed(() => isBoomerHeater.value ? "dead" : statusLabel.value);
const statusMarker = computed(() => isBoomerHeater.value ? "☠" : "");
const statusGlow = computed(() => {
if (isBoomerHeater.value) return "glow-red";
switch (props.miner.status) {
case "hashing": return "glow-green";
case "stale": return "glow-amber";
case "idle": return "glow-red";
}
});
const lastShareLabel = computed(() => {
if (isBoomerHeater.value) return "Nothing";
const s = props.miner.lastShareAgeS;
if (s === null) return "no shares yet";
if (s < 60) return `${s.toFixed(0)}s ago`;
if (s < 3600) return `${(s / 60).toFixed(1)}m ago`;
return `${(s / 3600).toFixed(1)}h ago`;
});
const lifetimeShares = computed(() => props.miner.diffAcceptedCount);
const acceptedWork = computed(() => isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.diffAcceptedSum));
const contributionWork = computed(() =>
isBoomerHeater.value ? "Zero. Obviously." : formatBig(props.miner.contributionAcceptedWork ?? props.miner.diffAcceptedSum),
);
const contributionShare = computed(() =>
isBoomerHeater.value ? "0.00%" : `${(props.miner.contributionPct ?? 0).toFixed(2)}%`,
);
const bestWork = computed(() => {
if (isBoomerHeater.value) return "Fuck all";
return props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting";
});
const staleNote = computed(() => {
if (isBoomerHeater.value) return "warming a room while contributing nothing to civilization.";
if (props.miner.status !== "stale") return "";
const idx = Math.floor(Math.random() * STALE_LINES.length);
return STALE_LINES[idx];
});
function formatBig(n: number): string {
if (n < 1_000) return n.toFixed(0);
if (n < 1e6) return `${(n / 1e3).toFixed(1)}K`;
if (n < 1e9) return `${(n / 1e6).toFixed(1)}M`;
if (n < 1e12) return `${(n / 1e9).toFixed(1)}B`;
return `${(n / 1e12).toFixed(1)}T`;
}
</script>
<template>
<div class="card panel" :data-status="miner.status">
<header>
<div class="nickname glow-magenta">{{ miner.nickname }}</div>
<div :class="['status', statusGlow]">
<span v-if="statusMarker" class="dead-mark">{{ statusMarker }}</span>
<span v-else class="dot pulse" />
{{ displayStatusLabel }}
</div>
</header>
<div class="model">{{ miner.model }}</div>
<div class="roast muted">{{ roast }}</div>
<div class="stats">
<div class="stat">
<div class="label">hashrate</div>
<div class="value glow-cyan">
{{ isBoomerHeater ? "No hashes" : miner.hashrateThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
</div>
<div class="stat">
<div class="label">last share</div>
<div class="value">{{ lastShareLabel }}</div>
</div>
<div class="stat">
<div class="label">tickets bought</div>
<div class="value">{{ isBoomerHeater ? "None" : lifetimeShares.toLocaleString() }}</div>
</div>
<div class="stat">
<div class="label">best calc</div>
<div class="value glow-amber">{{ bestWork }}</div>
</div>
<div class="stat">
<div class="label">reward share</div>
<div class="value glow-green">{{ contributionShare }}</div>
</div>
<div class="stat">
<div class="label">reward work</div>
<div class="value">{{ contributionWork }}</div>
</div>
<div class="stat">
<div class="label">reject %</div>
<div :class="['value', miner.rejectPct > 5 ? 'glow-red' : '']">
{{ isBoomerHeater ? "not even rejected" : `${miner.rejectPct.toFixed(2)}%` }}
</div>
</div>
<div class="stat">
<div class="label">accepted work</div>
<div class="value">{{ acceptedWork }}</div>
</div>
</div>
<footer>
<span class="ua muted" :title="miner.userAgent">{{ miner.userAgent || "—" }}</span>
<span class="loc muted">@{{ miner.location }}</span>
</footer>
<div v-if="miner.status === 'stale' || isBoomerHeater" class="stale-banner">
{{ staleNote }}
</div>
</div>
</template>
<style scoped>
.card {
display: flex;
flex-direction: column;
gap: 12px;
position: relative;
overflow: hidden;
}
.card[data-status="idle"] {
opacity: 0.6;
}
.card[data-status="stale"] {
border-color: var(--neon-amber);
}
.card[data-status="hashing"] {
border-color: var(--neon-green);
}
header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.nickname {
font-size: 22px;
font-weight: 700;
letter-spacing: 0.16em;
}
.status {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.14em;
display: flex;
gap: 6px;
align-items: center;
}
.dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
box-shadow: 0 0 8px currentColor;
}
.dead-mark {
font-size: 13px;
line-height: 1;
text-shadow: 0 0 8px currentColor;
}
.model {
font-size: 12px;
color: var(--fg-1);
letter-spacing: 0.06em;
}
.roast {
font-size: 11.5px;
font-style: italic;
line-height: 1.4;
}
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px 14px;
padding: 10px 0;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.stat .label {
font-size: 10px;
}
.stat .value {
font-size: 14px;
font-weight: 500;
}
.stat .unit {
font-size: 10px;
color: var(--fg-2);
}
footer {
display: flex;
justify-content: space-between;
font-size: 10.5px;
}
.ua { max-width: 65%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.stale-banner {
font-size: 10.5px;
color: var(--neon-amber);
border-top: 1px dashed var(--neon-amber);
padding-top: 8px;
font-style: italic;
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
import { useRotatingCopy } from "../composables/useRotatingCopy";
import { RACE_SCALE_NOTES, RACE_TITLES } from "../copy";
import { multiple } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const raceTitle = useRotatingCopy(RACE_TITLES);
const scaleNote = useRotatingCopy(RACE_SCALE_NOTES);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const rows = computed(() => {
const miners = props.snapshot?.miners ?? [];
const max = Math.max(...miners.map((m) => m.hashrateThs), 0.001);
const realRows = miners
.map((miner) => {
const key = minerKey(miner);
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
const velocity = samples.length >= 2 ? samples.at(-1)! - samples[0]! : 0;
return {
key,
miner,
image: imageFor(miner),
width: Math.max(8, (miner.hashrateThs / max) * 100),
trend: velocity,
place: 0,
};
})
.sort((a, b) => b.miner.hashrateThs - a.miner.hashrateThs)
.map((row, idx) => ({ ...row, place: idx + 1, joke: "" }));
return [
...realRows,
{
key: "boomer-heater",
miner: {
nickname: "Boomer Heater",
model: "Legacy Resistance Heater",
hashrateThs: 0,
status: "idle",
},
image: "/miners/boomer-heater.svg",
width: 0,
trend: 0,
place: realRows.length + 1,
joke: "0 TH/s · 0 shares · 0 sats · just fiat and a pension.",
},
];
});
const cosmicRows = computed(() => {
if (!networkEh.value || !totalThs.value) return [];
const totalNetworkThs = networkEh.value * 1_000_000;
const largePoolThs = totalNetworkThs * 0.25;
return [
{
key: "large-pool",
name: "Large pool-ish bully",
value: `${(largePoolThs / 1_000_000).toFixed(0)} EH/s`,
width: 78,
joke: "illustrative 25% chunk of the monster",
},
{
key: "network",
name: "Total Bitcoin network",
value: `${networkEh.value.toFixed(0)} EH/s`,
width: 100,
joke: `${multiple(networkMultiple.value)} your shelf. rude but factual.`,
},
];
});
function minerKey(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function imageFor(m: MinerStat): string {
if (m.nickname === "QU4CK") return "/miners/nerdqaxe.svg";
if (m.nickname === "P1XEL") return "/miners/bitaxe.svg";
if (m.nickname === "BigPapa" && /bitaxe/i.test(m.model)) return "/miners/bigpapa-bitaxe.svg";
if (m.nickname === "N4N0") return "/miners/avalon-nano-3.svg";
return "/miners/avalon-mini-3.svg";
}
</script>
<template>
<section class="race panel">
<header>
<div>
<div class="label">hashrate race</div>
<h2 class="glow-cyan">{{ raceTitle }}</h2>
</div>
<span class="muted">{{ scaleNote }}</span>
</header>
<div class="track">
<div class="section-label">Actual miners</div>
<div v-for="row in rows" :key="row.key" class="lane">
<div class="rank">#{{ row.place }}</div>
<img :src="row.image" :alt="row.miner.model" />
<div class="runner">
<div class="meta">
<strong>{{ row.miner.nickname }}</strong>
<span>{{ row.joke || `${row.miner.hashrateThs.toFixed(2)} TH/s` }}</span>
</div>
<div :class="['bar', { dead: row.width <= 0 }]">
<span :style="{ width: `${row.width}%` }">
<i>{{ row.width <= 0 ? "lol" : row.trend >= 0 ? "▲" : "▼" }}</i>
</span>
</div>
</div>
</div>
<div v-if="cosmicRows.length" class="section-label cosmic-label">
Big pools / total network · separate scale so the kids remain visible
</div>
<div v-for="row in cosmicRows" :key="row.key" class="lane cosmic">
<div class="rank">∞</div>
<div class="network-icon">₿</div>
<div class="runner">
<div class="meta">
<strong>{{ row.name }}</strong>
<span>{{ row.value }} · {{ row.joke }}</span>
</div>
<div class="bar cosmic-bar">
<span :style="{ width: `${row.width}%` }">
<i>NOPE</i>
</span>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.race {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
}
header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 20px;
letter-spacing: 0;
}
.track {
display: flex;
flex-direction: column;
gap: 12px;
}
.section-label {
color: var(--neon-amber);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
border-top: 1px solid var(--line);
padding-top: 10px;
}
.cosmic-label {
color: var(--neon-red);
}
.lane {
display: grid;
grid-template-columns: 46px 86px 1fr;
gap: 12px;
align-items: center;
}
.rank {
color: var(--neon-amber);
font-weight: 700;
font-size: 18px;
}
img {
width: 86px;
height: 58px;
object-fit: contain;
}
.network-icon {
width: 58px;
height: 58px;
display: grid;
place-items: center;
border: 1px solid var(--neon-red);
color: var(--neon-amber);
font-size: 28px;
box-shadow: 0 0 14px rgba(255, 79, 120, 0.24);
}
.runner {
min-width: 0;
}
.meta {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
}
.meta span {
color: var(--fg-2);
}
.bar {
height: 18px;
margin-top: 5px;
border: 1px solid var(--line);
background: var(--bg-2);
overflow: hidden;
}
.bar span {
display: flex;
justify-content: flex-end;
align-items: center;
min-width: 28px;
height: 100%;
background: linear-gradient(90deg, var(--neon-cyan), var(--neon-magenta), var(--heat-color, var(--neon-red)));
box-shadow: 0 0 14px rgba(255, 79, 120, 0.25);
transition: width 0.6s ease;
}
.bar.dead span {
width: 22px !important;
background: #475569;
box-shadow: none;
}
.cosmic .bar span {
background: linear-gradient(90deg, #ff4f78, #ffd84a);
}
.cosmic .meta strong {
color: var(--neon-red);
}
.bar i {
padding-right: 6px;
color: #07090f;
font-style: normal;
font-size: 10px;
font-weight: 700;
}
@media (max-width: 620px) {
header {
flex-direction: column;
}
.lane {
grid-template-columns: 36px 64px 1fr;
}
img {
width: 64px;
}
}
</style>
@@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot } from "../types";
import { useRotatingCopy } from "../composables/useRotatingCopy";
import { BLOCK_LABELS, HASHRATE_LABELS, NETWORK_LABELS } from "../copy";
import { multiple } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const networkRoast = computed(() => {
if (!networkMultiple.value) return "waiting for the grown-up numbers";
if (networkMultiple.value > 20_000_000) return "the network is a freight train; this shelf is a polite cough.";
if (networkMultiple.value > 1_000_000) return "big miners brought warehouses; you brought character.";
return "we are briefly visible on the cosmic spreadsheet.";
});
const subscribed = computed(() => props.snapshot?.pool.totalSubscriptions ?? 0);
const sharesAccepted = computed(() => props.snapshot?.pool.sharesAccepted ?? 0);
const sharesRejected = computed(() => props.snapshot?.pool.sharesRejected ?? 0);
const blockHeight = computed(() => props.snapshot?.job.blockHeight ?? 0);
const hashrateLabel = useRotatingCopy(HASHRATE_LABELS);
const blockLabel = useRotatingCopy(BLOCK_LABELS);
const networkLabel = useRotatingCopy(NETWORK_LABELS);
const ageS = computed(() => {
const t = props.snapshot?.fetchedAt;
if (!t) return null;
return Math.floor((Date.now() - t) / 1000);
});
</script>
<template>
<section class="hero panel">
<div class="left">
<div class="label">{{ hashrateLabel }}</div>
<div class="number glow-cyan">
{{ totalThs.toFixed(2) }}
<span class="unit">Th/s</span>
</div>
<div class="network-roast muted">{{ networkRoast }}</div>
</div>
<div class="right">
<div class="cell">
<div class="label">{{ blockLabel }}</div>
<div class="value">{{ blockHeight > 0 ? blockHeight.toLocaleString() : "—" }}</div>
</div>
<div class="cell">
<div class="label">{{ networkLabel }}</div>
<div class="value glow-amber">{{ networkEh > 0 ? `${networkEh.toFixed(0)} EH/s` : "—" }}</div>
</div>
<div class="cell">
<div class="label">network is bigger by</div>
<div class="value glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "—" }}</div>
</div>
<div class="cell">
<div class="label">subscribed</div>
<div class="value">{{ subscribed }}</div>
</div>
<div class="cell">
<div class="label">accepted</div>
<div class="value glow-green">{{ sharesAccepted.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">rejected</div>
<div class="value glow-red">{{ sharesRejected.toLocaleString() }}</div>
</div>
<div class="cell">
<div class="label">last poll</div>
<div class="value muted">{{ ageS === null ? "—" : `${ageS}s ago` }}</div>
</div>
</div>
</section>
</template>
<style scoped>
.hero {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 24px;
align-items: center;
}
.left .number {
font-size: 64px;
font-weight: 700;
letter-spacing: 0.04em;
line-height: 1;
}
.left .unit {
font-size: 18px;
color: var(--fg-2);
}
.network-roast {
margin-top: 10px;
font-size: 11px;
line-height: 1.4;
}
.right {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 12px;
align-items: stretch;
}
.cell {
min-height: 74px;
display: grid;
grid-template-rows: 34px 1fr;
align-content: start;
}
.cell .label {
min-height: 34px;
display: flex;
align-items: flex-start;
line-height: 1.25;
overflow: hidden;
}
.cell .value {
font-size: 18px;
font-weight: 600;
margin-top: 6px;
min-height: 28px;
display: flex;
align-items: baseline;
white-space: nowrap;
}
@media (max-width: 800px) {
.hero { grid-template-columns: 1fr; }
.right { grid-template-columns: repeat(2, 1fr); }
.left .number { font-size: 44px; }
}
</style>
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { RELEASE_NOTES } from "../releaseNotes";
defineProps<{ open: boolean }>();
const emit = defineEmits<{ close: [] }>();
</script>
<template>
<div v-if="open" class="release-backdrop" @click="emit('close')" />
<aside :class="['release-drawer', { open }]">
<header>
<div>
<div class="label">release notes</div>
<h2 class="glow-amber">what changed while the heaters screamed</h2>
</div>
<button class="thin" aria-label="close release notes" @click="emit('close')">×</button>
</header>
<div class="notes">
<article v-for="note in RELEASE_NOTES" :key="note.hash" class="note">
<div class="note-head">
<strong>{{ note.title }}</strong>
<code>{{ note.hash }}</code>
</div>
<ul>
<li v-for="bullet in note.bullets" :key="bullet">{{ bullet }}</li>
</ul>
</article>
</div>
</aside>
</template>
<style scoped>
.release-backdrop {
position: fixed;
inset: 0;
z-index: 18;
background: rgba(0, 0, 0, 0.22);
}
.release-drawer {
position: fixed;
top: 0;
right: 0;
z-index: 21;
width: min(520px, 94vw);
height: 100vh;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 16px;
background: rgba(7, 9, 15, 0.98);
border-left: 1px solid var(--line-bright);
box-shadow: -16px 0 40px rgba(0, 0, 0, 0.48);
transform: translateX(102%);
transition: transform 0.18s ease;
}
.release-drawer.open {
transform: translateX(0);
}
header,
.note-head {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 18px;
letter-spacing: 0;
}
.notes {
overflow: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.note {
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.025);
padding: 12px;
}
.note-head strong {
color: var(--neon-cyan);
}
code {
color: var(--fg-2);
font-size: 10px;
}
ul {
margin: 8px 0 0;
padding-left: 18px;
}
li {
margin: 5px 0;
color: var(--fg-1);
font-size: 12px;
line-height: 1.4;
}
@media (max-width: 760px) {
.release-drawer {
top: auto;
bottom: 0;
width: 100vw;
height: 65vh;
transform: translateY(105%);
border-left: 0;
border-top: 1px solid var(--line-bright);
}
.release-drawer.open {
transform: translateY(0);
}
}
</style>
@@ -0,0 +1,372 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
import { multiple, tinyPercentAsOdds } from "../format";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const HASHES_PER_TH = 1e12;
const DIFF_ONE_HASHES = 2 ** 32;
const BLOCKS_PER_DAY = 144;
const DAYS_PER_YEAR = 365;
const SUBSIDY_SATS = 312_500_000;
const miners = computed(() => props.snapshot?.miners ?? []);
const totalThs = computed(() => props.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => props.snapshot?.network.hashrateEh ?? 0);
const networkMultiple = computed(() => networkEh.value > 0 && totalThs.value > 0 ? (networkEh.value * 1_000_000) / totalThs.value : 0);
const totalWatts = computed(() => miners.value.reduce((sum, m) => sum + m.watts, 0));
const rewardSats = computed(() => props.snapshot?.job.blockValueSats || SUBSIDY_SATS);
const difficulty = computed(() => props.snapshot?.job.difficulty || 0);
const bestByMiner = computed(() => bestWorkByMiner(props.history));
const poolBest = computed(() => Math.max(0, ...Object.values(bestByMiner.value)));
const windowHours = computed(() => {
const first = props.history[0];
const last = props.history.at(-1);
if (!first || !last || last.t <= first.t) return 0;
return (last.t - first.t) / 3600000;
});
const shareVelocity = computed(() => {
const first = props.history[0];
const last = props.history.at(-1);
if (!first || !last || windowHours.value <= 0) return 0;
return Math.max(0, last.accepted - first.accepted) / windowHours.value;
});
const expectedSecondsToBlock = computed(() => {
if (!difficulty.value || totalThs.value <= 0) return 0;
return (difficulty.value * DIFF_ONE_HASHES) / (totalThs.value * HASHES_PER_TH);
});
const expectedBlocksYear = computed(() => expectedSecondsToBlock.value > 0 ? (DAYS_PER_YEAR * 86400) / expectedSecondsToBlock.value : 0);
const expectedSatsDay = computed(() => expectedBlocksYear.value > 0 ? (expectedBlocksYear.value / DAYS_PER_YEAR) * rewardSats.value : 0);
const heatBtu = computed(() => totalWatts.value * 3.412);
const poolEfficiency = computed(() => totalWatts.value > 0 ? totalThs.value / totalWatts.value : 0);
const connectionHealth = computed(() => {
const pool = props.snapshot?.pool;
if (!pool) return "collecting";
if (pool.totalSubscriptions < miners.value.length) return "missing subscriptions";
if (miners.value.some((m) => m.status !== "hashing")) return "somebody wandered off";
if (pool.activeThreads <= 0) return "threads unknown";
return "nominal, suspiciously";
});
const minerRows = computed(() => {
return miners.value
.map((m) => {
const key = minerKey(m);
const samples = props.history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
const avg = average(samples);
const sd = stddev(samples, avg);
const dominance = totalThs.value > 0 ? (m.hashrateThs / totalThs.value) * 100 : 0;
const expectedPct = m.expectedHashrateThs > 0 ? (m.hashrateThs / m.expectedHashrateThs) * 100 : 0;
const idlePct = statusPct(props.history, key, "idle") + statusPct(props.history, key, "stale");
const best = bestByMiner.value[key] ?? 0;
return {
key,
miner: m,
avg,
high: samples.length ? Math.max(...samples) : 0,
low: samples.length ? Math.min(...samples) : 0,
sd,
dominance,
expectedPct,
idlePct,
best,
bestDifficultyPct: difficulty.value > 0 ? (best / difficulty.value) * 100 : 0,
thPerWatt: m.watts > 0 ? m.hashrateThs / m.watts : 0,
heatBtu: m.watts * 3.412,
sharesPerHour: minerShareVelocity(props.history, key),
line: personaLine(m, dominance, expectedPct, sd, idlePct),
};
})
.sort((a, b) => b.best - a.best || b.miner.hashrateThs - a.miner.hashrateThs);
});
const leaders = computed(() => {
const rows = minerRows.value;
return [
{ label: "carrier", name: [...rows].sort((a, b) => b.dominance - a.dominance)[0]?.miner.nickname ?? "-" },
{ label: "best swing", name: [...rows].sort((a, b) => b.best - a.best)[0]?.miner.nickname ?? "-" },
{ label: "most stable", name: [...rows].sort((a, b) => a.sd - b.sd)[0]?.miner.nickname ?? "-" },
{ label: "reject pain", name: [...miners.value].sort((a, b) => b.rejectPct - a.rejectPct)[0]?.nickname ?? "-" },
];
});
function bestWorkByMiner(history: HistoryPoint[]): Record<string, number> {
const out: Record<string, number> = {};
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
for (const [key, work] of Object.entries(next.minerWork)) {
const shares = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = work - (prev.minerWork[key] ?? work);
if (shares > 0 && workDelta > 0) out[key] = Math.max(out[key] ?? 0, workDelta / shares);
}
}
return out;
}
function minerKey(m: MinerStat): string {
return m.authUsername || m.remoteHost || m.nickname;
}
function average(values: number[]): number {
if (!values.length) return 0;
return values.reduce((sum, n) => sum + n, 0) / values.length;
}
function stddev(values: number[], avg: number): number {
if (values.length < 2) return 0;
return Math.sqrt(values.reduce((sum, n) => sum + (n - avg) ** 2, 0) / values.length);
}
function statusPct(history: HistoryPoint[], key: string, status: MinerStat["status"]): number {
const seen = history.filter((p) => p.minerStatus?.[key]);
if (!seen.length) return 0;
return (seen.filter((p) => p.minerStatus[key] === status).length / seen.length) * 100;
}
function minerShareVelocity(history: HistoryPoint[], key: string): number {
const first = history.find((p) => Number.isFinite(p.minerShares?.[key]));
const last = [...history].reverse().find((p) => Number.isFinite(p.minerShares?.[key]));
if (!first || !last || last.t <= first.t) return 0;
return Math.max(0, (last.minerShares[key] ?? 0) - (first.minerShares[key] ?? 0)) / ((last.t - first.t) / 3600000);
}
function personaLine(m: MinerStat, dominance: number, expectedPct: number, sd: number, idlePct: number): string {
if (idlePct > 30) return `${m.nickname} has spent too much of this window being decorative.`;
if (m.rejectPct > 5) return `${m.nickname} is donating work to the void with confidence.`;
if (expectedPct > 110) return `${m.nickname} is carrying the group project and will be insufferable about it.`;
if (dominance > 55) return `${m.nickname} is the adult in the room, which is bleak for everyone else.`;
if (sd > Math.max(0.4, m.hashrateThs * 0.25)) return `${m.nickname} hashes like a shopping trolley with one bad wheel.`;
return `${m.nickname} is doing its tiny job. Nobody alert the institutions.`;
}
function ageLabel(seconds: number | null): string {
if (seconds === null) return "never";
if (seconds < 60) return `${seconds.toFixed(0)}s`;
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
return `${(seconds / 3600).toFixed(1)}h`;
}
function duration(seconds: number): string {
if (!seconds || !Number.isFinite(seconds)) return "collecting";
const years = seconds / 31536000;
if (years >= 1e9) return `${(years / 1e9).toFixed(2)}B years`;
if (years >= 1e6) return `${(years / 1e6).toFixed(2)}M years`;
if (years >= 1000) return `${(years / 1000).toFixed(2)}K years`;
if (years >= 1) return `${years.toFixed(1)} years`;
const days = seconds / 86400;
return days >= 1 ? `${days.toFixed(1)} days` : `${(seconds / 3600).toFixed(1)}h`;
}
function compact(n: number, digits = 2): string {
if (!Number.isFinite(n)) return "-";
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
return n.toFixed(digits);
}
function pct(n: number, digits = 4): string {
if (!Number.isFinite(n) || n <= 0) return "0%";
if (n < 0.001) return tinyPercentAsOdds(n);
return `${n.toFixed(digits)}%`;
}
</script>
<template>
<section class="shameboard panel">
<header>
<div>
<div class="label">leaderboard / shameboard</div>
<h2 class="glow-magenta">Receipts from the hashing cupboard</h2>
</div>
<div class="health">
<span class="label">connection</span>
<strong>{{ connectionHealth }}</strong>
</div>
</header>
<div class="summary">
<div>
<span class="label">best pool swing</span>
<strong class="glow-amber">{{ poolBest > 0 ? compact(poolBest) : "collecting" }}</strong>
<small>{{ difficulty > 0 ? `${pct((poolBest / difficulty) * 100)} of difficulty` : "difficulty unavailable" }}</small>
</div>
<div>
<span class="label">expected block</span>
<strong>{{ duration(expectedSecondsToBlock) }}</strong>
<small>{{ compact(expectedBlocksYear, 8) }} blocks/year</small>
</div>
<div>
<span class="label">imaginary payday</span>
<strong class="glow-green">{{ compact(expectedSatsDay, 4) }}</strong>
<small>sats/day expected</small>
</div>
<div>
<span class="label">share velocity</span>
<strong>{{ compact(shareVelocity, 1) }}</strong>
<small>accepted/hour</small>
</div>
<div>
<span class="label">space heater mode</span>
<strong>{{ compact(totalWatts, 0) }} W</strong>
<small>{{ compact(heatBtu, 0) }} BTU/hr · {{ compact(poolEfficiency, 4) }} TH/W</small>
</div>
<div>
<span class="label">network bullying</span>
<strong class="glow-red">{{ networkMultiple > 0 ? multiple(networkMultiple) : "collecting" }}</strong>
<small>{{ networkEh > 0 ? `${compact(networkEh, 0)} EH/s globally. easy version: the planet brought a warehouse.` : "tx1138 mempool warming up" }}</small>
</div>
</div>
<div class="leader-strip">
<div v-for="leader in leaders" :key="leader.label">
<span class="label">{{ leader.label }}</span>
<strong>{{ leader.name }}</strong>
</div>
</div>
<div class="table">
<div class="row head">
<span>miner</span>
<span>best</span>
<span>share</span>
<span>stability</span>
<span>power</span>
<span>last</span>
</div>
<div v-for="row in minerRows" :key="row.key" class="row">
<div class="who">
<strong>{{ row.miner.nickname }}</strong>
<small>{{ row.line }}</small>
</div>
<span>
{{ row.best > 0 ? compact(row.best) : "collecting" }}
<small>{{ difficulty > 0 ? pct(row.bestDifficultyPct) : "diff ?" }}</small>
</span>
<span>
{{ compact(row.dominance, 1) }}%
<small>{{ compact(row.sharesPerHour, 1) }}/hr</small>
</span>
<span>
{{ compact(row.avg) }} TH/s
<small>{{ compact(row.low) }}-{{ compact(row.high) }} · sd {{ compact(row.sd) }}</small>
</span>
<span>
{{ compact(row.thPerWatt, 4) }} TH/W
<small>{{ row.miner.watts }} W · {{ compact(row.heatBtu, 0) }} BTU/hr</small>
</span>
<span>
{{ ageLabel(row.miner.lastShareAgeS) }}
<small>{{ compact(row.idlePct, 1) }}% idle/stale</small>
</span>
</div>
</div>
</section>
</template>
<style scoped>
.shameboard {
display: flex;
flex-direction: column;
gap: 16px;
margin: 16px 0;
}
header,
.summary,
.leader-strip,
.row {
display: grid;
gap: 12px;
}
header {
grid-template-columns: 1fr auto;
align-items: start;
}
h2 {
margin: 2px 0 0;
font-size: 20px;
letter-spacing: 0;
}
.health {
text-align: right;
}
.health strong {
display: block;
color: var(--neon-cyan);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.summary {
grid-template-columns: repeat(6, 1fr);
}
.summary > div,
.leader-strip > div {
border: 1px solid var(--line);
background: rgba(0, 0, 0, 0.18);
padding: 10px;
min-height: 88px;
}
strong {
display: block;
font-size: 16px;
}
small {
display: block;
color: var(--fg-2);
font-size: 10px;
line-height: 1.35;
}
.leader-strip {
grid-template-columns: repeat(4, 1fr);
}
.table {
display: flex;
flex-direction: column;
border-top: 1px solid var(--line);
}
.row {
grid-template-columns: minmax(180px, 1.6fr) repeat(5, minmax(96px, 1fr));
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--line);
}
.row.head {
color: var(--fg-2);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.who strong {
color: var(--neon-magenta);
}
@media (max-width: 1100px) {
.summary {
grid-template-columns: repeat(2, 1fr);
}
.leader-strip {
grid-template-columns: repeat(2, 1fr);
}
.row {
grid-template-columns: 1fr 1fr;
}
.row.head {
display: none;
}
}
@media (max-width: 640px) {
header,
.summary,
.leader-strip,
.row {
grid-template-columns: 1fr;
}
.health {
text-align: left;
}
}
</style>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { DatumSnapshot } from "../types";
const props = defineProps<{ snapshot: DatumSnapshot | null }>();
// Simple sparkline: keep the last N hashrate samples and render as SVG.
const SAMPLES = 60;
const samples = ref<number[]>([]);
let lastFetchedAt = 0;
watch(
() => props.snapshot,
(snap) => {
if (!snap || snap.fetchedAt === lastFetchedAt) return;
lastFetchedAt = snap.fetchedAt;
samples.value.push(snap.pool.combinedHashrateThs);
if (samples.value.length > SAMPLES) samples.value.shift();
},
);
const path = computed(() => {
const ys = samples.value;
if (ys.length < 2) return "";
const max = Math.max(...ys, 0.001);
const w = 100;
const h = 32;
return ys
.map((y, i) => {
const x = (i / (SAMPLES - 1)) * w;
const yPx = h - (y / max) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${yPx.toFixed(2)}`;
})
.join(" ");
});
</script>
<template>
<section class="ticker panel">
<div class="label">hashrate · last {{ SAMPLES }} polls</div>
<svg viewBox="0 0 100 32" preserveAspectRatio="none">
<path :d="path" stroke="var(--neon-cyan)" stroke-width="0.6" fill="none" />
</svg>
</section>
</template>
<style scoped>
.ticker { display: flex; flex-direction: column; gap: 6px; }
svg { width: 100%; height: 60px; }
</style>
@@ -0,0 +1,18 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
export function useRotatingCopy(lines: readonly string[], intervalMs = 10_000) {
const index = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
index.value = (index.value + 1) % lines.length;
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) window.clearInterval(timer);
});
return computed(() => lines[index.value] ?? lines[0] ?? "");
}
+114
View File
@@ -0,0 +1,114 @@
export const HASHRATE_LABELS = [
"total hashrate · all actual miners",
"tiny thunder department",
"hashing cupboard output",
"combined cope velocity",
"someone's getting warmed tonight",
"space heaters with receipts",
"four boards versus a planet",
"domestic sha-256 turbulence",
"the little engines that almost certainly won't",
"thermal optimism index",
"proof-of-warmth aggregate",
"hashrate, allegedly",
"tiny chips doing tax fraud to physics",
"the room is warmer than the odds",
"solo lottery horsepower",
"sats-per-delusion engine",
"garage-grade entropy cannon",
"pleb furnace output",
"all miners, no excuses",
"bitcoin-adjacent radiator speed",
] as const;
export const BLOCK_LABELS = [
"current block height",
"where bitcoin is right now",
"latest block on the wall",
"chain height, for the adults",
"block number the kids are chasing",
"global scoreboard tick",
"bitcoin's current floor",
"the moving target",
"height of the mountain",
"mempool says we're here",
"sovereign block receipt",
"latest confirmed step",
"chain altitude",
"block ladder position",
"what the node believes",
"today's impossible number",
"where the grown-up miners are",
"the target keeps walking",
"bitcoin's odometer",
"public humiliation height",
] as const;
export const NETWORK_LABELS = [
"network hashrate",
"the actual boss fight",
"everyone else, unfortunately",
"global mining industrial complex",
"the dragon outside",
"planetary sha-256 weather",
"big pool gravity",
"the reason this is funny",
"industrial-scale bullying",
"total network furnace",
"the adults with warehouses",
"hashrate landlord class",
"bitcoin's meat grinder",
"global chip argument",
"big miner misery wall",
"the mountain of nope",
"everyone versus your shelf",
"network-sized emotional damage",
"the pool hall full of monsters",
"the thing Boomer Heater fears",
] as const;
export const RACE_TITLES = [
"Tiny machines, deeply unfair track",
"Actual miners, spiritually overconfident",
"The cupboard grand prix",
"Drag race at walking speed",
"The hashrate kindergarten derby",
"Domestic miners doing their little laps",
"Four warm rectangles and a dream",
"Race day for the statistically doomed",
"The space-heater invitational",
"Actual miners, actual delusion",
"The tiny ASIC humiliation circuit",
"Tour de not-finding-a-block",
"Hashrate horse race, if horses were firmware",
"A sprint measured in geological disappointment",
"The nobody-is-retiring 500",
"Proof-of-work nursery slopes",
"The little rigs are trying, allegedly",
"Solo mining track day",
"The warm boys championship",
"Someone's getting warmed tonight",
] as const;
export const RACE_SCALE_NOTES = [
"actual miners · scaled up to not shame the children",
"scaled up so the children remain visible",
"visual mercy scale enabled",
"not to scale, because cruelty has limits",
"tiny miner zoom mode",
"chart wearing reading glasses",
"magnified for emotional support",
"scaled up like their confidence",
"warehouse miners cropped out for decency",
"child-safe hashrate scale",
"actual miners, padded for dignity",
"zoomed in before the network laughs",
"big pools excluded from the yardstick",
"scale adjusted to preserve morale",
"the children get a booster seat",
"mercifully not linear",
"visual participation trophy mode",
"shown larger than nature intended",
"small rig cinema mode",
"the graph lies kindly",
] as const;
+26
View File
@@ -0,0 +1,26 @@
export function compactNumber(n: number, digits = 1): string {
if (!Number.isFinite(n)) return "-";
if (Math.abs(n) >= 1e12) return `${(n / 1e12).toFixed(digits)}T`;
if (Math.abs(n) >= 1e9) return `${(n / 1e9).toFixed(digits)}B`;
if (Math.abs(n) >= 1e6) return `${(n / 1e6).toFixed(digits)}M`;
if (Math.abs(n) >= 1e3) return `${(n / 1e3).toFixed(digits)}K`;
return n.toFixed(digits);
}
export function oneIn(probability: number): string {
if (!Number.isFinite(probability) || probability <= 0) return "basically never";
return `1 in ${compactNumber(1 / probability, 1)}`;
}
export function tinyPercentAsOdds(percent: number): string {
if (!Number.isFinite(percent) || percent <= 0) return "0%";
const probability = percent / 100;
if (percent < 0.001) return oneIn(probability);
if (percent < 1) return `${percent.toFixed(4)}%`;
return `${percent.toFixed(2)}%`;
}
export function multiple(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "-";
return `${compactNumber(n, 1)}x`;
}
+12
View File
@@ -0,0 +1,12 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import App from "./App.vue";
import { router } from "./router";
import { registerPwa } from "./services/pwa";
import "./style.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
registerPwa();
@@ -0,0 +1,96 @@
export type ReleaseNote = {
hash: string;
title: string;
bullets: string[];
};
export const RELEASE_NOTES: ReleaseNote[] = [
{
hash: "b715c3f",
title: "Nostr chat drawer",
bullets: [
"Added a signed Nostr chat panel, because apparently the miners needed a comments section for witnesses.",
"Desktop slides in from the right; mobile rises as a half-screen sheet like a tiny gossip bunker.",
"Loads kind-0 profile names and avatars, so the abuse has proper attribution.",
],
},
{
hash: "b2f81a1",
title: "Tidier hero stats",
bullets: [
"Pinned the hero stat boxes down so rotating jokes stop rearranging the furniture.",
"Aligned block height, network hashrate, and share stats like they have jobs.",
],
},
{
hash: "1f86bf9",
title: "Readable odds",
bullets: [
"Replaced cursed science notation with human-readable odds like '1 in 2.0B', which is still awful but now legible.",
"Slowed rotating labels and lottery jokes to 10 seconds so the dashboard stops acting caffeinated.",
],
},
{
hash: "fa707e2",
title: "Network stats and rotating jokes",
bullets: [
"Added block height, network hashrate, and difficulty from tx1138 mempool, because Datum was keeping the good gossip to itself.",
"Solo odds now use current network hashrate, so the humiliation is freshly calculated.",
"Added rotating labels and a large pile of darker lottery jokes about fiat, Bitcoin, and electrical self-deception.",
"Race view now compares actual miners to a large-pool-ish bully and the total network, on a mercy scale so the children remain visible.",
],
},
{
hash: "7e1f7a1",
title: "Heat-reactive theme",
bullets: [
"Interface now gets redder from 10 TH/s to 70 TH/s, because someone's getting warmed tonight.",
],
},
{
hash: "243cd0d",
title: "Boomer Heater got worse",
bullets: [
"Gave Boomer Heater a dead status, skull marker, and custom zero-stat labels because it contributes exactly fuck all.",
"Updated the heater asset to say 'NO HASHES. JUST GHEI.' Accurate thermodynamics, questionable monetary policy.",
],
},
{
hash: "d6f8fd7",
title: "Boomer Heater on status",
bullets: [
"Added fake Boomer Heater to the status screen, always last, where fiat appliances belong.",
"Race copy now says 'just fiat and a pension.' No sats, no shares, no future.",
],
},
{
hash: "0c8e26f",
title: "PWA and miner race",
bullets: [
"Made gashboard installable as a PWA with icons, service worker, and cache rules that should stop stale bundles haunting the cupboard.",
"Remote signer login can resume after Primal/Amber returns to the PWA callback, because mobile auth should not require ritual sacrifice.",
"Added miner race visuals with local miner assets, including the non-hashing radiator of shame.",
],
},
{
hash: "360b1eb",
title: "BigPapa",
bullets: ["Renamed the unknown Datum miner from ??? to BigPapa. Same mystery, more authority."],
},
{
hash: "c2c376f",
title: "Shameboard calculations",
bullets: [
"Added best observed share/work, expected block time, sats/day fantasy math, share velocity, reject pain, efficiency, BTU/hr, and per-miner insults. A spreadsheet with a personality disorder.",
],
},
{
hash: "c77c746",
title: "Graphs and remote signer login",
bullets: [
"Added graph tab with hashrate telemetry, share flow, miner mix, and browser-local history so the suffering can be trended.",
"Added Nostr Connect 'open signer app' login for Primal/Amber handoff, replacing the bunker URI homework nobody asked for.",
"Fixed Portainer-on-Umbrel deployment compose for host networking and direct Datum URL, because Portainer's fake network was cosplay.",
],
},
];
+45
View File
@@ -0,0 +1,45 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
import { useAuthStore } from "./stores/auth";
const routes: RouteRecordRaw[] = [
{
path: "/access",
name: "access",
component: () => import("./views/AccessView.vue"),
meta: { requiresAuth: true },
},
{
path: "/",
name: "dashboard",
component: () => import("./views/DashboardView.vue"),
meta: { requiresAuth: true },
},
{
path: "/graphs",
name: "graphs",
component: () => import("./views/GraphsView.vue"),
meta: { requiresAuth: true },
},
{
path: "/login",
name: "login",
component: () => import("./views/LoginView.vue"),
},
{
path: "/auth/nostr-callback",
name: "nostr-callback",
component: () => import("./views/NostrCallbackView.vue"),
},
];
export const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) return { name: "login" };
if (to.name === "login" && auth.isLoggedIn) return { name: "dashboard" };
return true;
});
@@ -0,0 +1,115 @@
import type { ApiError, ChatSnapshot, DatumSnapshot, LoginResponse } from "../types";
import { buildNip98AuthHeader } from "./nip98";
const TOKEN_KEY = "gashboard.jwt";
const NPUB_KEY = "gashboard.npub";
export function loadToken(): { token: string; npub: string } | null {
const token = localStorage.getItem(TOKEN_KEY);
const npub = localStorage.getItem(NPUB_KEY);
if (!token || !npub) return null;
return { token, npub };
}
export function saveToken(token: string, npub: string): void {
localStorage.setItem(TOKEN_KEY, token);
localStorage.setItem(NPUB_KEY, npub);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(NPUB_KEY);
}
async function asError(res: Response): Promise<Error> {
let body: ApiError | undefined;
try {
body = (await res.json()) as ApiError;
} catch {
/* ignore */
}
const code = body?.error?.code ?? `HTTP_${res.status}`;
const message = body?.error?.message ?? res.statusText;
const err = new Error(`${code}: ${message}`);
(err as Error & { code?: string }).code = code;
return err;
}
export async function login(): Promise<LoginResponse> {
const url = `${window.location.origin}/api/auth/login`;
const auth = await buildNip98AuthHeader("POST", url);
const res = await fetch(url, {
method: "POST",
headers: { authorization: auth, "content-type": "application/json" },
body: "{}",
});
if (!res.ok) throw await asError(res);
return (await res.json()) as LoginResponse;
}
async function authedGet<T>(path: string): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
headers: { authorization: `Bearer ${stored.token}` },
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
async function authedPost<T>(path: string, body: unknown): Promise<T> {
const stored = loadToken();
if (!stored) throw new Error("Not logged in");
const res = await fetch(path, {
method: "POST",
headers: {
authorization: `Bearer ${stored.token}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (res.status === 401) {
clearToken();
throw new Error("Session expired");
}
if (!res.ok) throw await asError(res);
return (await res.json()) as T;
}
export async function fetchSnapshot(): Promise<DatumSnapshot> {
return authedGet<DatumSnapshot>("/api/datum/stats");
}
export async function fetchChatPubkeys(): Promise<string[]> {
const res = await authedGet<{ pubkeys: string[] }>("/api/auth/chat-config");
return res.pubkeys;
}
export function fetchChat(): Promise<ChatSnapshot> {
return authedGet<ChatSnapshot>("/api/chat");
}
export function postChatMessage(body: {
content: string;
replyToId?: string;
replyToPubkey?: string;
}): Promise<ChatSnapshot["messages"][number]> {
return authedPost<ChatSnapshot["messages"][number]>("/api/chat/messages", body);
}
export function postChatReaction(body: {
messageId: string;
content: string;
}): Promise<ChatSnapshot["reactions"][number]> {
return authedPost<ChatSnapshot["reactions"][number]>("/api/chat/reactions", body);
}
export function postChatKeyWraps(body: {
wraps: Array<{ recipientPubkey: string; ciphertext: string }>;
}): Promise<{ wraps: ChatSnapshot["keyWraps"] }> {
return authedPost<{ wraps: ChatSnapshot["keyWraps"] }>("/api/chat/key-wraps", body);
}
@@ -0,0 +1,274 @@
import type { Event as NostrEvent } from "nostr-tools";
import { nip19 } from "nostr-tools";
import { SimplePool } from "nostr-tools/pool";
import { fetchChat, fetchChatPubkeys, postChatKeyWraps, postChatMessage, postChatReaction } from "./api";
import { getActiveSigner, hasActiveSigner } from "./signer";
import type { ChatSnapshot } from "../types";
const RELAYS = ["wss://relay.primal.net"];
const POLL_MS = 2500;
const pool = new SimplePool();
export type ChatMessage = {
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
};
export type ChatReaction = {
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
};
export type ChatProfile = {
pubkey: string;
name: string;
picture: string;
};
let reactionCache: ChatReaction[] = [];
let cachedRecipients: string[] | null = null;
let cachedRoomKey: CryptoKey | null = null;
let cachedRoomKeyRaw = "";
export function canSendChat(): boolean {
return hasActiveSigner();
}
export function pubkeyFromNpub(npub: string | null): string | null {
if (!npub) return null;
try {
const decoded = nip19.decode(npub);
return decoded.type === "npub" ? decoded.data : null;
} catch {
return null;
}
}
export async function loadChat(): Promise<ChatMessage[]> {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
return (await Promise.all(snapshot.messages.map(decryptMessage))).sort((a, b) => a.createdAt - b.createdAt);
}
export async function loadReactions(messageIds: string[]): Promise<ChatReaction[]> {
const wanted = new Set(messageIds);
if (!reactionCache.length) {
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
reactionCache = await Promise.all(snapshot.reactions.map(decryptReaction));
}
return reactionCache.filter((r) => wanted.has(r.messageId));
}
export function subscribeChat(
onMessage: (message: ChatMessage) => void,
onReaction?: (reaction: ChatReaction) => void,
): { close: () => void } {
const seenMessages = new Set<string>();
const seenReactions = new Set<string>();
let closed = false;
async function poll(): Promise<void> {
if (closed) return;
const snapshot = await fetchChat();
await ensureRoomKey(snapshot);
for (const message of snapshot.messages) {
if (seenMessages.has(message.id)) continue;
seenMessages.add(message.id);
onMessage(await decryptMessage(message));
}
for (const reaction of snapshot.reactions) {
if (seenReactions.has(reaction.id)) continue;
seenReactions.add(reaction.id);
onReaction?.(await decryptReaction(reaction));
}
}
const timer = window.setInterval(() => {
void poll().catch(() => {});
}, POLL_MS);
void poll().catch(() => {});
return {
close() {
closed = true;
window.clearInterval(timer);
},
};
}
export async function sendChat(
content: string,
replyTo?: { id: string; pubkey: string },
): Promise<ChatMessage> {
const encrypted = await encryptPayload(content);
const message = await postChatMessage({
content: encrypted,
...(replyTo ? { replyToId: replyTo.id, replyToPubkey: replyTo.pubkey } : {}),
});
return { ...message, content };
}
export async function sendReaction(message: ChatMessage, content: string): Promise<ChatReaction> {
const encrypted = await encryptPayload(content);
const reaction = await postChatReaction({ messageId: message.id, content: encrypted });
return { ...reaction, content };
}
export async function loadProfiles(pubkeys: string[]): Promise<Record<string, ChatProfile>> {
const authors = [...new Set(pubkeys)].filter(Boolean);
if (!authors.length) return {};
const events = await pool.querySync(RELAYS, { kinds: [0], authors }, { maxWait: 2500 });
const latest = new Map<string, NostrEvent>();
for (const event of events) {
const prev = latest.get(event.pubkey);
if (!prev || event.created_at > prev.created_at) latest.set(event.pubkey, event);
}
const out: Record<string, ChatProfile> = {};
for (const [pubkey, event] of latest) {
try {
const meta = JSON.parse(event.content) as { name?: string; display_name?: string; picture?: string };
out[pubkey] = {
pubkey,
name: meta.display_name || meta.name || shortPubkey(pubkey),
picture: meta.picture || "",
};
} catch {
out[pubkey] = { pubkey, name: shortPubkey(pubkey), picture: "" };
}
}
return out;
}
export function shortPubkey(pubkey: string): string {
return `${pubkey.slice(0, 8)}:${pubkey.slice(-4)}`;
}
type EncryptedEnvelope = {
v: 1;
alg: "aes-gcm";
room: "gashboard-chat";
iv: string;
ciphertext: string;
};
async function encryptPayload(plaintext: string): Promise<string> {
const key = await ensureRoomKey();
try {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
key,
new TextEncoder().encode(plaintext),
);
return JSON.stringify({
v: 1,
alg: "aes-gcm",
room: "gashboard-chat",
iv: base64(iv),
ciphertext: base64(new Uint8Array(ciphertext)),
} satisfies EncryptedEnvelope);
} catch (e) {
throw new Error(
e instanceof Error
? `Private chat encryption failed: ${e.message}. Reconnect signer if the room key is missing.`
: "Private chat encryption failed. Reconnect signer if the room key is missing.",
);
}
}
async function decryptPayload(value: string): Promise<string> {
try {
const envelope = JSON.parse(value) as EncryptedEnvelope;
if (envelope.v !== 1 || envelope.alg !== "aes-gcm" || envelope.room !== "gashboard-chat") {
return "[encrypted with unknown chat format]";
}
const key = await ensureRoomKey();
const decrypted = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: unbase64(envelope.iv) },
key,
unbase64(envelope.ciphertext),
);
return new TextDecoder().decode(decrypted);
} catch {
return "[encrypted - decrypt failed]";
}
}
async function decryptMessage(message: ChatMessage): Promise<ChatMessage> {
return { ...message, content: await decryptPayload(message.content) };
}
async function decryptReaction(reaction: ChatReaction): Promise<ChatReaction> {
return { ...reaction, content: await decryptPayload(reaction.content) };
}
async function getRecipients(): Promise<string[]> {
const signer = getActiveSigner();
const own = await signer?.getPublicKey();
if (!cachedRecipients) cachedRecipients = await fetchChatPubkeys();
return [...new Set([...(cachedRecipients ?? []), ...(own ? [own] : [])])].filter(Boolean);
}
async function ensureRoomKey(snapshot?: ChatSnapshot): Promise<CryptoKey> {
if (cachedRoomKey) return cachedRoomKey;
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to unlock private chat");
const own = await signer.getPublicKey();
const current = snapshot ?? await fetchChat();
const wrap = current.keyWraps.find((item) => item.recipientPubkey === own);
if (wrap) {
const raw = await signer.decrypt(wrap.senderPubkey, wrap.ciphertext);
return importRoomKey(raw);
}
const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const raw = base64(rawBytes);
await importRoomKey(raw);
await publishRoomKey(raw);
return cachedRoomKey!;
}
async function publishRoomKey(raw: string): Promise<void> {
const signer = getActiveSigner();
if (!signer) throw new Error("Reconnect your signer to share private chat key");
const recipients = await getRecipients();
const wraps = await Promise.all(
recipients.map(async (recipientPubkey) => ({
recipientPubkey,
ciphertext: await signer.encrypt(recipientPubkey, raw),
})),
);
await postChatKeyWraps({ wraps });
}
async function importRoomKey(raw: string): Promise<CryptoKey> {
if (cachedRoomKey && cachedRoomKeyRaw === raw) return cachedRoomKey;
cachedRoomKey = await crypto.subtle.importKey("raw", unbase64(raw), { name: "AES-GCM" }, false, [
"encrypt",
"decrypt",
]);
cachedRoomKeyRaw = raw;
return cachedRoomKey;
}
function base64(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function unbase64(value: string): Uint8Array {
const binary = atob(value);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
return out;
}
@@ -0,0 +1,21 @@
import type { EventTemplate } from "nostr-tools";
import { getActiveSigner } from "./signer";
export async function buildNip98AuthHeader(method: string, fullUrl: string): Promise<string> {
const signer = getActiveSigner();
if (!signer) throw new Error("Not logged in");
const template: EventTemplate = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
["u", fullUrl],
["method", method.toUpperCase()],
],
content: "",
};
const signed = await signer.signEvent(template);
const json = JSON.stringify(signed);
const b64 = btoa(unescape(encodeURIComponent(json)));
return `Nostr ${b64}`;
}
@@ -0,0 +1,18 @@
export function registerPwa(): void {
if (!("serviceWorker" in navigator)) return;
if (import.meta.env.DEV) return;
window.addEventListener("load", () => {
void navigator.serviceWorker.register("/sw.js").then((registration) => {
registration.addEventListener("updatefound", () => {
const worker = registration.installing;
if (!worker) return;
worker.addEventListener("statechange", () => {
if (worker.state === "installed" && navigator.serviceWorker.controller) {
worker.postMessage({ type: "SKIP_WAITING" });
}
});
});
});
});
}
@@ -0,0 +1,404 @@
// Two signing paths: NIP-07 browser extension and NIP-46 remote signer
// (covers Primal app + Amber via bunker:// URI). The active signer is held
// in module state — there's only one logged-in user at a time.
import type { Event as NostrEvent, EventTemplate } from "nostr-tools";
import type { Filter } from "nostr-tools/filter";
import { SimplePool } from "nostr-tools/pool";
import { readonly, ref } from "vue";
export type SignerKind = "extension" | "bunker";
export type Signer = {
kind: SignerKind;
getPublicKey(): Promise<string>;
signEvent(template: EventTemplate): Promise<NostrEvent>;
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
const activeSigner = ref<Signer | null>(null);
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
const NOSTR_CONNECT_PENDING_KEY = "gashboard.nostrconnect.pending";
const SIGNER_SESSION_KEY = "gashboard.signer.session";
const pool = new SimplePool();
export function getActiveSigner(): Signer | null {
return activeSigner.value;
}
export function hasActiveSigner(): boolean {
return activeSigner.value !== null;
}
export function clearSigner(): void {
activeSigner.value = null;
clearSavedSignerSession();
}
export const activeSignerState = readonly(activeSigner);
export async function loginWithExtension(): Promise<string> {
if (!window.nostr) {
throw new Error("No Nostr signer available. Open Gashboard from Archipelago, or enable a NIP-07 browser extension.");
}
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
activeSigner.value = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await ext.signEvent({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your signer cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your signer cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return pubkey;
}
export async function loginWithBunker(bunkerUri: string): Promise<string> {
// Lazy-load applesauce-signers — only pulled when bunker login is attempted
// so the dashboard works for users who only use NIP-07.
const mod = await import("applesauce-signers");
const Ctor =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
if (!Ctor) {
throw new Error("applesauce-signers does not expose NostrConnectSigner");
}
const fromUri = Ctor.fromBunkerURI ?? Ctor.fromURI;
if (!fromUri) {
throw new Error("NostrConnectSigner has no fromBunkerURI / fromURI helper");
}
const signer = await fromUri.call(Ctor, bunkerUri);
const pubkey: string = await signer.getPublicKey();
const remote = signer.remote || pubkey;
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
key: Array.from(signer.signer.key),
secret: signer.secret,
relays: signer.relays,
remote,
pubkey,
});
return pubkey;
}
export async function loginWithRemoteApp(): Promise<string> {
return connectRemoteApp({ openApp: true });
}
export async function reconnectRemoteApp(): Promise<string> {
clearSigner();
return connectRemoteApp({ openApp: true });
}
export async function resumeRemoteAppLogin(): Promise<string> {
return connectRemoteApp({ openApp: false });
}
export function hasPendingRemoteAppLogin(): boolean {
return !!sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
}
async function connectRemoteApp(opts: { openApp: boolean }): Promise<string> {
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
const PrivateKeySigner =
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
if (!NostrConnectSigner || !PrivateKeySigner) {
throw new Error("Nostr Connect signer support is unavailable");
}
const pending = loadPendingRemoteLogin();
const clientSigner = pending
? new PrivateKeySigner(new Uint8Array(pending.key))
: new PrivateKeySigner();
const signer = new NostrConnectSigner({
relays: pending?.relays ?? NOSTR_CONNECT_RELAYS,
signer: clientSigner,
...(pending ? { secret: pending.secret } : {}),
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
const permissions = [
...(NostrConnectSigner.buildSigningPermissions?.([27235, 4]) ?? ["sign_event:27235", "sign_event:4"]),
"nip44_encrypt",
"nip44_decrypt",
];
if (!pending) {
savePendingRemoteLogin({
key: Array.from(clientSigner.key),
secret: signer.secret,
relays: NOSTR_CONNECT_RELAYS,
});
}
if (opts.openApp) {
const uri = withCallback(
signer.getNostrConnectURI({
name: "gashboard",
url: window.location.origin,
permissions,
}),
);
openSignerApp(uri);
}
const abort = new AbortController();
const timeout = window.setTimeout(() => abort.abort(), NOSTR_CONNECT_TIMEOUT_MS);
try {
await signer.waitForSigner(abort.signal);
if (!signer.remote) throw new Error("Remote signer did not complete the connection");
const pubkey: string = await signer.getPublicKey();
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
saveSignerSession({
kind: "bunker",
key: Array.from(clientSigner.key),
secret: signer.secret,
relays: signer.relays,
remote: signer.remote,
pubkey,
});
clearPendingRemoteLogin();
return pubkey;
} finally {
window.clearTimeout(timeout);
if (!signer.isConnected) await signer.close().catch(() => {});
}
}
export async function restoreSavedSigner(): Promise<boolean> {
if (activeSigner.value) return true;
const saved = loadSavedSignerSession();
if (!saved) return false;
if (saved.kind === "extension") {
if (!window.nostr) return false;
const ext = window.nostr;
const pubkey = await ext.getPublicKey();
if (saved.pubkey && saved.pubkey !== pubkey) {
clearSavedSignerSession();
return false;
}
activeSigner.value = {
kind: "extension",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await ext.signEvent({
kind: template.kind,
created_at: template.created_at,
tags: template.tags,
content: template.content,
})) as NostrEvent,
encrypt: async (target: string, plaintext: string) => {
if (ext.nip44) return ext.nip44.encrypt(target, plaintext);
if (ext.nip04) return ext.nip04.encrypt(target, plaintext);
throw new Error("Your signer cannot encrypt private chat messages");
},
decrypt: async (sender: string, ciphertext: string) => {
if (ext.nip44) return ext.nip44.decrypt(sender, ciphertext);
if (ext.nip04) return ext.nip04.decrypt(sender, ciphertext);
throw new Error("Your signer cannot decrypt private chat messages");
},
};
saveSignerSession({ kind: "extension", pubkey });
return true;
}
const mod = await import("applesauce-signers");
const NostrConnectSigner =
(mod as { NostrConnectSigner?: any }).NostrConnectSigner ??
((mod as { default?: { NostrConnectSigner?: any } }).default?.NostrConnectSigner);
const PrivateKeySigner =
(mod as { PrivateKeySigner?: any }).PrivateKeySigner ??
((mod as { default?: { PrivateKeySigner?: any } }).default?.PrivateKeySigner);
if (!NostrConnectSigner || !PrivateKeySigner) return false;
const signer = new NostrConnectSigner({
relays: saved.relays,
signer: new PrivateKeySigner(new Uint8Array(saved.key)),
secret: saved.secret,
remote: saved.remote,
pubkey: saved.pubkey,
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
activeSigner.value = {
kind: "bunker",
getPublicKey: async () => saved.pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
encrypt: async (target: string, plaintext: string) => signer.nip44Encrypt(target, plaintext),
decrypt: async (sender: string, ciphertext: string) => signer.nip44Decrypt(sender, ciphertext),
};
return true;
}
type PendingRemoteLogin = {
key: number[];
secret: string;
relays: string[];
};
type SavedSignerSession =
| {
kind: "extension";
pubkey: string;
}
| {
kind: "bunker";
key: number[];
secret: string;
relays: string[];
remote: string;
pubkey: string;
};
function loadPendingRemoteLogin(): PendingRemoteLogin | null {
try {
const raw = sessionStorage.getItem(NOSTR_CONNECT_PENDING_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as PendingRemoteLogin;
if (!Array.isArray(parsed.key) || !parsed.secret || !Array.isArray(parsed.relays)) return null;
return parsed;
} catch {
return null;
}
}
function savePendingRemoteLogin(pending: PendingRemoteLogin): void {
sessionStorage.setItem(NOSTR_CONNECT_PENDING_KEY, JSON.stringify(pending));
}
function clearPendingRemoteLogin(): void {
sessionStorage.removeItem(NOSTR_CONNECT_PENDING_KEY);
}
function loadSavedSignerSession(): SavedSignerSession | null {
try {
const raw = localStorage.getItem(SIGNER_SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as SavedSignerSession;
if (parsed.kind === "extension" && parsed.pubkey) return parsed;
if (
parsed.kind === "bunker" &&
Array.isArray(parsed.key) &&
parsed.secret &&
Array.isArray(parsed.relays) &&
parsed.remote &&
parsed.pubkey
) {
return parsed;
}
return null;
} catch {
return null;
}
}
function saveSignerSession(session: SavedSignerSession): void {
localStorage.setItem(SIGNER_SESSION_KEY, JSON.stringify(session));
}
function clearSavedSignerSession(): void {
localStorage.removeItem(SIGNER_SESSION_KEY);
}
function withCallback(uri: string): string {
const separator = uri.includes("?") ? "&" : "?";
return `${uri}${separator}callback=${encodeURIComponent(`${window.location.origin}/auth/nostr-callback`)}`;
}
function openSignerApp(uri: string): void {
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(
navigator.userAgent,
);
if (isMobile) {
window.location.href = uri;
return;
}
const opened = window.open(uri, "_blank", "noopener,noreferrer");
if (!opened) window.location.href = uri;
}
function subscribeToRelays(relays: string[], filters: Filter[]): AsyncIterable<NostrEvent | string> {
return {
[Symbol.asyncIterator]() {
const queue: Array<NostrEvent | string> = [];
let wake: (() => void) | null = null;
let closed = false;
const sub = pool.subscribeMany(relays, filters, {
onevent(event) {
queue.push(event as NostrEvent);
wake?.();
},
onclose(reasons) {
for (const reason of reasons) queue.push(reason);
closed = true;
wake?.();
},
});
return {
async next(): Promise<IteratorResult<NostrEvent | string>> {
while (!queue.length && !closed) {
await new Promise<void>((resolve) => {
wake = resolve;
});
wake = null;
}
const value = queue.shift();
if (value) return { value, done: false };
return { value: undefined, done: true };
},
async return(): Promise<IteratorResult<NostrEvent | string>> {
closed = true;
sub.close();
wake?.();
return { value: undefined, done: true };
},
};
},
};
}
async function publishToRelays(relays: string[], event: NostrEvent): Promise<void> {
const results = await Promise.allSettled(pool.publish(relays, event));
if (!results.some((r) => r.status === "fulfilled")) {
throw new Error("Could not publish Nostr Connect request to relay");
}
}
@@ -0,0 +1,147 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import * as api from "../services/api";
import * as signer from "../services/signer";
export const useAuthStore = defineStore("auth", () => {
const stored = api.loadToken();
const npub = ref<string | null>(stored?.npub ?? null);
const token = ref<string | null>(stored?.token ?? null);
const error = ref<string | null>(null);
const busy = ref(false);
const restoringSigner = ref(false);
const isLoggedIn = computed(() => !!token.value);
const hasActiveSigner = computed(() => signer.activeSignerState.value !== null);
if (token.value) void restoreSavedSigner();
async function loginExtension(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithExtension();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
async function loginBunker(uri: string): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithBunker(uri);
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Login failed";
throw e;
} finally {
busy.value = false;
}
}
async function loginRemoteApp(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.loginWithRemoteApp();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer login failed";
throw e;
} finally {
busy.value = false;
}
}
async function reconnectRemoteApp(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.reconnectRemoteApp();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer reconnect failed";
throw e;
} finally {
busy.value = false;
}
}
async function resumeRemoteAppLogin(): Promise<void> {
error.value = null;
busy.value = true;
try {
await signer.resumeRemoteAppLogin();
const r = await api.login();
token.value = r.token;
npub.value = r.npub;
api.saveToken(r.token, r.npub);
} catch (e) {
signer.clearSigner();
error.value = e instanceof Error ? e.message : "Remote signer login failed";
throw e;
} finally {
busy.value = false;
}
}
function logout(): void {
api.clearToken();
signer.clearSigner();
token.value = null;
npub.value = null;
error.value = null;
}
async function restoreSavedSigner(): Promise<boolean> {
if (hasActiveSigner.value) return true;
restoringSigner.value = true;
try {
return await signer.restoreSavedSigner();
} catch (e) {
error.value = e instanceof Error ? e.message : "Could not restore signer";
return false;
} finally {
restoringSigner.value = false;
}
}
return {
npub,
token,
error,
busy,
restoringSigner,
isLoggedIn,
hasActiveSigner,
loginExtension,
loginBunker,
loginRemoteApp,
reconnectRemoteApp,
resumeRemoteAppLogin,
restoreSavedSigner,
hasPendingRemoteAppLogin: signer.hasPendingRemoteAppLogin,
logout,
};
});
@@ -0,0 +1,101 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "../services/api";
import type { DatumSnapshot, HistoryPoint } from "../types";
const POLL_MS = 5000;
const HISTORY_KEY = "gashboard.history.v1";
const MAX_HISTORY_AGE_MS = 24 * 60 * 60 * 1000;
const MAX_HISTORY_POINTS = 18_000;
function loadHistory(): HistoryPoint[] {
try {
const raw = localStorage.getItem(HISTORY_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as HistoryPoint[];
if (!Array.isArray(parsed)) return [];
const minT = Date.now() - MAX_HISTORY_AGE_MS;
return parsed.filter((p) => Number.isFinite(p.t) && p.t >= minT);
} catch {
return [];
}
}
function asHistoryPoint(snap: DatumSnapshot): HistoryPoint {
const miners: Record<string, number> = {};
const minerWork: Record<string, number> = {};
const minerShares: Record<string, number> = {};
const minerStatus: Record<string, DatumSnapshot["miners"][number]["status"]> = {};
for (const miner of snap.miners) {
const key = miner.authUsername || miner.remoteHost || miner.nickname;
miners[key] = miner.hashrateThs;
minerWork[key] = miner.diffAcceptedSum;
minerShares[key] = miner.diffAcceptedCount;
minerStatus[key] = miner.status;
}
return {
t: snap.fetchedAt,
ths: snap.pool.combinedHashrateThs,
accepted: snap.pool.sharesAccepted,
rejected: snap.pool.sharesRejected,
connections: snap.pool.totalConnections,
subscriptions: snap.pool.totalSubscriptions,
height: snap.job.blockHeight,
miners,
minerWork,
minerShares,
minerStatus,
};
}
export const useStatsStore = defineStore("stats", () => {
const snapshot = ref<DatumSnapshot | null>(null);
const history = ref<HistoryPoint[]>(loadHistory());
const error = ref<string | null>(null);
const loading = ref(false);
let timer: number | null = null;
let persistTimer: number | null = null;
function remember(snap: DatumSnapshot): void {
if (!snap.ok) return;
const last = history.value.at(-1);
if (last?.t === snap.fetchedAt) return;
history.value.push(asHistoryPoint(snap));
const minT = Date.now() - MAX_HISTORY_AGE_MS;
history.value = history.value.filter((p) => p.t >= minT).slice(-MAX_HISTORY_POINTS);
if (persistTimer !== null) window.clearTimeout(persistTimer);
persistTimer = window.setTimeout(() => {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.value));
persistTimer = null;
}, 250);
}
async function fetchOnce(): Promise<void> {
try {
loading.value = true;
const next = await api.fetchSnapshot();
snapshot.value = next;
remember(next);
error.value = null;
} catch (e) {
error.value = e instanceof Error ? e.message : "Fetch failed";
} finally {
loading.value = false;
}
}
function start(): void {
if (timer !== null) return;
void fetchOnce();
timer = window.setInterval(() => void fetchOnce(), POLL_MS);
}
function stop(): void {
if (timer !== null) {
window.clearInterval(timer);
timer = null;
}
}
return { snapshot, history, error, loading, fetchOnce, start, stop };
});
+177
View File
@@ -0,0 +1,177 @@
import { oneIn } from "./format";
// All user-facing copy. Lean into the futility — these are 4 hobby boards
// trying to hit a 1-in-quadrillions lottery. Take the piss with affection.
export const ROASTS: Record<string, string> = {
nerdqaxe: "wired together by a guy on Telegram. somehow still hashing.",
bitaxe: "single chip, single mum, one in 10²² odds. heart of gold.",
"avalon-nano-3": "desk ornament with a fan. occasionally impersonates infrastructure.",
"avalon-mini-3": "the only one of you that pays for itself. don't get a big head.",
unknown: "we don't know what this is. we love it anyway.",
};
export const STATUS_LINES = {
hashing: "hashing",
stale: "thinking real hard",
idle: "having a lie down",
};
export const LOTTERY_LINES = [
(oddsPerDay: number) =>
`today: ${formatPct(oddsPerDay)}. A rounding error wearing a cape.`,
(oddsPerDay: number) =>
`expected wait: ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. Your descendants' descendants will still be checking the fan noise.`,
() =>
`the math says no in every language, including ones we haven't invented yet.`,
(oddsPerDay: number) =>
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. Even the sky has a better business plan.`,
() =>
`four little heaters bullying SHA-256 and calling it a retirement strategy.`,
() =>
`every share is proof of work. Not useful work. Let's not get carried away.`,
() =>
`solo mining: where hope goes to become server logs.`,
() =>
`this is not financial advice. It is barely electrical advice.`,
() =>
`the fiat system is shaking, but not because of this. This is mostly fan noise.`,
() =>
`somewhere a central banker felt nothing at all.`,
() =>
`if this finds a block, check the logs, the stars, and whoever touched the thermostat.`,
() =>
`the orange pill was supposed to be freedom, not watching a desk fan beg for destiny.`,
() =>
`your odds are non-zero, which is how all terrible ideas get funding.`,
() =>
`the network difficulty looked at this rig and filed a noise complaint.`,
() =>
`proof of work? More like proof of warm.`,
() =>
`fiat dies slowly. This block attempt dies every ten minutes.`,
() =>
`the hash is strong. The probability is in witness protection.`,
() =>
`the IMF has not yet issued a statement about your 0.000000whatever percent chance.`,
() =>
`solo mining is just buying a lottery ticket that also heats the hallway.`,
() =>
`the block reward remains technically available, like buried treasure on Mars.`,
() =>
`every rejected share is a tiny central bank meeting with worse catering.`,
() =>
`some call it sovereign mining. Others call it an elaborate way to run a heater.`,
() =>
`the machines are awake. The odds are legally dead.`,
() =>
`if math had a face, it would be avoiding eye contact.`,
() =>
`hash harder, little toaster. The empire will not dismantle itself.`,
() =>
`Bitcoin is permissionless, including permission to embarrass yourself electrically.`,
() =>
`your miners are attacking fiat with the force of a damp receipt.`,
() =>
`the conspiracy is real: the big pools have more ASICs than your shelf.`,
() =>
`this is how monetary revolution sounds when it has a 40mm fan.`,
() =>
`the Cantillon effect remains undefeated by the cupboard.`,
() =>
`the nonce search continues. So does the comedy.`,
() =>
`somewhere, Satoshi is either proud or changing the Wi-Fi password.`,
() =>
`the chance is small enough to qualify as abstract art.`,
() =>
`mining a block today would be less prediction, more paranormal incident.`,
() =>
`these rigs are not decentralizing power. They are decentralizing warmth.`,
() =>
`the mempool sees you. It is trying not to laugh.`,
() =>
`fiat printers go brrr. Your fan goes whrrr. Guess who has better margins.`,
() =>
`today's block odds have been forwarded to the Ministry of Delusion.`,
() =>
`the network hashrate is a dragon. You brought teaspoons.`,
() =>
`this is sovereign self-custody of disappointment.`,
() =>
`if a block appears, assume divine intervention or a rounding bug.`,
() =>
`monetary debasement is tragic. This rig's odds are slapstick.`,
() =>
`your ASICs are yelling at SHA-256 through a letterbox.`,
() =>
`the pools have warehouses. You have vibes and a power strip.`,
() =>
`a beautiful act of rebellion, if rebellion paid in almost nothing.`,
() =>
`the block subsidy is safe from you for now.`,
() =>
`hashing like this is how you whisper at a volcano.`,
() =>
`the odds are dark, but at least the room isn't cold.`,
() =>
`fiat friends buy heaters. You bought statistically complicated heaters.`,
() =>
`the nonce lottery has reviewed your application and placed it under a very large pile.`,
() =>
`this is not a get-rich-quick scheme. It is barely a get-warm-slowly scheme.`,
() =>
`the global banking cartel remains untroubled by this particular fan curve.`,
() =>
`somebody said "be your own bank" and now the shelf is making whale noises.`,
() =>
`the blockchain is immutable. So is the humiliation.`,
() =>
`your chance is smaller than a politician's apology and twice as expensive to heat.`,
() =>
`the rig believes. The spreadsheet has concerns.`,
() =>
`mining solo is what happens when optimism discovers electricity.`,
() =>
`the odds are so low they qualify as a privacy feature.`,
() =>
`keep stacking sats. These machines are mostly stacking anecdotes.`,
() =>
`the revolution will be decentralized, but apparently not profitable today.`,
];
export const BLOCK_CELEBRATION_LINES = [
"WAIT. WHAT. CHECK THE LOG. YOU FOUND A — actually let me check that twice.",
"no but seriously, one of these little boards just won the lottery.",
"the network has been notified. so have your friends. so has god.",
];
export const STALE_LINES = [
"hasn't submitted in a while. either thinking really hard or the breaker tripped.",
"currently meditating. has been for some time.",
"not dead, just resting. probably.",
];
function formatPct(p: number): string {
if (!isFinite(p) || p <= 0) return "ε";
if (p >= 0.01) return `${(p * 100).toFixed(4)}%`;
return oneIn(p);
}
function humanYears(years: number): string {
if (!isFinite(years)) return "forever";
if (years < 1) return `${(years * 365).toFixed(1)} days`;
if (years < 100) return `${years.toFixed(1)} years`;
if (years < 1e6) return `${(years / 1e3).toFixed(1)} thousand years`;
if (years < 1e9) return `${(years / 1e6).toFixed(1)} million years`;
return `${(years / 1e9).toFixed(1)} billion years`;
}
function ratioVsLightning(oddsPerDay: number): string {
const lightning = 0.00000028;
const r = oddsPerDay / lightning;
if (r < 0.001) return `about ${oneIn(1 / r)} as likely`;
if (r < 1) return r.toFixed(3);
if (r < 1000) return r.toFixed(1);
return `${(r / 1000).toFixed(1)}K`;
}
+179
View File
@@ -0,0 +1,179 @@
/* gashboard cyberpunk theme */
:root {
--bg-0: #07090f;
--bg-1: #0c1120;
--bg-2: #131a2e;
--line: #1f2a4a;
--line-bright: #2c3a66;
--fg-0: #e6edff;
--fg-1: #b6c3e0;
--fg-2: #6a7aa0;
--neon-cyan: #29ffe6;
--neon-magenta: #ff3df0;
--neon-amber: #ffd84a;
--neon-red: #ff4f78;
--neon-green: #6cff8c;
--shadow-cyan: 0 0 16px rgba(41, 255, 230, 0.35);
--shadow-magenta: 0 0 16px rgba(255, 61, 240, 0.4);
--mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", Menlo, Consolas, monospace;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
background:
radial-gradient(900px 460px at 50% -20%, rgba(255, 79, 120, calc(var(--heat, 0) * 0.22)), transparent),
radial-gradient(1200px 600px at 20% -10%, rgba(255, 61, 240, 0.12), transparent),
radial-gradient(1000px 500px at 100% 110%, rgba(41, 255, 230, 0.10), transparent),
var(--bg-0);
color: var(--fg-0);
font-family: var(--mono);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
letter-spacing: 0.01em;
}
body.hot-hash {
--line-bright: #7a3046;
--neon-magenta: #ff4f78;
--shadow-magenta: 0 0 18px rgba(255, 79, 120, 0.48);
}
button {
font-family: var(--mono);
font-size: 13px;
background: transparent;
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 14px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: border-color 0.12s, color 0.12s, box-shadow 0.12s, background 0.12s;
}
button:hover:not(:disabled) {
border-color: var(--neon-cyan);
color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
button.primary {
border-color: var(--neon-magenta);
color: var(--neon-magenta);
}
button.primary:hover:not(:disabled) {
background: rgba(255, 61, 240, 0.08);
box-shadow: var(--shadow-magenta);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
input,
textarea {
font-family: var(--mono);
background: var(--bg-1);
color: var(--fg-0);
border: 1px solid var(--line-bright);
padding: 8px 10px;
width: 100%;
font-size: 13px;
}
input:focus,
textarea:focus {
outline: none;
border-color: var(--neon-cyan);
box-shadow: var(--shadow-cyan);
}
a {
color: var(--neon-cyan);
text-decoration: none;
}
.muted {
color: var(--fg-2);
}
.label {
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 11px;
color: var(--fg-2);
}
.panel {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent),
var(--bg-1);
border: 1px solid var(--line);
padding: 16px;
}
.glow-cyan {
color: var(--neon-cyan);
text-shadow: 0 0 8px rgba(41, 255, 230, 0.45);
}
.glow-magenta {
color: var(--neon-magenta);
text-shadow: 0 0 8px rgba(255, 61, 240, 0.5);
}
.glow-amber {
color: var(--neon-amber);
text-shadow: 0 0 8px rgba(255, 216, 74, 0.5);
}
.glow-red {
color: var(--neon-red);
text-shadow: 0 0 8px rgba(255, 79, 120, 0.5);
}
.glow-green {
color: var(--neon-green);
text-shadow: 0 0 8px rgba(108, 255, 140, 0.5);
}
/* CRT scanlines (very subtle, opt-in via body.crt class) */
body.crt::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
background:
repeating-linear-gradient(
to bottom,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0) 2px,
rgba(0, 0, 0, 0.18) 3px,
rgba(0, 0, 0, 0.18) 4px
);
mix-blend-mode: multiply;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.55; }
}
.pulse {
animation: pulse 1.6s ease-in-out infinite;
}
@keyframes flicker {
0%, 100% { opacity: 1; }
47%, 49% { opacity: 0.85; }
48% { opacity: 0.6; }
}
.flicker {
animation: flicker 5s steps(20) infinite;
}
+149
View File
@@ -0,0 +1,149 @@
export type MinerStat = {
authUsername: string;
remoteHost: string;
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;
diffAcceptedSum: number;
diffAcceptedCount: number;
diffRejectedSum: number;
diffRejectedCount: number;
rejectPct: number;
vdiff: number;
userAgent: string;
subscribed: boolean;
status: "hashing" | "stale" | "idle";
contributionAcceptedWork?: number;
contributionAcceptedShares?: number;
contributionPct?: number;
contributionFirstSeenAt?: number;
contributionLastSeenAt?: number;
currentlyConnected?: boolean;
};
export type PoolStat = {
combinedHashrateThs: number;
totalConnections: number;
totalSubscriptions: number;
activeThreads: number;
sharesAccepted: number;
sharesRejected: number;
uptimeSeconds: number;
connectionStatus: string;
poolHost: string;
poolTag: string;
minerTag: string;
poolDifficulty: number;
};
export type CurrentJob = {
blockHeight: number;
blockValueSats: number;
difficulty: number;
bits: string;
prevBlock: string;
target: string;
version: string;
weight: number;
size: number;
txnCount: number;
timeInfo: string;
};
export type NetworkStat = {
blockHeight: number;
hashrateEh: number;
difficulty: number;
source: string;
};
export type DatumSnapshot = {
ok: boolean;
fetchedAt: number;
pool: PoolStat;
job: CurrentJob;
network: NetworkStat;
miners: MinerStat[];
error?: { code: string; message: string };
};
export type HistoryPoint = {
t: number;
ths: number;
accepted: number;
rejected: number;
connections: number;
subscriptions: number;
height: number;
miners: Record<string, number>;
minerWork: Record<string, number>;
minerShares: Record<string, number>;
minerStatus: Record<string, MinerStat["status"]>;
};
export type LoginResponse = {
token: string;
npub: string;
expiresAt: number;
};
export type ChatSnapshot = {
messages: Array<{
id: string;
pubkey: string;
content: string;
createdAt: number;
replyToId: string;
replyToPubkey: string;
}>;
reactions: Array<{
id: string;
pubkey: string;
messageId: string;
content: string;
createdAt: number;
}>;
keyWraps: Array<{
recipientPubkey: string;
senderPubkey: string;
ciphertext: string;
updatedAt: number;
}>;
};
export type ApiError = { error: { code: string; message: string } };
declare global {
interface Window {
nostr?: {
getPublicKey(): Promise<string>;
signEvent(event: {
kind: number;
created_at: number;
tags: string[][];
content: string;
}): Promise<{
id: string;
pubkey: string;
kind: number;
created_at: number;
tags: string[][];
content: string;
sig: string;
}>;
nip44?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
nip04?: {
encrypt(pubkey: string, plaintext: string): Promise<string>;
decrypt(pubkey: string, ciphertext: string): Promise<string>;
};
};
}
}
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { loadToken } from "../services/api";
type Member = { npub: string; role: "owner" | "viewer" };
const members = ref<Member[]>([]);
const owner = ref(false);
const loaded = ref(false);
const busy = ref(false);
const npub = ref("");
const error = ref("");
const notice = ref("");
async function request(method = "GET", key = ""): Promise<void> {
busy.value = true;
error.value = "";
notice.value = "";
try {
const token = loadToken()?.token;
const res = await fetch(`/api/access${method === "DELETE" ? `/${encodeURIComponent(key)}` : ""}`, {
method,
headers: { authorization: `Bearer ${token ?? ""}`, "content-type": "application/json" },
...(method === "POST" ? { body: JSON.stringify({ npub: key }) } : {}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error?.message ?? "Could not update access. Please sign in again.");
owner.value = data.isOwner;
members.value = data.members;
loaded.value = true;
if (method === "POST") { npub.value = ""; notice.value = "Viewer added. They can now sign in with that Nostr identity."; }
if (method === "DELETE") notice.value = "Viewer removed. Existing sessions no longer have access.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Could not load access list.";
} finally { busy.value = false; }
}
onMounted(() => { void request(); });
</script>
<template>
<section class="panel access">
<h1 class="glow-cyan">Your mining crew</h1>
<p class="muted">Choose who can see your fleet, shares, and dashboard chat.</p>
<p v-if="error" role="alert">{{ error }}</p>
<p v-if="notice" role="status">{{ notice }}</p>
<p v-if="!loaded && !error">Loading access list…</p>
<p v-if="loaded && !owner">Ask a node owner to add or remove people. Your account has viewer access.</p>
<template v-if="loaded && owner">
<form @submit.prevent="request('POST', npub.trim())">
<label for="viewer-npub">Invite a viewer by Nostr public key</label>
<div class="invite">
<input id="viewer-npub" v-model="npub" placeholder="npub1…" autocomplete="off" spellcheck="false" required :disabled="busy" />
<button class="primary" type="submit" :disabled="busy || !npub.trim()">Add viewer</button>
</div>
<small class="muted">Viewers can follow mining and join chat. Only node owners can change this list.</small>
</form>
<ul>
<li v-for="member in members" :key="member.npub">
<div><span class="role">{{ member.role }}</span><code>{{ member.npub }}</code></div>
<button v-if="member.role === 'viewer'" :disabled="busy" :aria-label="`Remove ${member.npub}`" @click="request('DELETE', member.npub)">Remove</button>
</li>
</ul>
<p class="muted">Owners come from Archipelago identities. Restart Gashboard after changing node identities. Invited viewers stay on this list across restarts.</p>
</template>
</section>
</template>
<style scoped>
.access { max-width: 880px; margin: 0 auto; }
h1 { margin-top: 0; }
form { margin: 28px 0; }
label { display: block; margin-bottom: 12px; }
.invite { display: flex; gap: 12px; margin-bottom: 12px; }
input { flex: 1; min-width: 0; }
ul { padding: 0; list-style: none; }
li { display: flex; align-items: center; gap: 16px; padding: 16px 0; border-bottom: 1px solid var(--line); }
li > div { flex: 1; min-width: 0; }
code { display: block; overflow-wrap: anywhere; font-size: 12px; margin-top: 8px; }
.role { color: var(--neon-cyan); text-transform: uppercase; }
[role="alert"] { color: var(--neon-red); }
[role="status"] { color: var(--neon-cyan); }
@media (max-width: 520px) { .invite { flex-direction: column; } }
</style>
@@ -0,0 +1,237 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from "vue";
import { useStatsStore } from "../stores/stats";
import MinerCard from "../components/MinerCard.vue";
import PoolHero from "../components/PoolHero.vue";
import LotteryWidget from "../components/LotteryWidget.vue";
import ShareTicker from "../components/ShareTicker.vue";
import BlockCelebration from "../components/BlockCelebration.vue";
import Shameboard from "../components/Shameboard.vue";
const stats = useStatsStore();
onMounted(() => stats.start());
onUnmounted(() => stats.stop());
const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 0);
const networkEh = computed(() => stats.snapshot?.network.hashrateEh ?? 0);
const miners = computed(() => stats.snapshot?.miners ?? []);
const errorMsg = computed(() => stats.error);
const upstreamErr = computed(() => stats.snapshot?.error);
const bestWorkByMiner = computed(() => {
const out: Record<string, number> = {};
for (let i = 1; i < stats.history.length; i += 1) {
const prev = stats.history[i - 1];
const next = stats.history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
for (const [key, work] of Object.entries(next.minerWork)) {
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = work - (prev.minerWork[key] ?? work);
if (shareDelta <= 0 || workDelta <= 0) continue;
out[key] = Math.max(out[key] ?? 0, workDelta / shareDelta);
}
}
return out;
});
const poolBestWork = computed(() => Math.max(0, ...Object.values(bestWorkByMiner.value)));
const contributionRows = computed(() => {
return miners.value
.filter((m) => (m.contributionAcceptedWork ?? m.diffAcceptedSum) > 0)
.map((m) => ({
key: minerKey(m),
name: m.nickname,
work: m.contributionAcceptedWork ?? m.diffAcceptedSum,
pct: m.contributionPct ?? 0,
connected: m.currentlyConnected ?? true,
}))
.sort((a, b) => b.work - a.work);
});
const boomerHeater = {
authUsername: "fiat.heat",
remoteHost: "wall-socket",
nickname: "Boomer Heater",
model: "Legacy Resistance Heater",
location: "The Past",
expectedHashrateThs: 0,
watts: 2000,
hashrateThs: 0,
hashrateAgeS: null,
lastShareAgeS: null,
diffAcceptedSum: 0,
diffAcceptedCount: 0,
diffRejectedSum: 0,
diffRejectedCount: 0,
rejectPct: 0,
vdiff: 0,
userAgent: "CentralHeating/fiat-only",
subscribed: false,
status: "idle",
} as const;
function minerKey(m: { nickname: string; authUsername: string; remoteHost: string }): string {
return m.authUsername || m.remoteHost || m.nickname;
}
</script>
<template>
<BlockCelebration :snapshot="stats.snapshot" />
<div v-if="errorMsg" class="banner err">
✗ {{ errorMsg }}
</div>
<div v-else-if="upstreamErr" class="banner warn">
⚠ datum: {{ upstreamErr.code }} — {{ upstreamErr.message }}
</div>
<PoolHero :snapshot="stats.snapshot" />
<section class="best panel">
<div>
<div class="label">best accepted calculation observed</div>
<strong class="glow-amber">
{{ poolBestWork > 0 ? poolBestWork.toLocaleString(undefined, { maximumFractionDigits: 2 }) : "collecting" }}
</strong>
</div>
<span class="muted">derived from accepted difficulty jumps while this browser has history</span>
</section>
<section class="split panel">
<div class="split-head">
<div>
<div class="label">block reward split</div>
<strong class="glow-green">accepted-work ledger</strong>
</div>
<span class="muted">persisted after this deploy</span>
</div>
<div v-if="contributionRows.length" class="split-rows">
<div v-for="row in contributionRows" :key="row.key" class="split-row">
<span>
{{ row.name }}
<small v-if="!row.connected" class="muted">offline</small>
</span>
<strong>{{ row.pct.toFixed(2) }}%</strong>
<em class="muted">{{ row.work.toLocaleString(undefined, { maximumFractionDigits: 2 }) }}</em>
</div>
</div>
<div v-else class="muted">waiting for accepted work</div>
</section>
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
<ShareTicker :snapshot="stats.snapshot" />
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
<div class="miners">
<MinerCard
v-for="m in miners"
:key="m.authUsername || m.nickname"
:miner="m"
:best-work="bestWorkByMiner[minerKey(m)]"
/>
<MinerCard
key="boomer-heater"
:miner="boomerHeater"
:best-work="0"
/>
<div v-if="!miners.length && !errorMsg" class="empty panel muted">
waiting for the first poll · the boards are warming up
</div>
</div>
</template>
<style scoped>
.banner {
padding: 10px 14px;
margin-bottom: 16px;
font-size: 12px;
border: 1px solid var(--line);
}
.banner.err { border-color: var(--neon-red); color: var(--neon-red); }
.banner.warn { border-color: var(--neon-amber); color: var(--neon-amber); }
.grid-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
margin: 16px 0;
}
.best {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 16px;
min-width: 0;
}
.best strong {
display: block;
margin-top: 4px;
font-size: 24px;
}
.best span {
font-size: 11px;
text-align: right;
overflow-wrap: anywhere;
}
.split {
margin-top: 16px;
}
.split-head,
.split-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 14px;
align-items: center;
}
.split-head {
margin-bottom: 10px;
}
.split-rows {
display: grid;
gap: 8px;
}
.split-row {
padding-top: 8px;
border-top: 1px solid var(--line);
font-size: 13px;
}
.split-row small {
margin-left: 8px;
font-size: 10px;
text-transform: uppercase;
}
.split-row em {
font-style: normal;
font-size: 11px;
}
.miners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.empty {
padding: 40px;
text-align: center;
font-style: italic;
}
@media (max-width: 800px) {
.grid-row { grid-template-columns: 1fr; }
.best {
align-items: flex-start;
flex-direction: column;
}
.best span {
text-align: left;
}
.split-head,
.split-row {
grid-template-columns: minmax(0, 1fr) auto;
}
.split-head > span,
.split-row em {
grid-column: 1 / -1;
}
}
</style>
@@ -0,0 +1,447 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useStatsStore } from "../stores/stats";
import type { HistoryPoint } from "../types";
import Shameboard from "../components/Shameboard.vue";
import MinerRace from "../components/MinerRace.vue";
const stats = useStatsStore();
onMounted(() => stats.start());
onUnmounted(() => stats.stop());
const ranges = [
{ label: "15m", ms: 15 * 60 * 1000 },
{ label: "1h", ms: 60 * 60 * 1000 },
{ label: "6h", ms: 6 * 60 * 60 * 1000 },
{ label: "24h", ms: 24 * 60 * 60 * 1000 },
] as const;
const selectedRange = ref<(typeof ranges)[number]["label"]>("1h");
const activeRange = computed(() => ranges.find((r) => r.label === selectedRange.value) ?? ranges[1]);
const now = computed(() => stats.snapshot?.fetchedAt ?? Date.now());
const points = computed(() => {
const minT = now.value - activeRange.value.ms;
const filtered = stats.history.filter((p) => p.t >= minT);
return filtered.length > 0 ? filtered : stats.history;
});
const current = computed(() => stats.snapshot);
const currentThs = computed(() => current.value?.pool.combinedHashrateThs ?? 0);
const activeMiners = computed(() => current.value?.miners.filter((m) => m.status === "hashing").length ?? 0);
const windowLabel = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last) return "collecting";
const minutes = Math.max(0, Math.round((last.t - first.t) / 60000));
if (minutes < 60) return `${minutes}m captured`;
return `${(minutes / 60).toFixed(1)}h captured`;
});
const deltas = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last) return { accepted: 0, rejected: 0, total: 0, rejectPct: 0 };
const accepted = Math.max(0, last.accepted - first.accepted);
const rejected = Math.max(0, last.rejected - first.rejected);
const total = accepted + rejected;
return { accepted, rejected, total, rejectPct: total > 0 ? (rejected / total) * 100 : 0 };
});
const avgThs = computed(() => {
if (!points.value.length) return 0;
return points.value.reduce((sum, p) => sum + p.ths, 0) / points.value.length;
});
const highLow = computed(() => {
if (!points.value.length) return { high: 0, low: 0 };
return {
high: Math.max(...points.value.map((p) => p.ths)),
low: Math.min(...points.value.map((p) => p.ths)),
};
});
const shareRate = computed(() => {
const first = points.value[0];
const last = points.value.at(-1);
if (!first || !last || last.t <= first.t) return 0;
return deltas.value.accepted / ((last.t - first.t) / 3600000);
});
const hashratePath = computed(() => linePath(points.value.map((p) => ({ x: p.t, y: p.ths })), 460, 190));
const shareBars = computed(() => buildShareBuckets(points.value, activeRange.value.ms));
const minerRows = computed(() => {
const latest = current.value?.miners ?? [];
return latest
.map((m) => {
const key = m.authUsername || m.remoteHost || m.nickname;
const average = averageMinerHashrate(points.value, key);
const bestWork = bestObservedWork(stats.history, key);
const pct = currentThs.value > 0 ? (m.hashrateThs / currentThs.value) * 100 : 0;
return { key, name: m.nickname, model: m.model, current: m.hashrateThs, average, bestWork, pct, status: m.status };
})
.sort((a, b) => b.current - a.current);
});
function linePath(items: { x: number; y: number }[], width: number, height: number): string {
if (items.length < 2) return "";
const first = items[0];
if (!first) return "";
const minX = first.x;
const maxX = items.at(-1)?.x ?? minX + 1;
const minY = Math.min(...items.map((i) => i.y), 0);
const maxY = Math.max(...items.map((i) => i.y), 1);
const spanX = Math.max(1, maxX - minX);
const spanY = Math.max(0.001, maxY - minY);
return items
.map((item, idx) => {
const x = ((item.x - minX) / spanX) * width;
const y = height - ((item.y - minY) / spanY) * height;
return `${idx === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
}
function buildShareBuckets(history: HistoryPoint[], rangeMs: number): { x: number; accepted: number; rejected: number; h: number }[] {
if (history.length < 2) return [];
const bucketCount = rangeMs <= 60 * 60 * 1000 ? 30 : 48;
const first = history[0];
if (!first) return [];
const start = first.t;
const end = history.at(-1)?.t ?? start + 1;
const span = Math.max(1, end - start);
const buckets = Array.from({ length: bucketCount }, (_, i) => ({ x: i, accepted: 0, rejected: 0, h: 0 }));
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev || !next) continue;
const idx = Math.min(bucketCount - 1, Math.max(0, Math.floor(((next.t - start) / span) * bucketCount)));
const bucket = buckets[idx];
if (!bucket) continue;
bucket.accepted += Math.max(0, next.accepted - prev.accepted);
bucket.rejected += Math.max(0, next.rejected - prev.rejected);
}
const max = Math.max(...buckets.map((b) => b.accepted + b.rejected), 1);
return buckets.map((b) => ({ ...b, h: ((b.accepted + b.rejected) / max) * 72 }));
}
function averageMinerHashrate(history: HistoryPoint[], key: string): number {
const values = history.map((p) => p.miners[key]).filter((v): v is number => Number.isFinite(v));
if (!values.length) return 0;
return values.reduce((sum, value) => sum + value, 0) / values.length;
}
function bestObservedWork(history: HistoryPoint[], key: string): number {
let best = 0;
for (let i = 1; i < history.length; i += 1) {
const prev = history[i - 1];
const next = history[i];
if (!prev?.minerWork || !next?.minerWork || !prev.minerShares || !next.minerShares) continue;
const shareDelta = (next.minerShares[key] ?? 0) - (prev.minerShares[key] ?? 0);
const workDelta = (next.minerWork[key] ?? 0) - (prev.minerWork[key] ?? 0);
if (shareDelta > 0 && workDelta > 0) best = Math.max(best, workDelta / shareDelta);
}
return best;
}
function fmt(n: number, digits = 2): string {
return n.toLocaleString(undefined, { maximumFractionDigits: digits, minimumFractionDigits: digits });
}
</script>
<template>
<section class="graph-shell">
<header class="graph-head">
<div>
<div class="label">graph view</div>
<h1 class="glow-cyan">Hashrate telemetry</h1>
</div>
<div class="range-tabs">
<button
v-for="range in ranges"
:key="range.label"
:class="{ active: selectedRange === range.label }"
@click="selectedRange = range.label"
>
{{ range.label }}
</button>
</div>
</header>
<MinerRace :snapshot="stats.snapshot" :history="stats.history" />
<div class="metric-grid">
<div class="metric panel">
<div class="label">now</div>
<strong class="glow-cyan">{{ fmt(currentThs) }}</strong>
<span>Th/s</span>
</div>
<div class="metric panel">
<div class="label">window avg</div>
<strong>{{ fmt(avgThs) }}</strong>
<span>Th/s · {{ windowLabel }}</span>
</div>
<div class="metric panel">
<div class="label">accepted rate</div>
<strong class="glow-green">{{ fmt(shareRate, 1) }}</strong>
<span>shares/hour</span>
</div>
<div class="metric panel">
<div class="label">rejects</div>
<strong :class="deltas.rejectPct > 3 ? 'glow-red' : ''">{{ fmt(deltas.rejectPct, 2) }}%</strong>
<span>{{ deltas.rejected.toLocaleString() }} / {{ deltas.total.toLocaleString() }}</span>
</div>
<div class="metric panel">
<div class="label">active miners</div>
<strong>{{ activeMiners }}</strong>
<span>{{ current?.pool.totalSubscriptions ?? 0 }} subscriptions</span>
</div>
</div>
<div class="chart-grid">
<section class="panel chart-card wide">
<div class="chart-title">
<div>
<div class="label">combined hashrate</div>
<strong>{{ fmt(highLow.low) }} → {{ fmt(highLow.high) }} Th/s</strong>
</div>
<span class="muted">{{ points.length.toLocaleString() }} samples</span>
</div>
<svg class="line-chart" viewBox="0 0 460 190" preserveAspectRatio="none">
<defs>
<linearGradient id="hashFill" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="var(--neon-cyan)" stop-opacity="0.22" />
<stop offset="100%" stop-color="var(--neon-cyan)" stop-opacity="0" />
</linearGradient>
</defs>
<path v-if="hashratePath" :d="`${hashratePath} L460,190 L0,190 Z`" fill="url(#hashFill)" />
<path v-if="hashratePath" :d="hashratePath" class="hash-line" />
<text v-else x="230" y="96" text-anchor="middle">collecting samples</text>
</svg>
</section>
<section class="panel chart-card">
<div class="chart-title">
<div>
<div class="label">share flow</div>
<strong>{{ deltas.accepted.toLocaleString() }} accepted</strong>
</div>
<span class="muted">{{ deltas.rejected.toLocaleString() }} rejected</span>
</div>
<div class="bars">
<div v-for="bar in shareBars" :key="bar.x" class="bar-track">
<span class="bar accepted" :style="{ height: `${bar.h}px` }" />
<span
v-if="bar.rejected"
class="bar rejected"
:style="{ height: `${Math.max(4, (bar.rejected / Math.max(1, bar.accepted + bar.rejected)) * bar.h)}px` }"
/>
</div>
</div>
</section>
<section class="panel chart-card">
<div class="chart-title">
<div>
<div class="label">miner mix</div>
<strong>{{ minerRows.length }} devices</strong>
</div>
</div>
<div class="miner-mix">
<div v-for="miner in minerRows" :key="miner.key" class="miner-row">
<div class="miner-meta">
<span :class="['status-dot', miner.status]" />
<strong>{{ miner.name }}</strong>
<span class="muted">{{ fmt(miner.current) }} Th/s</span>
</div>
<div class="mix-track">
<span :style="{ width: `${Math.min(100, miner.pct)}%` }" />
</div>
<div class="muted avg">
avg {{ fmt(miner.average) }} Th/s · best {{ miner.bestWork > 0 ? fmt(miner.bestWork) : "collecting" }}
</div>
</div>
</div>
</section>
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
</section>
</template>
<style scoped>
.graph-shell {
display: flex;
flex-direction: column;
gap: 16px;
}
.graph-head,
.chart-title,
.miner-meta {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
h1 {
margin: 2px 0 0;
font-size: 28px;
letter-spacing: 0;
}
.range-tabs {
display: flex;
gap: 8px;
}
.range-tabs button {
min-width: 52px;
padding: 7px 10px;
}
.range-tabs button.active {
border-color: var(--neon-magenta);
color: var(--neon-magenta);
box-shadow: var(--shadow-magenta);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.metric {
min-height: 104px;
}
.metric strong {
display: block;
margin-top: 8px;
font-size: 24px;
line-height: 1.1;
}
.metric span {
display: block;
margin-top: 6px;
color: var(--fg-2);
font-size: 11px;
}
.chart-grid {
display: grid;
grid-template-columns: 3fr 2fr;
gap: 16px;
}
.chart-card {
min-height: 260px;
overflow: hidden;
}
.chart-card.wide {
grid-column: 1 / -1;
}
.line-chart {
width: 100%;
height: 300px;
margin-top: 12px;
border-bottom: 1px solid var(--line);
background:
linear-gradient(to bottom, transparent 24%, rgba(255,255,255,0.04) 25%, transparent 26%),
linear-gradient(to bottom, transparent 49%, rgba(255,255,255,0.04) 50%, transparent 51%),
linear-gradient(to bottom, transparent 74%, rgba(255,255,255,0.04) 75%, transparent 76%);
}
.hash-line {
fill: none;
stroke: var(--neon-cyan);
stroke-width: 2.5;
vector-effect: non-scaling-stroke;
filter: drop-shadow(0 0 5px rgba(41, 255, 230, 0.45));
}
.line-chart text {
fill: var(--fg-2);
font-size: 12px;
}
.bars {
height: 170px;
display: grid;
grid-template-columns: repeat(48, 1fr);
gap: 3px;
align-items: end;
margin-top: 34px;
border-bottom: 1px solid var(--line);
}
.bar-track {
height: 90px;
position: relative;
display: flex;
align-items: end;
}
.bar {
width: 100%;
min-height: 2px;
display: block;
}
.bar.accepted {
background: var(--neon-green);
box-shadow: 0 0 8px rgba(108, 255, 140, 0.25);
}
.bar.rejected {
position: absolute;
bottom: 0;
background: var(--neon-red);
}
.miner-mix {
display: flex;
flex-direction: column;
gap: 14px;
margin-top: 18px;
}
.miner-row {
display: grid;
gap: 6px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--fg-2);
box-shadow: 0 0 8px currentColor;
}
.status-dot.hashing { background: var(--neon-green); }
.status-dot.stale { background: var(--neon-amber); }
.status-dot.idle { background: var(--neon-red); }
.mix-track {
height: 8px;
background: var(--bg-2);
border: 1px solid var(--line);
}
.mix-track span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--neon-magenta), var(--neon-cyan));
}
.avg {
font-size: 10px;
}
@media (max-width: 1000px) {
.metric-grid {
grid-template-columns: repeat(2, 1fr);
}
.chart-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 620px) {
.graph-head {
align-items: flex-start;
flex-direction: column;
}
.range-tabs {
width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
}
.range-tabs button {
min-width: 0;
}
.metric-grid {
grid-template-columns: 1fr;
}
.line-chart {
height: 220px;
}
}
</style>
@@ -0,0 +1,172 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
const route = useRoute();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
const waitingRemote = ref(false);
async function loginExt(): Promise<void> {
try {
await auth.loginExtension();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
async function loginBunker(): Promise<void> {
if (!bunkerUri.value.trim().startsWith("bunker://")) {
auth.error = 'URI must start with "bunker://"';
return;
}
try {
await auth.loginBunker(bunkerUri.value.trim());
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
}
}
async function loginRemoteApp(): Promise<void> {
waitingRemote.value = true;
try {
await auth.loginRemoteApp();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} finally {
waitingRemote.value = false;
}
}
async function resumeRemoteApp(): Promise<void> {
if (!auth.hasPendingRemoteAppLogin()) return;
waitingRemote.value = true;
try {
await auth.resumeRemoteAppLogin();
void router.push({ name: "dashboard" });
} catch {
/* surfaced via auth.error */
} finally {
waitingRemote.value = false;
}
}
onMounted(() => {
if (route.query.remote === "return") void resumeRemoteApp();
});
</script>
<template>
<div class="login">
<div class="panel">
<div class="header">
<div class="title glow-cyan">// auth_required</div>
<div class="subtitle muted">
gashboard is a closed dashboard. only allow-listed npubs may pass.
</div>
</div>
<div class="options">
<button class="primary big" :disabled="auth.busy" @click="loginExt">
{{ auth.busy ? "..." : "sign in with Nostr" }}
<small>Archipelago identity or browser extension</small>
</button>
<button
class="primary big"
:disabled="auth.busy"
@click="loginRemoteApp"
>
{{ waitingRemote ? "waiting for signer..." : "open signer app" }}
<small>Primal, Amber, or any Nostr Connect signer</small>
</button>
<button
class="big"
:disabled="auth.busy"
@click="showBunkerInput = !showBunkerInput"
>
bunker URI
<small>advanced fallback · nsecbunker</small>
</button>
<div v-if="showBunkerInput" class="bunker">
<label class="label">paste bunker URI</label>
<input
v-model="bunkerUri"
placeholder="bunker://<pubkey>?relay=wss://…&secret=…"
spellcheck="false"
autocomplete="off"
/>
<button class="primary" :disabled="auth.busy" @click="loginBunker">
{{ auth.busy ? "connecting…" : "connect" }}
</button>
</div>
</div>
<div v-if="auth.error" class="err">
✗ {{ auth.error }}
</div>
<div class="foot muted">
choose your signer, select your identity, and approve the sign-in request.
</div>
</div>
</div>
</template>
<style scoped>
.login {
width: min(540px, 92vw);
}
.header { margin-bottom: 24px; }
.title {
font-size: 22px;
letter-spacing: 0.18em;
margin-bottom: 6px;
font-weight: 700;
}
.subtitle { font-size: 12px; }
.options { display: flex; flex-direction: column; gap: 14px; }
button.big {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 14px 16px;
font-size: 14px;
}
button.big small {
font-size: 10.5px;
font-weight: 400;
text-transform: none;
color: var(--fg-2);
letter-spacing: 0.04em;
}
.bunker {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
border: 1px dashed var(--line-bright);
background: rgba(0, 0, 0, 0.25);
}
.err {
margin-top: 18px;
padding: 10px 12px;
border: 1px solid var(--neon-red);
color: var(--neon-red);
font-size: 12px;
}
.foot {
margin-top: 24px;
font-size: 11px;
letter-spacing: 0.04em;
}
</style>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useRouter } from "vue-router";
import { useAuthStore } from "../stores/auth";
const auth = useAuthStore();
const router = useRouter();
onMounted(async () => {
if (auth.hasPendingRemoteAppLogin()) {
try {
await auth.resumeRemoteAppLogin();
} catch {
/* surfaced on the login screen if resume fails */
}
void router.replace({ name: auth.isLoggedIn ? "dashboard" : "login", query: auth.isLoggedIn ? {} : { remote: "return" } });
return;
}
if (auth.isLoggedIn) {
void router.replace({ name: "dashboard" });
return;
}
void router.replace({ name: "login", query: { remote: "return" } });
});
</script>
<template>
<div class="callback panel">
<div class="title glow-cyan">// signer_return</div>
<p class="muted">Finishing remote signer login...</p>
</div>
</template>
<style scoped>
.callback {
width: min(460px, 92vw);
text-align: center;
}
.title {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.16em;
margin-bottom: 8px;
}
p {
margin: 0;
font-size: 12px;
}
</style>
+14
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
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:1337",
changeOrigin: true,
},
"/healthz": "http://localhost:1337",
},
},
build: {
outDir: "dist",
sourcemap: true,
target: "es2022",
},
});
+45
View File
@@ -0,0 +1,45 @@
# gashboard — deploy as a Portainer Stack on the same Umbrel host that runs Datum.
#
# Uses host networking so the API can reach Datum through the port Umbrel
# already publishes on the host, avoiding container DNS and changing IPs.
services:
gashboard:
# Portainer "Stacks → Repository" will build this from the gashboard git repo.
# Do not set `image: gashboard:...` here: Portainer will try to pull that
# local-only name from Docker Hub before building and deployment will fail.
# If you want to pull a pre-built image instead, replace `build:` with an
# `image:` value that includes your registry, for example:
# image: ghcr.io/your-user/gashboard:0.1.0
build:
context: .
dockerfile: Dockerfile
container_name: gashboard
restart: unless-stopped
environment:
NODE_ENV: production
PORT: "1337"
LOG_LEVEL: "${LOG_LEVEL:-info}"
CORS_ORIGIN: "${CORS_ORIGIN:-}"
# Datum's admin UI/API is published by Umbrel on the host at port 21000.
DATUM_URL: "${DATUM_URL:-http://127.0.0.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}"
CONTRIBUTION_LEDGER_PATH: "${CONTRIBUTION_LEDGER_PATH:-/data/contribution-ledger.json}"
MEMPOOL_API_URL: "${MEMPOOL_API_URL:-https://tx1138.com/api}"
NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}"
JWT_SECRET: "${JWT_SECRET?must be set}"
JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"
network_mode: host
volumes:
- gashboard_data:/data
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
volumes:
gashboard_data:
+400
View File
@@ -0,0 +1,400 @@
/**
* NIP-07 Nostr Provider Shim — Archipelago
*
* In an Archipelago iframe, requests go directly to the parent dashboard.
* In a browser tab or companion WebView, a dashboard-origin signer frame
* supplies the same identity picker and consent UI. No opener is required,
* and private keys never leave the node backend.
*/
(function () {
'use strict';
if (window.__archipelagoNostr) return;
window.__archipelagoNostr = true;
var providerScript = document.currentScript;
var autoNip98 = !(providerScript && providerScript.hasAttribute('data-no-nip98'));
var embedded = window !== window.top;
var pending = {}, nextId = 1, queuedMessages = [];
var identitySelection = null;
var selectedIdentity = null, identitySubscribers = [];
var selectedPublicKey = null, selectedPublicKeyTimer = null;
var signerFrame = null, signerReady = embedded, signerInitialised = embedded;
var signerVisible = false, signerHideWaiters = [];
var appReady = embedded || document.readyState === 'complete';
function dashboardOrigin() {
var url = new URL(window.location.href);
url.port = '';
return url.origin;
}
function inferAppId() {
var configured = providerScript && providerScript.getAttribute('data-app-id');
if (configured) return configured;
var route = window.location.pathname.match(/^\/app\/([a-z0-9._-]+)(?:\/|$)/i);
if (route) return route[1].toLowerCase();
var ports = { '7778': 'indeedhub', '8337': 'archipelago-source' };
return ports[window.location.port] || ('app-' + (window.location.port || 'dashboard'));
}
function sendToSignerFrame(message) {
if (!signerFrame || !signerFrame.contentWindow) return;
signerFrame.contentWindow.postMessage(message, dashboardOrigin());
}
function postToSigner(message) {
if (embedded) {
window.parent.postMessage(message, '*');
return;
}
if (!signerFrame) createSignerFrame();
// A loaded iframe is not yet an initialised signer. Requests that arrive
// while the host app is still booting must follow signer-init, otherwise
// the signer correctly rejects them because it has no app id/origin yet.
if (!signerReady || !signerInitialised || !signerFrame || !signerFrame.contentWindow) {
queuedMessages.push(message);
return;
}
sendToSignerFrame(message);
}
function setSignerVisible(visible) {
if (!signerFrame) return;
signerVisible = visible;
signerFrame.style.display = 'block';
signerFrame.style.visibility = 'visible';
signerFrame.style.pointerEvents = visible ? 'auto' : 'none';
signerFrame.style.opacity = visible ? '1' : '0';
signerFrame.style.top = '0';
signerFrame.style.left = '0';
signerFrame.style.width = visible ? '100vw' : '1px';
signerFrame.style.height = visible ? '100vh' : '1px';
signerFrame.style.transform = visible ? 'none' : 'translate(-10000px, -10000px)';
signerFrame.setAttribute('aria-hidden', visible ? 'false' : 'true');
if (!visible && signerHideWaiters.length) {
var waiters = signerHideWaiters.splice(0);
waiters.forEach(function (resolve) { resolve(); });
}
}
// NIP-98 returns before the signer's short success animation has closed.
// Reloading an Android WebView while that topmost cross-origin frame is
// still visible can leave a blank compositor surface until the user reloads
// again. Let the broker finish and hide first, with a bounded fallback so a
// lost UI message can never prevent authentication from completing.
function waitForSignerToHide() {
if (embedded || !signerVisible) return Promise.resolve();
return new Promise(function (resolve) {
var settled = false;
function finish() {
if (settled) return;
settled = true;
resolve();
}
signerHideWaiters.push(finish);
setTimeout(finish, 1500);
});
}
function createSignerFrame() {
if (embedded || signerFrame) return;
signerFrame = document.createElement('iframe');
signerFrame.id = 'archipelago-nostr-signer';
signerFrame.title = 'Archipelago Nostr signer';
signerFrame.src = dashboardOrigin() + '/nostr-signer';
// Keep the broker document alive between requests, but park its compositor
// surface physically off-screen. Removing or display-hiding a full-screen
// cross-origin iframe can leave Android WebView (and some mobile Chromium
// builds) showing that stale black/grey surface until a manual refresh.
// A 1px off-screen frame cannot obscure the app and also avoids reloading
// the signer between getPublicKey/signEvent calls.
signerFrame.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;transform:translate(-10000px,-10000px);border:0;z-index:2147483647;background:transparent;display:block;visibility:visible;opacity:0;pointer-events:none;';
signerFrame.setAttribute('aria-hidden', 'true');
document.documentElement.appendChild(signerFrame);
}
function initialiseSignerWhenReady() {
if (embedded || signerInitialised || !signerReady || !appReady) return;
sendToSignerFrame({
type: 'archipelago:signer-init',
appId: inferAppId(),
appName: (document.title || 'App').replace(/\s*[|—-]\s*Archipelago\s*$/i, ''),
});
signerInitialised = true;
while (queuedMessages.length) sendToSignerFrame(queuedMessages.shift());
}
function request(method, params) {
return new Promise(function (resolve, reject) {
var id = nextId++;
pending[id] = { resolve: resolve, reject: reject };
postToSigner({ type: 'nostr-request', id: id, method: method, params: params || {} });
setTimeout(function () {
if (pending[id]) {
pending[id].reject(new Error('NIP-07 timeout'));
delete pending[id];
}
}, 30000);
});
}
// Archipelago-aware apps can call this immediately before an explicit login
// action. Standard NIP-07 intentionally has no "choose account" method, so
// getPublicKey() alone cannot distinguish a fresh login from a routine signer
// call. Keeping this as an optional companion API preserves NIP-07 compatibility
// while allowing users to change their node identity when they log in again.
function selectIdentity() {
if (identitySelection) {
identitySelection.reject(new Error('A node identity choice is already open'));
clearTimeout(identitySelection.timer);
}
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
if (!identitySelection) return;
identitySelection = null;
reject(new Error('Identity selection timed out'));
}, 30000);
identitySelection = { resolve: resolve, reject: reject, timer: timer };
postToSigner({
type: embedded
? 'archipelago:identity:request'
: 'archipelago:signer-select-identity',
force: true,
});
});
}
function finishIdentitySelection(identity) {
// The identity picker is itself an explicit choice to disclose this key.
// Keep it briefly so the login library's immediately-following
// getPublicKey() does not depend on another cross-origin WebView round trip.
// This is deliberately one-shot and short-lived.
if (identity && typeof identity.nostr_pubkey === 'string' && identity.nostr_pubkey) {
selectedIdentity = { nostr_pubkey: identity.nostr_pubkey };
selectedPublicKey = identity.nostr_pubkey;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = setTimeout(function () {
selectedPublicKey = null;
selectedPublicKeyTimer = null;
}, 15000);
identitySubscribers.slice().forEach(function (subscriber) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
});
}
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.resolve(identity);
}
function cancelIdentitySelection() {
if (!identitySelection) return;
var selection = identitySelection;
identitySelection = null;
clearTimeout(selection.timer);
selection.reject(new Error('Identity selection cancelled'));
}
function getPublicKey() {
// Most NIP-07 apps call getPublicKey directly from their login button. A
// live user activation lets the node offer account switching to those apps
// without making background account restoration reopen the picker. Apps
// with an async login flow should call archipelagoNostr.selectIdentity()
// explicitly; its result is consumed here so the picker is not shown twice.
if (selectedPublicKey) {
var publicKey = selectedPublicKey;
selectedPublicKey = null;
clearTimeout(selectedPublicKeyTimer);
selectedPublicKeyTimer = null;
return Promise.resolve(publicKey);
}
if (navigator.userActivation && navigator.userActivation.isActive) {
return selectIdentity().then(function () {
return getPublicKey();
});
}
return request('getPublicKey');
}
// Framework components often mount just after the provider receives the
// eager first-launch identity. A sticky subscription prevents that choice
// from being lost between window.load and React/Vue effect registration.
function onIdentitySelected(subscriber) {
if (typeof subscriber !== 'function') {
throw new TypeError('Identity subscriber must be a function');
}
identitySubscribers.push(subscriber);
if (selectedIdentity) {
try { subscriber(selectedIdentity); } catch (error) {
console.error('[nostr-provider] identity listener failed:', error);
}
}
return function () {
identitySubscribers = identitySubscribers.filter(function (entry) {
return entry !== subscriber;
});
};
}
function getSelectedIdentity() {
return selectedIdentity && { nostr_pubkey: selectedIdentity.nostr_pubkey };
}
window.addEventListener('message', function (e) {
var validSource = embedded
? e.source === window.parent
: signerFrame && e.source === signerFrame.contentWindow && e.origin === dashboardOrigin();
if (!validSource || !e.data) return;
if (!embedded && e.data.type === 'archipelago:signer-ready') {
signerReady = true;
initialiseSignerWhenReady();
return;
}
if (!embedded && e.data.type === 'archipelago:signer-show') {
setSignerVisible(true);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-hide') {
setSignerVisible(false);
return;
}
if (!embedded && e.data.type === 'archipelago:signer-identity') {
finishIdentitySelection(e.data.identity);
window.postMessage({
type: 'archipelago:identity',
nostr_pubkey: e.data.identity && e.data.identity.nostr_pubkey,
}, window.location.origin);
return;
}
if (embedded && e.data.type === 'archipelago:identity') {
finishIdentitySelection(e.data);
return;
}
if (e.data.type === 'archipelago:identity-cancelled' ||
e.data.type === 'archipelago:signer-identity-cancelled') {
cancelIdentitySelection();
return;
}
if (e.data.type !== 'nostr-response') return;
var handler = pending[e.data.id];
if (!handler) return;
delete pending[e.data.id];
e.data.error ? handler.reject(new Error(e.data.error)) : handler.resolve(e.data.result);
});
window.nostr = {
getPublicKey: getPublicKey,
signEvent: function (event) { return request('signEvent', { event: event }); },
sign: function (event) { return request('signEvent', { event: event }); },
getRelays: function () { return request('getRelays'); },
nip04: {
encrypt: function (pubkey, plaintext) { return request('nip04.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip04.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
nip44: {
encrypt: function (pubkey, plaintext) { return request('nip44.encrypt', { pubkey: pubkey, plaintext: plaintext }); },
decrypt: function (pubkey, ciphertext) { return request('nip44.decrypt', { pubkey: pubkey, ciphertext: ciphertext }); },
},
};
window.archipelagoNostr = {
selectIdentity: selectIdentity,
onIdentitySelected: onIdentitySelected,
getSelectedIdentity: getSelectedIdentity,
};
// Optional direct NIP-98 session bootstrap for apps that use it. Signing
// itself is shown by the shared broker, so this deliberately adds no second
// full-screen loader inside the app.
var authDone = false;
function doNip98Auth(pubkey) {
if (authDone) return;
authDone = true;
var healthUrl = window.location.origin + '/api/nostr-auth/health';
var sessionUrl = window.location.origin + '/api/auth/nostr/session';
var healthController = new AbortController();
var healthTimeout = setTimeout(function () { healthController.abort(); }, 3000);
fetch(healthUrl, { signal: healthController.signal }).then(function (response) {
clearTimeout(healthTimeout);
if (!response.ok) throw new Error('Health ' + response.status);
return window.nostr.signEvent({
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
pubkey: pubkey,
tags: [['u', sessionUrl], ['method', 'POST']],
});
}).then(function (signed) {
var controller = new AbortController();
setTimeout(function () { controller.abort(); }, 10000);
return fetch(sessionUrl, {
method: 'POST',
headers: { 'Authorization': 'Nostr ' + btoa(JSON.stringify(signed)) },
signal: controller.signal,
});
}).then(function (response) {
if (!response.ok) throw new Error('Auth failed: ' + response.status);
return response.json();
}).then(function (data) {
if (!data.accessToken) throw new Error('Authentication returned no access token');
sessionStorage.setItem('nostr_token', data.accessToken);
sessionStorage.setItem('nostr_pubkey', pubkey);
if (data.refreshToken) sessionStorage.setItem('refresh_token', data.refreshToken);
return waitForSignerToHide().then(function () {
// Give WebView one paint after the iframe is hidden before replacing
// the document. The stored session is already durable at this point.
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
}).then(function () {
if (window.ArchipelagoSurface &&
typeof window.ArchipelagoSurface.expectPageTransition === 'function') {
window.ArchipelagoSurface.expectPageTransition();
}
window.location.reload();
});
}).catch(function (error) {
authDone = false;
var message = error && error.message ? error.message : String(error);
if (message.toLowerCase().indexOf('abort') > -1) message = 'API timeout';
console.warn('[nostr-provider] NIP-98 skipped:', message);
});
}
window.addEventListener('message', function (e) {
if (!e.data || e.data.type !== 'archipelago:identity' || !autoNip98) return;
if (e.source !== window && e.source !== window.parent) return;
var pubkey = e.data.nostr_pubkey;
if (!pubkey) return;
try {
var token = sessionStorage.getItem('nostr_token');
if (token && token.indexOf('mock-') === -1) return;
} catch (_) {}
setTimeout(function () { doNip98Auth(pubkey); }, 1500);
});
// Only identity-aware apps open the chooser eagerly. The provider is also
// injected into several ordinary app proxies; those stay untouched unless
// they actually invoke a NIP-07 method, which lazily creates the broker.
if (!embedded && ['indeedhub', 'nostrudel', 'archipelago-source'].indexOf(inferAppId()) !== -1) {
createSignerFrame();
}
// The provider is injected in <head>, before framework startup. Waiting for
// load makes the first-launch picker meaningful: React/Vue login listeners
// and account stores exist before a fast identity choice can be emitted.
if (!embedded && !appReady) {
window.addEventListener('load', function () {
appReady = true;
initialiseSignerWhenReady();
}, { once: true });
}
})();
+16
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
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
packages:
- 'apps/*'
+16
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
}
}