Add network stats and rotating mining jokes

This commit is contained in:
Dorian
2026-05-06 19:43:28 +01:00
parent 7e1f7a1a4b
commit fa707e2464
14 changed files with 440 additions and 14 deletions

View File

@@ -25,6 +25,9 @@ DATUM_ADMIN_PASSWORD=
# few seconds; 5s is a sane default.
DATUM_POLL_INTERVAL_MS=5000
# 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.

View File

@@ -15,6 +15,7 @@ const RawEnv = z.object({
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),
MEMPOOL_API_URL: z.string().url().default("https://tx1138.com/api"),
NOSTR_ALLOWED_NPUBS: z.string().min(1),
@@ -57,6 +58,9 @@ export const config = {
adminPassword: parsed.DATUM_ADMIN_PASSWORD,
pollIntervalMs: parsed.DATUM_POLL_INTERVAL_MS,
},
mempool: {
url: parsed.MEMPOOL_API_URL.replace(/\/$/, ""),
},
nostr: {
allowedHexPubkeys: parseAllowedNpubs(parsed.NOSTR_ALLOWED_NPUBS),
},

View File

@@ -2,7 +2,7 @@ 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, PoolStat } from "./types.js";
import type { CurrentJob, DatumSnapshot, NetworkStat, PoolStat } from "./types.js";
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -35,11 +35,19 @@ const EMPTY_JOB: CurrentJob = {
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" },
};
@@ -85,6 +93,37 @@ 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;
};
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();
@@ -193,6 +232,12 @@ async function pollOnce(): Promise<DatumSnapshot> {
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,
@@ -207,7 +252,12 @@ async function pollOnce(): Promise<DatumSnapshot> {
sharesRejected,
connectionStatus: "ok",
},
job: EMPTY_JOB,
job: {
...EMPTY_JOB,
blockHeight: network.blockHeight,
difficulty: network.difficulty,
},
network,
miners,
};
} catch (err) {

View File

@@ -60,11 +60,19 @@ export type CurrentJob = {
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 };
};

View File

@@ -2,18 +2,17 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { LOTTERY_LINES } from "../strings";
const props = defineProps<{ totalThs: number }>();
const props = defineProps<{ totalThs: number; networkEh: number }>();
// As of mid-2026, network hashrate is roughly ~720 EH/s. Hardcoded estimate;
// good enough for self-deprecating jokes about cosmic odds.
const NETWORK_EH_S = 720;
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 / (NETWORK_EH_S * 1e6);
return props.totalThs / (activeNetworkEh.value * 1e6);
});
const probPerDay = computed(() => {
@@ -44,6 +43,9 @@ const currentLine = computed(() => {
<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>
@@ -64,6 +66,9 @@ const currentLine = computed(() => {
flex-direction: column;
gap: 10px;
}
.source {
font-size: 10px;
}
.line {
font-size: 13px;
display: flex;

View File

@@ -1,8 +1,15 @@
<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";
const props = defineProps<{ snapshot: DatumSnapshot | null; history: HistoryPoint[] }>();
const raceTitle = useRotatingCopy(RACE_TITLES);
const scaleNote = useRotatingCopy(RACE_SCALE_NOTES, 7600);
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 ?? [];
@@ -43,6 +50,28 @@ const rows = computed(() => {
];
});
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: `${networkMultiple.value.toExponential(2)}x your shelf. rude but factual.`,
},
];
});
function minerKey(m: MinerStat): string {
return m.nickname || m.authUsername || m.remoteHost;
}
@@ -60,12 +89,13 @@ function imageFor(m: MinerStat): string {
<header>
<div>
<div class="label">hashrate race</div>
<h2 class="glow-cyan">Tiny machines, deeply unfair track</h2>
<h2 class="glow-cyan">{{ raceTitle }}</h2>
</div>
<span class="muted">ranked by current TH/s</span>
<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" />
@@ -81,6 +111,25 @@ function imageFor(m: MinerStat): string {
</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>
@@ -108,6 +157,17 @@ h2 {
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;
@@ -124,6 +184,16 @@ img {
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;
}
@@ -158,6 +228,12 @@ img {
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;

View File

@@ -1,14 +1,27 @@
<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";
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, 7200);
const networkLabel = useRotatingCopy(NETWORK_LABELS, 7800);
const ageS = computed(() => {
const t = props.snapshot?.fetchedAt;
if (!t) return null;
@@ -19,17 +32,26 @@ const ageS = computed(() => {
<template>
<section class="hero panel">
<div class="left">
<div class="label">total hashrate · all 4 little soldiers</div>
<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">block height</div>
<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 ? `${networkMultiple.toExponential(2)}x` : "—" }}</div>
</div>
<div class="cell">
<div class="label">subscribed</div>
<div class="value">{{ subscribed }}</div>
@@ -67,9 +89,14 @@ const ageS = computed(() => {
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(5, 1fr);
grid-template-columns: repeat(7, 1fr);
gap: 12px;
}
.cell .value {

View File

@@ -12,6 +12,8 @@ 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);
@@ -212,6 +214,11 @@ function pct(n: number, digits = 4): string {
<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 ? `${networkMultiple.toExponential(2)}x` : "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">
@@ -294,7 +301,7 @@ h2 {
letter-spacing: 0.08em;
}
.summary {
grid-template-columns: repeat(5, 1fr);
grid-template-columns: repeat(6, 1fr);
}
.summary > div,
.leader-strip > div {

View File

@@ -0,0 +1,18 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
export function useRotatingCopy(lines: readonly string[], intervalMs = 6500) {
const index = ref(0);
let timer: number | null = null;
onMounted(() => {
timer = window.setInterval(() => {
index.value = (index.value + 1) % lines.length;
}, intervalMs);
});
onUnmounted(() => {
if (timer !== null) window.clearInterval(timer);
});
return computed(() => lines[index.value] ?? lines[0] ?? "");
}

114
apps/web/src/copy.ts Normal file
View File

@@ -0,0 +1,114 @@
export const HASHRATE_LABELS = [
"total hashrate · all actual miners",
"tiny thunder department",
"hashing cupboard output",
"combined cope velocity",
"someone's getting warmed tonight",
"space heaters with receipts",
"four boards versus a planet",
"domestic sha-256 turbulence",
"the little engines that almost certainly won't",
"thermal optimism index",
"proof-of-warmth aggregate",
"hashrate, allegedly",
"tiny chips doing tax fraud to physics",
"the room is warmer than the odds",
"solo lottery horsepower",
"sats-per-delusion engine",
"garage-grade entropy cannon",
"pleb furnace output",
"all miners, no excuses",
"bitcoin-adjacent radiator speed",
] as const;
export const BLOCK_LABELS = [
"current block height",
"where bitcoin is right now",
"latest block on the wall",
"chain height, for the adults",
"block number the kids are chasing",
"global scoreboard tick",
"bitcoin's current floor",
"the moving target",
"height of the mountain",
"mempool says we're here",
"sovereign block receipt",
"latest confirmed step",
"chain altitude",
"block ladder position",
"what the node believes",
"today's impossible number",
"where the grown-up miners are",
"the target keeps walking",
"bitcoin's odometer",
"public humiliation height",
] as const;
export const NETWORK_LABELS = [
"network hashrate",
"the actual boss fight",
"everyone else, unfortunately",
"global mining industrial complex",
"the dragon outside",
"planetary sha-256 weather",
"big pool gravity",
"the reason this is funny",
"industrial-scale bullying",
"total network furnace",
"the adults with warehouses",
"hashrate landlord class",
"bitcoin's meat grinder",
"global chip argument",
"big miner misery wall",
"the mountain of nope",
"everyone versus your shelf",
"network-sized emotional damage",
"the pool hall full of monsters",
"the thing Boomer Heater fears",
] as const;
export const RACE_TITLES = [
"Tiny machines, deeply unfair track",
"Actual miners, spiritually overconfident",
"The cupboard grand prix",
"Drag race at walking speed",
"The hashrate kindergarten derby",
"Domestic miners doing their little laps",
"Four warm rectangles and a dream",
"Race day for the statistically doomed",
"The space-heater invitational",
"Actual miners, actual delusion",
"The tiny ASIC humiliation circuit",
"Tour de not-finding-a-block",
"Hashrate horse race, if horses were firmware",
"A sprint measured in geological disappointment",
"The nobody-is-retiring 500",
"Proof-of-work nursery slopes",
"The little rigs are trying, allegedly",
"Solo mining track day",
"The warm boys championship",
"Someone's getting warmed tonight",
] as const;
export const RACE_SCALE_NOTES = [
"actual miners · scaled up to not shame the children",
"scaled up so the children remain visible",
"visual mercy scale enabled",
"not to scale, because cruelty has limits",
"tiny miner zoom mode",
"chart wearing reading glasses",
"magnified for emotional support",
"scaled up like their confidence",
"warehouse miners cropped out for decency",
"child-safe hashrate scale",
"actual miners, padded for dignity",
"zoomed in before the network laughs",
"big pools excluded from the yardstick",
"scale adjusted to preserve morale",
"the children get a booster seat",
"mercifully not linear",
"visual participation trophy mode",
"shown larger than nature intended",
"small rig cinema mode",
"the graph lies kindly",
] as const;

View File

@@ -32,6 +32,110 @@ export const LOTTERY_LINES = [
`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 = [

View File

@@ -49,11 +49,19 @@ export type CurrentJob = {
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 };
};

View File

@@ -14,6 +14,7 @@ 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);
@@ -84,7 +85,7 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
</section>
<div class="grid-row">
<LotteryWidget :total-ths="totalThs" />
<LotteryWidget :total-ths="totalThs" :network-eh="networkEh" />
<ShareTicker :snapshot="stats.snapshot" />
</div>

View File

@@ -33,6 +33,7 @@ services:
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}"
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}"