feat(apps): add Gashboard with native signing and viewer access
@@ -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
|
||||
@@ -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
|
||||
@@ -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/
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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." } });
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 |
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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] ?? "");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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.",
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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:
|
||||
@@ -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 });
|
||||
}
|
||||
})();
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
packages:
|
||||
- 'apps/*'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||