Add miner shameboard calculations

This commit is contained in:
Dorian
2026-05-06 18:47:51 +01:00
parent c77c74612d
commit c2c376f8b9
10 changed files with 393 additions and 5 deletions

View File

@@ -13,10 +13,12 @@ LOG_LEVEL=info
# STATIC_DIR=
# ---- Datum gateway (the Umbrel app we're polling) ----
# 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
# Portainer on Umbrel runs stacks inside Docker-in-Docker with host networking.
# Use Datum's real Umbrel Docker IP. Do not use datum/datum_datum_1 DNS and do
# not use host port 21000, which is Umbrel's auth proxy.
# Find it with:
# docker inspect -f '{{.NetworkSettings.Networks.umbrel_main_network.IPAddress}}' datum_datum_1
DATUM_URL=http://10.21.0.11:21000
DATUM_ADMIN_USER=admin
DATUM_ADMIN_PASSWORD=
# How often to scrape /clients (ms). Datum updates per-worker hashrate every

View File

@@ -9,6 +9,7 @@ const UNKNOWN_PROFILE: MinerProfile = {
ownerLabel: "unknown",
locationLabel: "unknown",
expectedHashrateThs: 0,
watts: 0,
workerNameMatchers: [],
};
@@ -119,6 +120,8 @@ export function parseClientsHtml(html: string): MinerStat[] {
nickname: profile.nickname,
model: profile.model,
location: profile.locationLabel,
expectedHashrateThs: profile.expectedHashrateThs,
watts: profile.watts,
hashrateThs,
hashrateAgeS,
lastShareAgeS,

View File

@@ -12,6 +12,7 @@ export const MINER_PROFILES: readonly MinerProfile[] = [
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 4.5,
watts: 80,
workerNameMatchers: ["nerdqaxe", "qu4ck", "quack"],
},
{
@@ -21,6 +22,7 @@ export const MINER_PROFILES: readonly MinerProfile[] = [
ownerLabel: "shared",
locationLabel: "Site A",
expectedHashrateThs: 1.2,
watts: 18,
workerNameMatchers: ["bitaxe", "p1xel", "pixel"],
},
{
@@ -30,6 +32,7 @@ export const MINER_PROFILES: readonly MinerProfile[] = [
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 4.0,
watts: 140,
workerNameMatchers: ["nano", "nano3", "n4n0"],
},
{
@@ -39,6 +42,7 @@ export const MINER_PROFILES: readonly MinerProfile[] = [
ownerLabel: "shared",
locationLabel: "Site B",
expectedHashrateThs: 37.0,
watts: 800,
workerNameMatchers: ["mini", "mini3", "m1n1"],
},
] as const;

View File

@@ -5,6 +5,7 @@ export type MinerProfile = {
ownerLabel: string;
locationLabel: string;
expectedHashrateThs: number;
watts: number;
workerNameMatchers: readonly string[];
};
@@ -14,6 +15,8 @@ export type MinerStat = {
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;

View File

@@ -0,0 +1,364 @@
<script setup lang="ts">
import { computed } from "vue";
import type { DatumSnapshot, HistoryPoint, MinerStat } from "../types";
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 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.nickname || m.authUsername || m.remoteHost;
}
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.0001) return `${n.toExponential(2)}%`;
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>
<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(5, 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>

View File

@@ -25,11 +25,13 @@ 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.nickname || miner.authUsername || miner.remoteHost;
miners[key] = miner.hashrateThs;
minerWork[key] = miner.diffAcceptedSum;
minerShares[key] = miner.diffAcceptedCount;
minerStatus[key] = miner.status;
}
return {
t: snap.fetchedAt,
@@ -42,6 +44,7 @@ function asHistoryPoint(snap: DatumSnapshot): HistoryPoint {
miners,
minerWork,
minerShares,
minerStatus,
};
}

View File

@@ -4,7 +4,7 @@
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": "USB-stick miner. AliExpress's revenge on the network hashrate.",
"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.",
};

View File

@@ -4,6 +4,8 @@ export type MinerStat = {
nickname: string;
model: string;
location: string;
expectedHashrateThs: number;
watts: number;
hashrateThs: number;
hashrateAgeS: number | null;
lastShareAgeS: number | null;
@@ -67,6 +69,7 @@ export type HistoryPoint = {
miners: Record<string, number>;
minerWork: Record<string, number>;
minerShares: Record<string, number>;
minerStatus: Record<string, MinerStat["status"]>;
};
export type LoginResponse = {

View File

@@ -6,6 +6,7 @@ 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();
@@ -66,6 +67,8 @@ function minerKey(m: { nickname: string; authUsername: string; remoteHost: strin
<ShareTicker :snapshot="stats.snapshot" />
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
<div class="miners">
<MinerCard
v-for="m in miners"

View File

@@ -2,6 +2,7 @@
import { computed, onMounted, onUnmounted, ref } from "vue";
import { useStatsStore } from "../stores/stats";
import type { HistoryPoint } from "../types";
import Shameboard from "../components/Shameboard.vue";
const stats = useStatsStore();
@@ -260,6 +261,8 @@ function fmt(n: number, digits = 2): string {
</div>
</section>
</div>
<Shameboard :snapshot="stats.snapshot" :history="stats.history" />
</section>
</template>