Add network stats and rotating mining jokes
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
18
apps/web/src/composables/useRotatingCopy.ts
Normal file
18
apps/web/src/composables/useRotatingCopy.ts
Normal 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
114
apps/web/src/copy.ts
Normal 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;
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user