Add graph telemetry and remote signer login

This commit is contained in:
Dorian
2026-05-06 18:09:58 +01:00
parent aee42e9c5f
commit c77c74612d
16 changed files with 855 additions and 41 deletions

View File

@@ -13,10 +13,10 @@ LOG_LEVEL=info
# STATIC_DIR=
# ---- Datum gateway (the Umbrel app we're polling) ----
# Reachable internally inside Umbrel's docker network. From your LAN it's also
# at http://192.168.1.191:21000 but that path goes through umbrelOS auth.
# Inside the Umbrel docker network, use the Datum service hostname directly.
DATUM_URL=http://10.21.0.11:21000
# Inside the Umbrel docker network, use Datum's service hostname directly.
# If you run the API outside that Docker network, override this with a hostname
# or IP address reachable from that process.
DATUM_URL=http://datum:21000
DATUM_ADMIN_USER=admin
DATUM_ADMIN_PASSWORD=
# How often to scrape /clients (ms). Datum updates per-worker hashrate every

View File

@@ -51,6 +51,15 @@ function formatErr(err: unknown): string {
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
? "; check DATUM_NETWORK or set DATUM_URL to a hostname/IP reachable from the gashboard 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;

View File

@@ -26,7 +26,7 @@ export function buildApp() {
"script-src": ["'self'"],
"style-src": ["'self'", "'unsafe-inline'"],
"img-src": ["'self'", "data:"],
"connect-src": ["'self'"],
"connect-src": ["'self'", "wss://relay.primal.net"],
"font-src": ["'self'", "data:"],
"frame-ancestors": ["'none'"],
"upgrade-insecure-requests": null,

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { RouterView, useRoute } from "vue-router";
import { RouterLink, RouterView, useRoute } from "vue-router";
import { useAuthStore } from "./stores/auth";
const auth = useAuthStore();
@@ -30,6 +30,10 @@ function shortNpub(n: string): string {
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>
</nav>
<div class="actions">
<button class="thin" @click="toggleCrt">CRT {{ crt ? "on" : "off" }}</button>
<template v-if="auth.isLoggedIn">
@@ -72,6 +76,27 @@ function shortNpub(n: string): string {
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);
}
.actions {
display: flex;
gap: 12px;
@@ -98,4 +123,18 @@ main.shellLogin {
text-align: center;
letter-spacing: 0.08em;
}
@media (max-width: 760px) {
.topbar {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.nav {
margin: 0;
}
.actions {
width: 100%;
justify-content: space-between;
}
}
</style>

View File

@@ -3,7 +3,7 @@ import { computed } from "vue";
import type { MinerStat } from "../types";
import { ROASTS, STATUS_LINES, STALE_LINES } from "../strings";
const props = defineProps<{ miner: MinerStat }>();
const props = defineProps<{ miner: MinerStat; bestWork: number | undefined }>();
const slug = computed(() => {
// Map nickname → roast key
@@ -36,7 +36,8 @@ const lastShareLabel = computed(() => {
});
const lifetimeShares = computed(() => props.miner.diffAcceptedCount);
const bestShare = computed(() => formatBig(props.miner.diffAcceptedSum));
const acceptedWork = computed(() => formatBig(props.miner.diffAcceptedSum));
const bestWork = computed(() => props.bestWork && props.bestWork > 0 ? formatBig(props.bestWork) : "collecting");
const staleNote = computed(() => {
if (props.miner.status !== "stale") return "";
@@ -83,8 +84,12 @@ function formatBig(n: number): string {
<div class="value">{{ lifetimeShares.toLocaleString() }}</div>
</div>
<div class="stat">
<div class="label">total work</div>
<div class="value">{{ bestShare }}</div>
<div class="label">best calc</div>
<div class="value glow-amber">{{ bestWork }}</div>
</div>
<div class="stat">
<div class="label">accepted work</div>
<div class="value">{{ acceptedWork }}</div>
</div>
<div class="stat">
<div class="label">reject %</div>

View File

@@ -8,11 +8,22 @@ const routes: RouteRecordRaw[] = [
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({

View File

@@ -3,6 +3,8 @@
// 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";
export type SignerKind = "extension" | "bunker";
@@ -13,6 +15,9 @@ export type Signer = {
};
let activeSigner: Signer | null = null;
const NOSTR_CONNECT_RELAYS = ["wss://relay.primal.net"];
const NOSTR_CONNECT_TIMEOUT_MS = 120_000;
const pool = new SimplePool();
export function getActiveSigner(): Signer | null {
return activeSigner;
@@ -66,3 +71,118 @@ export async function loginWithBunker(bunkerUri: string): Promise<string> {
};
return pubkey;
}
export async function loginWithRemoteApp(): 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 signer = new NostrConnectSigner({
relays: NOSTR_CONNECT_RELAYS,
signer: new PrivateKeySigner(),
subscriptionMethod: subscribeToRelays,
publishMethod: publishToRelays,
});
const permissions =
NostrConnectSigner.buildSigningPermissions?.([27235]) ?? ["sign_event:27235"];
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 = {
kind: "bunker",
getPublicKey: async () => pubkey,
signEvent: async (template: EventTemplate) =>
(await signer.signEvent(template)) as NostrEvent,
};
return pubkey;
} finally {
window.clearTimeout(timeout);
if (!signer.isConnected) await signer.close().catch(() => {});
}
}
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");
}
}

View File

@@ -48,6 +48,24 @@ export const useAuthStore = defineStore("auth", () => {
}
}
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;
}
}
function logout(): void {
api.clearToken();
signer.clearSigner();
@@ -56,5 +74,5 @@ export const useAuthStore = defineStore("auth", () => {
error.value = null;
}
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, logout };
return { npub, token, error, busy, isLoggedIn, loginExtension, loginBunker, loginRemoteApp, logout };
});

View File

@@ -1,21 +1,78 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import * as api from "../services/api";
import type { DatumSnapshot } from "../types";
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> = {};
for (const miner of snap.miners) {
const key = miner.nickname || miner.authUsername || miner.remoteHost;
miners[key] = miner.hashrateThs;
minerWork[key] = miner.diffAcceptedSum;
minerShares[key] = miner.diffAcceptedCount;
}
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,
};
}
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";
@@ -37,5 +94,5 @@ export const useStatsStore = defineStore("stats", () => {
}
}
return { snapshot, error, loading, fetchOnce, start, stop };
return { snapshot, history, error, loading, fetchOnce, start, stop };
});

View File

@@ -17,17 +17,21 @@ export const STATUS_LINES = {
export const LOTTERY_LINES = [
(oddsPerDay: number) =>
`today's chance: ${formatPct(oddsPerDay)}. small number, enormous main-character energy.`,
`today: ${formatPct(oddsPerDay)}. A rounding error wearing a cape.`,
(oddsPerDay: number) =>
`statistically, the next block arrives in ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. emotionally, any minute now.`,
`expected wait: ${humanYears(1 / Math.max(oddsPerDay, 1e-30) / 365)}. Your descendants' descendants will still be checking the fan noise.`,
() =>
`the math says no. the dashboard has chosen to hear "not yet."`,
`the math says no in every language, including ones we haven't invented yet.`,
(oddsPerDay: number) =>
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. bad weather has better marketing.`,
`versus lightning today: ${ratioVsLightning(oddsPerDay)}x. Even the sky has a better business plan.`,
() =>
`four tiny tickets in a planetary raffle. extremely unserious. deeply respected.`,
`four little heaters bullying SHA-256 and calling it a retirement strategy.`,
() =>
`every share is a receipt for optimism with terrible accounting.`,
`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.`,
];
export const BLOCK_CELEBRATION_LINES = [

View File

@@ -56,6 +56,19 @@ export type DatumSnapshot = {
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>;
};
export type LoginResponse = {
token: string;
npub: string;

View File

@@ -16,6 +16,27 @@ const totalThs = computed(() => stats.snapshot?.pool.combinedHashrateThs ?? 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)));
function minerKey(m: { nickname: string; authUsername: string; remoteHost: string }): string {
return m.nickname || m.authUsername || m.remoteHost;
}
</script>
<template>
@@ -30,13 +51,28 @@ const upstreamErr = computed(() => stats.snapshot?.error);
<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>
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" />
<ShareTicker :snapshot="stats.snapshot" />
</div>
<div class="miners">
<MinerCard v-for="m in miners" :key="m.authUsername || m.nickname" :miner="m" />
<MinerCard
v-for="m in miners"
:key="m.authUsername || m.nickname"
:miner="m"
:best-work="bestWorkByMiner[minerKey(m)]"
/>
<div v-if="!miners.length && !errorMsg" class="empty panel muted">
waiting for the first poll · the boards are warming up
</div>
@@ -58,6 +94,22 @@ const upstreamErr = computed(() => stats.snapshot?.error);
gap: 16px;
margin: 16px 0;
}
.best {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-top: 16px;
}
.best strong {
display: block;
margin-top: 4px;
font-size: 24px;
}
.best span {
font-size: 11px;
text-align: right;
}
.miners {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
@@ -70,5 +122,12 @@ const upstreamErr = computed(() => stats.snapshot?.error);
}
@media (max-width: 800px) {
.grid-row { grid-template-columns: 1fr; }
.best {
align-items: flex-start;
flex-direction: column;
}
.best span {
text-align: left;
}
}
</style>

View File

@@ -0,0 +1,441 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useStatsStore } from "../stores/stats";
import type { HistoryPoint } from "../types";
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.nickname || m.authUsername || m.remoteHost;
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>
<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>
</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>

View File

@@ -7,6 +7,7 @@ const auth = useAuthStore();
const router = useRouter();
const bunkerUri = ref("");
const showBunkerInput = ref(false);
const waitingRemote = ref(false);
async function loginExt(): Promise<void> {
try {
@@ -29,6 +30,18 @@ async function loginBunker(): Promise<void> {
/* 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;
}
}
</script>
<template>
@@ -47,13 +60,22 @@ async function loginBunker(): Promise<void> {
<small>NIP-07 · alby, nos2x, primal</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"
>
remote signer
<small>NIP-46 · primal app, amber, nsecbunker</small>
bunker URI
<small>advanced fallback · nsecbunker</small>
</button>
<div v-if="showBunkerInput" class="bunker">
@@ -75,9 +97,7 @@ async function loginBunker(): Promise<void> {
</div>
<div class="foot muted">
no extension? install
<a href="https://github.com/nostr-protocol/nips/blob/master/07.md" target="_blank" rel="noopener">alby</a>,
or use a remote signer.
tap open signer app, approve the connection, then approve the sign-in event.
</div>
</div>
</div>

View File

@@ -0,0 +1,23 @@
<template>
<div class="callback panel">
<div class="title glow-cyan">// signer_return</div>
<p class="muted">Return to the gashboard tab to finish sign-in.</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>

View File

@@ -1,9 +1,11 @@
version: "3.9"
# gashboard — deploy as a Portainer Stack on the same Umbrel host that runs Datum.
# IMPORTANT: set DATUM_NETWORK to the actual Docker network used by datum_datum_1.
# Find it with:
# docker inspect -f '{{range $name, $_ := .NetworkSettings.Networks}}{{$name}}{{"\n"}}{{end}}' datum_datum_1
#
# Umbrel's Portainer app runs user stacks inside Docker-in-Docker. A Portainer
# stack network named "umbrel_main_network" is not the real Umbrel host network,
# so Docker DNS names such as datum_datum_1 do not resolve from this container.
# Host networking is used here so gashboard can reach Datum's real container IP.
services:
gashboard:
@@ -23,28 +25,21 @@ services:
PORT: "1337"
LOG_LEVEL: "${LOG_LEVEL:-info}"
CORS_ORIGIN: "${CORS_ORIGIN:-}"
# Reach the Datum gateway container directly on its Docker network.
# Do not use the Umbrel host proxy here; it serves the Umbrel web shell.
DATUM_URL: "${DATUM_URL:-http://datum:21000}"
# Reach the Datum gateway container directly. Do not use Umbrel's
# published port 21000 here; it is the Umbrel auth proxy.
# Find the current IP with:
# docker inspect -f '{{.NetworkSettings.Networks.umbrel_main_network.IPAddress}}' datum_datum_1
DATUM_URL: "${DATUM_URL?must be set, e.g. http://10.21.0.11:21000}"
DATUM_ADMIN_USER: "${DATUM_ADMIN_USER:-admin}"
DATUM_ADMIN_PASSWORD: "${DATUM_ADMIN_PASSWORD?must be set}"
DATUM_POLL_INTERVAL_MS: "${DATUM_POLL_INTERVAL_MS:-5000}"
NOSTR_ALLOWED_NPUBS: "${NOSTR_ALLOWED_NPUBS?must be set}"
JWT_SECRET: "${JWT_SECRET?must be set}"
JWT_TTL_SECONDS: "${JWT_TTL_SECONDS:-86400}"
ports:
- "1337:1337"
network_mode: host
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:1337/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- datum_network
- default
networks:
datum_network:
external: true
name: "${DATUM_NETWORK:-umbrel_main_network}"