Add best share analysis page, miner column, and UI improvements
- Best share page: hex + binary hash comparison against network target, per-bit coloring showing exactly which bits prevented a valid block, toggle between network diff at time of finding vs current diff - Capture best share hash from ckpool logs with one-time backfill - Persist network difficulty at time of best share for historical accuracy - Add miner (worker) column to blocks table via coinbase address matching - Truncate block hashes in table with full hash on hover - Increase hashrate chart Y-axis to 7 ticks for better readability
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
import UserDetailPage from "./lib/UserDetailPage.svelte";
|
||||
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
|
||||
import AcceleratorPage from "./lib/AcceleratorPage.svelte";
|
||||
import StatsPage from "./lib/StatsPage.svelte";
|
||||
import BestSharePage from "./lib/BestSharePage.svelte";
|
||||
import SharesBar from "./lib/SharesBar.svelte";
|
||||
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
||||
|
||||
@@ -41,6 +43,18 @@
|
||||
<AcceleratorPage />
|
||||
</div>
|
||||
{/key}
|
||||
{:else if selection.page === "stats"}
|
||||
{#key 'stats'}
|
||||
<div class="page-enter">
|
||||
<StatsPage />
|
||||
</div>
|
||||
{/key}
|
||||
{:else if selection.page === "bestshare"}
|
||||
{#key 'bestshare'}
|
||||
<div class="page-enter">
|
||||
<BestSharePage />
|
||||
</div>
|
||||
{/key}
|
||||
{:else if selection.worker}
|
||||
{#key selection.worker}
|
||||
<div class="page-enter">
|
||||
|
||||
@@ -0,0 +1,575 @@
|
||||
<script lang="ts">
|
||||
import { clearSelection } from "../stores/selection.svelte";
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { formatDifficulty } from "../format";
|
||||
|
||||
const data = $derived(snap.data!);
|
||||
const hash = $derived(data.best_share_hash ?? "");
|
||||
const diff = $derived(data.best_diff ?? 0);
|
||||
const currentNetDiff = $derived(data.chain?.difficulty ?? 0);
|
||||
// Network difficulty at the time the best share was found.
|
||||
// Legacy fallback: hardcoded 136.6T for pre-upgrade shares.
|
||||
const foundNetDiff = $derived(data.best_share_net_diff || 136_597_951_737_045);
|
||||
const isLegacyNetDiff = $derived(!data.best_share_net_diff);
|
||||
|
||||
// Toggle: "found" = difficulty at time of finding, "current" = live network diff
|
||||
let diffView: "found" | "current" = $state("found");
|
||||
const netDiff = $derived(diffView === "found" ? foundNetDiff : currentNetDiff);
|
||||
|
||||
// --- Target computation ---
|
||||
// Bitcoin's difficulty-1 target is 0x00000000FFFF << 208.
|
||||
// Target for difficulty D = diff1_target / D.
|
||||
// We work in hex strings (64 chars = 256 bits) so we can do a
|
||||
// character-by-character comparison with the share hash.
|
||||
//
|
||||
// For display we only need ~20 hex chars (the significant prefix).
|
||||
// We compute this via BigInt arithmetic for full precision.
|
||||
const diff1Target = BigInt("0x00000000FFFF0000000000000000000000000000000000000000000000000000");
|
||||
|
||||
function targetHex(d: number): string {
|
||||
if (!d || d <= 0) return "f".repeat(64);
|
||||
// Scale difficulty to integer: multiply by 2^48 to preserve precision,
|
||||
// then divide. target = diff1_target / D
|
||||
// = diff1_target * 2^48 / (D * 2^48)
|
||||
const scale = BigInt(1) << BigInt(48);
|
||||
const dScaled = BigInt(Math.round(d * Number(scale)));
|
||||
if (dScaled === BigInt(0)) return "f".repeat(64);
|
||||
const t = (diff1Target * scale) / dScaled;
|
||||
let s = t.toString(16);
|
||||
// Pad to 64 hex chars.
|
||||
while (s.length < 64) s = "0" + s;
|
||||
if (s.length > 64) s = s.slice(0, 64);
|
||||
return s;
|
||||
}
|
||||
|
||||
const target = $derived(targetHex(netDiff));
|
||||
|
||||
// --- Per-character comparison ---
|
||||
// Walk hex chars left-to-right. For each position:
|
||||
// green = share char is lower than target char (share is winning here)
|
||||
// yellow = share char is higher than target char (share fails here, needs to be lower)
|
||||
// white = chars are equal (keep going) OR position is past the decided point
|
||||
//
|
||||
// Once we hit a position where they differ, all subsequent chars are decided:
|
||||
// - if share was lower → the rest is green (share already won)
|
||||
// - if share was higher → the rest is white (doesn't matter, already lost)
|
||||
type CharInfo = { c: string; cls: string };
|
||||
|
||||
// Per-char hex coloring matching the binary approach:
|
||||
// - Positions within the network's required leading-zero hex chars:
|
||||
// green if '0' (correct), yellow if non-zero (problem char)
|
||||
// - Boundary nibble (first non-zero target char): compare against target
|
||||
// - Positions past the boundary: white
|
||||
const hashChars = $derived.by((): CharInfo[] => {
|
||||
if (!hash) return [];
|
||||
return hash.split("").map((c, i) => {
|
||||
const hVal = parseInt(c, 16);
|
||||
const tVal = parseInt(target[i] ?? "f", 16);
|
||||
if (i < networkHexZeros) {
|
||||
// Must be zero for a valid block
|
||||
return { c, cls: hVal === 0 ? "z-have" : "z-need" };
|
||||
}
|
||||
if (i === networkHexZeros) {
|
||||
// Boundary char — must be <= target char
|
||||
return { c, cls: hVal <= tVal ? "z-have" : "z-need" };
|
||||
}
|
||||
// Past the significant zone
|
||||
return { c, cls: "z-rest" };
|
||||
});
|
||||
});
|
||||
|
||||
// Target row: color the significant zone (leading zeros + boundary)
|
||||
// to align visually with the hash row.
|
||||
const targetChars = $derived.by((): CharInfo[] => {
|
||||
return target.split("").map((c, i) => {
|
||||
if (i <= networkHexZeros) return { c, cls: "z-have" };
|
||||
return { c, cls: "z-rest" };
|
||||
});
|
||||
});
|
||||
|
||||
// Count leading zero bits in the hash (not just hex zeros — count
|
||||
// the actual zero bits in the first non-zero nibble too).
|
||||
function leadingZeroBits(h: string): number {
|
||||
let bits = 0;
|
||||
for (const c of h) {
|
||||
const v = parseInt(c, 16);
|
||||
if (v === 0) { bits += 4; continue; }
|
||||
if (v < 2) bits += 3;
|
||||
else if (v < 4) bits += 2;
|
||||
else if (v < 8) bits += 1;
|
||||
break;
|
||||
}
|
||||
return bits;
|
||||
}
|
||||
|
||||
function requiredZeroBits(d: number): number {
|
||||
if (!d || d <= 0) return 0;
|
||||
return leadingZeroBits(targetHex(d));
|
||||
}
|
||||
|
||||
const shareZeroBits = $derived(hash ? leadingZeroBits(hash) : requiredZeroBits(diff));
|
||||
const networkZeroBits = $derived(requiredZeroBits(netDiff));
|
||||
|
||||
// How many leading hex zeros the share has / network requires (for display).
|
||||
function leadingHexZeros(h: string): number {
|
||||
let n = 0;
|
||||
for (const c of h) { if (c === "0") n++; else break; }
|
||||
return n;
|
||||
}
|
||||
const shareHexZeros = $derived(hash ? leadingHexZeros(hash) : leadingHexZeros(targetHex(diff)));
|
||||
const networkHexZeros = $derived(leadingHexZeros(target));
|
||||
|
||||
// Progress: linear ratio of share difficulty to network difficulty.
|
||||
const progressPct = $derived(
|
||||
netDiff > 0 && diff > 0
|
||||
? Math.min(100, (diff / netDiff) * 100)
|
||||
: 0,
|
||||
);
|
||||
|
||||
// How many times harder the network target is.
|
||||
const diffRatio = $derived(netDiff > 0 && diff > 0 ? netDiff / diff : 0);
|
||||
|
||||
// Hex → binary lookup.
|
||||
const hexToBin: Record<string, string> = {
|
||||
"0": "0000", "1": "0001", "2": "0010", "3": "0011",
|
||||
"4": "0100", "5": "0101", "6": "0110", "7": "0111",
|
||||
"8": "1000", "9": "1001", "a": "1010", "b": "1011",
|
||||
"c": "1100", "d": "1101", "e": "1110", "f": "1111",
|
||||
};
|
||||
|
||||
function hexToBits(h: string): string {
|
||||
return h.split("").map(c => hexToBin[c.toLowerCase()] ?? "0000").join("");
|
||||
}
|
||||
|
||||
// Per-bit coloring based on position relative to networkZeroBits:
|
||||
// - Positions < networkZeroBits: green if '0' (correct), yellow if '1' (problem bit)
|
||||
// - Positions >= networkZeroBits: white (past the required leading-zero zone)
|
||||
// This shows exactly which bits prevented the hash from being a valid block.
|
||||
const binaryChars = $derived.by(() => {
|
||||
if (!hash) return [];
|
||||
const hBits = hexToBits(hash);
|
||||
const out: CharInfo[] = [];
|
||||
for (let i = 0; i < hBits.length; i++) {
|
||||
if (i > 0 && i % 16 === 0) {
|
||||
out.push({ c: " ", cls: "z-sep" });
|
||||
}
|
||||
const hb = hBits[i];
|
||||
let cls: string;
|
||||
if (i < networkZeroBits) {
|
||||
// Within the zone that must be zero for a valid block
|
||||
cls = hb === "0" ? "z-have" : "z-need";
|
||||
} else {
|
||||
// Past the required zero zone — doesn't matter
|
||||
cls = "z-rest";
|
||||
}
|
||||
out.push({ c: hb, cls });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") clearSelection();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<section class="page">
|
||||
<nav class="crumbs">
|
||||
<button type="button" class="back" onclick={clearSelection}>
|
||||
← Back to dashboard
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header class="head">
|
||||
<div class="stat-label">Best Share Analysis</div>
|
||||
<h2>How close to a block?</h2>
|
||||
</header>
|
||||
|
||||
<!-- Difficulty view toggle -->
|
||||
<section class="toggle-bar">
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-btn"
|
||||
class:active={diffView === "found"}
|
||||
onclick={() => diffView = "found"}
|
||||
>
|
||||
At time of finding
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-btn"
|
||||
class:active={diffView === "current"}
|
||||
onclick={() => diffView = "current"}
|
||||
>
|
||||
Current network diff
|
||||
</button>
|
||||
</section>
|
||||
{#if diffView === "found" && isLegacyNetDiff}
|
||||
<div class="disclaimer">
|
||||
Network difficulty at time of finding was not recorded for this share. Using approximate value of 136.6T based on historical data.
|
||||
</div>
|
||||
{/if}
|
||||
{#if diffView === "current" && currentNetDiff !== foundNetDiff}
|
||||
<div class="disclaimer">
|
||||
Comparing against the <strong>current</strong> network difficulty ({formatDifficulty(currentNetDiff)}), which may differ from the difficulty when this share was found{isLegacyNetDiff ? "" : ` (${formatDifficulty(foundNetDiff)})`}.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Explanation -->
|
||||
<section class="card explainer">
|
||||
<p>
|
||||
To mine a block, you must find a hash whose <strong>numeric value</strong>
|
||||
is less than the network's target. It's not just about leading zeros —
|
||||
the entire hash must be smaller than the target. Think of it like a lottery:
|
||||
you need to roll a number below a threshold, and the threshold gets lower as
|
||||
difficulty rises.
|
||||
</p>
|
||||
<p>
|
||||
Your best share has difficulty <strong>{formatDifficulty(diff)}</strong>,
|
||||
producing a hash with <strong>{shareZeroBits}</strong> leading zero bits.
|
||||
The current network difficulty of <strong>{formatDifficulty(netDiff)}</strong>
|
||||
requires a hash with at least <strong>{networkZeroBits}</strong> leading zero bits.
|
||||
{#if diffRatio <= 1}
|
||||
Your share meets the network target — this would be a valid block!
|
||||
{:else}
|
||||
The network target is <strong>{diffRatio.toFixed(1)}x</strong> harder than your
|
||||
best share.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- Difficulty comparison cards -->
|
||||
<section class="totals">
|
||||
<div class="card">
|
||||
<div class="stat-label">Your Best Share</div>
|
||||
<div class="stat-value">{formatDifficulty(diff)}</div>
|
||||
<div class="stat-sub">{shareZeroBits} leading zero bits ({shareHexZeros} hex zeros){!hash ? " est." : ""}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Network Target</div>
|
||||
<div class="stat-value">{formatDifficulty(netDiff)}</div>
|
||||
<div class="stat-sub">{networkZeroBits} leading zero bits ({networkHexZeros} hex zeros)</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Gap</div>
|
||||
<div class="stat-value" class:complete={diffRatio <= 1}>
|
||||
{diffRatio <= 1 ? "Block!" : diffRatio < 1000 ? diffRatio.toFixed(1) + "x" : formatDifficulty(diffRatio)}
|
||||
</div>
|
||||
<div class="stat-sub">
|
||||
{#if diffRatio <= 1}
|
||||
This share satisfies the network target
|
||||
{:else}
|
||||
{networkZeroBits - shareZeroBits} more leading zero {networkZeroBits - shareZeroBits === 1 ? "bit" : "bits"} needed
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<section class="card">
|
||||
<h3>Progress to Network Target</h3>
|
||||
<div class="progress-row">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
class:full={progressPct >= 100}
|
||||
style="width:{Math.min(progressPct, 100)}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-label">{progressPct.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div class="stat-sub">
|
||||
{formatDifficulty(diff)} / {formatDifficulty(netDiff)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{#if hash}
|
||||
<!-- Hex comparison -->
|
||||
<section class="card">
|
||||
<h3>Share Hash vs Network Target (hex)</h3>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">YOUR HASH</span>
|
||||
<div class="hash-vis mono">
|
||||
{#each hashChars as ch}<!--
|
||||
--><span class="hc {ch.cls}">{ch.c}</span><!--
|
||||
-->{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="compare-row">
|
||||
<span class="compare-label">TARGET</span>
|
||||
<div class="hash-vis mono target-row">
|
||||
{#each targetChars as ch}<!--
|
||||
--><span class="hc {ch.cls}">{ch.c}</span><!--
|
||||
-->{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="hash-legend">
|
||||
<span class="legend-item"><span class="swatch have"></span> Below target (good)</span>
|
||||
<span class="legend-item"><span class="swatch need"></span> Above target (too high)</span>
|
||||
<span class="legend-item"><span class="swatch rest"></span> Remaining</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Binary hash -->
|
||||
<section class="card">
|
||||
<h3>Share Hash (binary)</h3>
|
||||
<div class="hash-vis binary mono">
|
||||
{#each binaryChars as ch}<!--
|
||||
-->{#if ch.cls === "z-sep"}<span class="sep"> </span>{:else}<span class="hc {ch.cls}">{ch.c}</span>{/if}<!--
|
||||
-->{/each}
|
||||
</div>
|
||||
<p class="bin-explain">
|
||||
Every additional leading zero bit makes the hash <strong>2x harder</strong> to find.
|
||||
The first {networkZeroBits} bits must all be zero for a valid block.
|
||||
<span class="z-have" style="font-weight:700">Green</span> bits are already correct (zero),
|
||||
<span class="z-need" style="font-weight:700">yellow</span> bits are the ones that
|
||||
prevented this share from being a valid block.
|
||||
{#if networkZeroBits > shareZeroBits}
|
||||
The gap of <strong>{networkZeroBits - shareZeroBits} bits</strong> means the target is roughly
|
||||
<strong>2<sup>{networkZeroBits - shareZeroBits}</sup> ≈ {Math.round(Math.pow(2, networkZeroBits - shareZeroBits)).toLocaleString()}x</strong>
|
||||
harder — matching the {diffRatio.toFixed(1)}x difficulty ratio.
|
||||
{/if}
|
||||
</p>
|
||||
</section>
|
||||
{:else}
|
||||
<div class="card">
|
||||
<div class="empty">
|
||||
Block header hash will appear here once a share is accepted after deploying this version.
|
||||
The stats above are estimated from difficulty.
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.crumbs {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.back {
|
||||
font: inherit;
|
||||
color: var(--fg-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4em 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
/* Difficulty view toggle */
|
||||
.toggle-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
width: fit-content;
|
||||
}
|
||||
.toggle-btn {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.5em 1em;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--fg-dim);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
.toggle-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.toggle-btn.active {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 600;
|
||||
}
|
||||
.toggle-btn:hover:not(.active) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--fg);
|
||||
}
|
||||
.disclaimer {
|
||||
font-size: 0.82rem;
|
||||
color: var(--fg-dim);
|
||||
background: rgba(245, 196, 71, 0.08);
|
||||
border: 1px solid rgba(245, 196, 71, 0.25);
|
||||
border-radius: 6px;
|
||||
padding: 0.5em 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.disclaimer strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Explanation */
|
||||
.explainer p {
|
||||
margin: 0 0 0.6em;
|
||||
line-height: 1.6;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.explainer p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.explainer strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.totals { grid-template-columns: 1fr; }
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
color: var(--fg-dim);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.complete {
|
||||
color: var(--good);
|
||||
text-shadow: 0 0 12px rgba(92, 224, 168, 0.4);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.progress-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.progress-bar {
|
||||
flex: 1;
|
||||
height: 20px;
|
||||
background: var(--border);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
.progress-fill.full {
|
||||
background: var(--good);
|
||||
box-shadow: 0 0 12px rgba(92, 224, 168, 0.4);
|
||||
}
|
||||
.progress-label {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 4em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Hash comparison rows */
|
||||
.compare-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.compare-row:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.compare-label {
|
||||
display: inline-block;
|
||||
font-size: 0.72rem;
|
||||
color: var(--fg-dim);
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.target-row {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Hash visualization */
|
||||
.hash-vis {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.04em;
|
||||
word-break: break-all;
|
||||
}
|
||||
.hash-vis.binary {
|
||||
font-size: 1rem;
|
||||
line-height: 1.7;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.hc {
|
||||
display: inline;
|
||||
}
|
||||
.z-have {
|
||||
color: var(--good);
|
||||
text-shadow: 0 0 6px rgba(92, 224, 168, 0.35);
|
||||
font-weight: 700;
|
||||
}
|
||||
.z-need {
|
||||
color: rgb(245, 196, 71);
|
||||
text-shadow: 0 0 6px rgba(245, 196, 71, 0.3);
|
||||
font-weight: 700;
|
||||
}
|
||||
.z-rest {
|
||||
color: var(--fg);
|
||||
}
|
||||
.sep {
|
||||
display: inline;
|
||||
user-select: none;
|
||||
width: 0.3em;
|
||||
}
|
||||
.bin-explain {
|
||||
margin: 0.75rem 0 0;
|
||||
line-height: 1.6;
|
||||
color: var(--fg-dim);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.bin-explain strong {
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
.hash-legend {
|
||||
display: flex;
|
||||
gap: 1.2rem;
|
||||
margin-top: 0.75rem;
|
||||
font-size: 0.78em;
|
||||
color: var(--fg-dim);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4em;
|
||||
}
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.swatch.have { background: var(--good); }
|
||||
.swatch.need { background: rgb(245, 196, 71); }
|
||||
.swatch.rest { background: var(--fg); }
|
||||
</style>
|
||||
@@ -19,6 +19,26 @@
|
||||
);
|
||||
|
||||
const currentChain = $derived(snap.data?.chain?.chain ?? "");
|
||||
|
||||
function truncHash(hash: string): string {
|
||||
if (!hash || hash.length <= 16) return hash;
|
||||
return hash.slice(0, 8) + "…" + hash.slice(-8);
|
||||
}
|
||||
|
||||
function minerLabel(miner: string | undefined): string {
|
||||
if (!miner) return "—";
|
||||
// Show address.worker truncated
|
||||
const dot = miner.indexOf(".");
|
||||
if (dot < 0) {
|
||||
// Just an address, truncate middle
|
||||
if (miner.length > 20) return miner.slice(0, 8) + "…" + miner.slice(-6);
|
||||
return miner;
|
||||
}
|
||||
const addr = miner.slice(0, dot);
|
||||
const worker = miner.slice(dot + 1);
|
||||
const shortAddr = addr.length > 12 ? addr.slice(0, 6) + "…" + addr.slice(-4) : addr;
|
||||
return shortAddr + "." + worker;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="card">
|
||||
@@ -31,6 +51,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Height</th>
|
||||
<th>Miner</th>
|
||||
<th>Chain</th>
|
||||
<th>Hash</th>
|
||||
<th class="num">Reward</th>
|
||||
@@ -47,6 +68,7 @@
|
||||
<span class="orphan-tag" title="Reorged out of the canonical chain at {b.orphaned_at}">orphaned</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="miner-cell" title={b.miner ?? ""}>{minerLabel(b.miner)}</td>
|
||||
<td>
|
||||
{#if b.chain && currentChain && b.chain !== currentChain}
|
||||
<span class="chain-tag" title="Mined on {displayChain(b.chain)} - current node is on {displayChain(currentChain)}">{displayChain(b.chain)}</span>
|
||||
@@ -63,8 +85,8 @@
|
||||
href="{explorerBase}/block/{b.hash}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Open block on mempool.space"
|
||||
>{b.hash}</a>
|
||||
title={b.hash}
|
||||
>{truncHash(b.hash)}</a>
|
||||
{:else}
|
||||
<span class="hash mono">—</span>
|
||||
{/if}
|
||||
@@ -100,11 +122,16 @@
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.miner-cell {
|
||||
font-size: 0.85em;
|
||||
color: var(--fg-dim);
|
||||
white-space: nowrap;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.hash-cell {
|
||||
/* Let the 64-char hash wrap inside the cell instead of stretching
|
||||
* the whole table. */
|
||||
max-width: 620px;
|
||||
word-break: break-all;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hash {
|
||||
color: var(--fg-dim);
|
||||
|
||||
@@ -67,10 +67,13 @@
|
||||
}
|
||||
area += ` L${toX(tMax).toFixed(1)},${(PAD.top + plotH).toFixed(1)} Z`;
|
||||
|
||||
const yTicks = [0, vMax * 0.5, vMax].map((v) => ({
|
||||
y: toY(v),
|
||||
label: formatHashrate(v),
|
||||
}));
|
||||
// Generate 5-7 evenly spaced Y ticks for better readability.
|
||||
const tickCount = 6;
|
||||
const yTicks: Array<{ y: number; label: string }> = [];
|
||||
for (let i = 0; i <= tickCount; i++) {
|
||||
const v = (i / tickCount) * vMax;
|
||||
yTicks.push({ y: toY(v), label: formatHashrate(v) });
|
||||
}
|
||||
|
||||
const xTicks: Array<{ x: number; label: string }> = [];
|
||||
const count = Math.min(6, points.length);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selectBestShare } from "../stores/selection.svelte";
|
||||
import {
|
||||
formatHashrate,
|
||||
formatUptime,
|
||||
@@ -115,11 +116,6 @@
|
||||
fetch("/api/admin/ack-best", { method: "POST" });
|
||||
}
|
||||
|
||||
// DEBUG: remove before shipping
|
||||
function debugBest(): void {
|
||||
localAcked = 0;
|
||||
fetch("/api/admin/reset-ack-best", { method: "POST" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="grid-5">
|
||||
@@ -149,11 +145,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
<div class="best-inner">
|
||||
<div class="stat-label">
|
||||
Best share
|
||||
<!-- DEBUG: remove before shipping -->
|
||||
<button class="debug-btn" onclick={(e: MouseEvent) => { e.stopPropagation(); debugBest(); }}>test</button>
|
||||
</div>
|
||||
<div class="stat-label">Best share</div>
|
||||
<div class="stat-value">{formatDifficulty(data.best_diff)}</div>
|
||||
<div
|
||||
class="luck-sub"
|
||||
@@ -167,7 +159,17 @@
|
||||
{#if bestGlow}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="ack-btn" onclick={ackBest} title="Dismiss the new best share animation">Nice</div>
|
||||
<div class="ack-btn" onclick={ackBest} title="Dismiss the new best share notification">Nice</div>
|
||||
{/if}
|
||||
{#if data.best_diff > 0}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="inspect-btn" onclick={selectBestShare} title="Inspect best share hash and leading zeros">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" width="14" height="14">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
Inspect
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -446,16 +448,27 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.debug-btn {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1em 0.4em;
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5em;
|
||||
vertical-align: middle;
|
||||
.inspect-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3em;
|
||||
width: fit-content;
|
||||
margin: 0.4em auto 0;
|
||||
padding: 0.2em 0.7em;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--fg-dim);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.debug-btn:hover {
|
||||
opacity: 1;
|
||||
.inspect-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.height-card {
|
||||
|
||||
+29
-16
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selectStats } from "../stores/selection.svelte";
|
||||
|
||||
const data = $derived(snap.data!);
|
||||
|
||||
@@ -56,10 +57,6 @@
|
||||
prevAccepted = cur;
|
||||
});
|
||||
|
||||
// DEBUG: remove before shipping
|
||||
function debugPulse(): void { pulse = true; setTimeout(() => { pulse = false; }, 700); }
|
||||
function debugReject(): void { reject = true; setTimeout(() => { reject = false; }, 1200); }
|
||||
|
||||
function fmtCount(n: number): string {
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K";
|
||||
@@ -72,9 +69,13 @@
|
||||
<div class="reject-flash" aria-hidden="true"></div>
|
||||
<div class="content">
|
||||
<h3 class="title">Shares
|
||||
<!-- DEBUG: remove before shipping -->
|
||||
<button class="debug-btn" onclick={debugPulse}>acc</button>
|
||||
<button class="debug-btn" onclick={debugReject}>rej</button>
|
||||
<button class="stats-btn" onclick={selectStats} title="Share Statistics">
|
||||
<svg class="stats-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="3" y="12" width="4" height="9" rx="1"/>
|
||||
<rect x="10" y="7" width="4" height="14" rx="1"/>
|
||||
<rect x="17" y="3" width="4" height="18" rx="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
</h3>
|
||||
<div class="stats">
|
||||
<div class="group">
|
||||
@@ -129,17 +130,29 @@
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--fg);
|
||||
}
|
||||
.debug-btn {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1em 0.4em;
|
||||
opacity: 0.3;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.3em;
|
||||
.stats-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.35em;
|
||||
margin-left: 0.6em;
|
||||
vertical-align: middle;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
color: var(--fg-dim);
|
||||
transition: color 0.2s, border-color 0.2s, box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
.debug-btn:hover {
|
||||
opacity: 1;
|
||||
.stats-btn:hover {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 12px rgba(255, 122, 58, 0.5);
|
||||
transform: translateY(-2px) scale(1.05);
|
||||
}
|
||||
.stats-icon {
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
}
|
||||
.stats {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
<script lang="ts">
|
||||
import { clearSelection } from "../stores/selection.svelte";
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { formatDifficulty } from "../format";
|
||||
|
||||
const data = $derived(snap.data!);
|
||||
|
||||
// --- Rejection reason formatting ---
|
||||
const reasonInfo: Record<string, { label: string; tip: string }> = {
|
||||
"Stale": { label: "Stale", tip: "The share was mined on a block that has already been found. Usually caused by network latency." },
|
||||
"Duplicate": { label: "Duplicate", tip: "This exact share was already submitted. Indicates a bug in the mining software or a network retry." },
|
||||
"Above target": { label: "Above Target", tip: "The share difficulty was below the required minimum target set by the pool." },
|
||||
"Dupe": { label: "Duplicate", tip: "This exact share was already submitted." },
|
||||
"High": { label: "Above Target", tip: "The share difficulty was below the required minimum target set by the pool." },
|
||||
"Ntime out of range": { label: "Invalid nTime", tip: "The share timestamp was outside the acceptable range (too far in the future or past)." },
|
||||
"Invalid JobID": { label: "Invalid Job", tip: "The share referenced a work unit that no longer exists. Likely stale work from a slow connection." },
|
||||
"Invalid nonce2 length": { label: "Bad Nonce2", tip: "The extranonce2 field had an unexpected length. Indicates a stratum protocol mismatch." },
|
||||
"Worker mismatch": { label: "Worker Mismatch", tip: "The share was submitted by a different worker than the one that requested the work." },
|
||||
"No nonce": { label: "Missing Nonce", tip: "The share submission was missing the required nonce field." },
|
||||
"No ntime": { label: "Missing nTime", tip: "The share submission was missing the required ntime field." },
|
||||
"No nonce2": { label: "Missing Nonce2", tip: "The share submission was missing the required extranonce2 field." },
|
||||
"No job_id": { label: "Missing Job ID", tip: "The share submission was missing the required job_id field." },
|
||||
"No username": { label: "Missing Username", tip: "The share submission was missing the worker username." },
|
||||
"Invalid array size": { label: "Bad Params Size", tip: "The mining.submit parameters had the wrong number of elements." },
|
||||
"Params not array": { label: "Bad Params Format", tip: "The mining.submit parameters were not a JSON array." },
|
||||
"Invalid version mask": { label: "Bad Version Mask", tip: "The version rolling mask in the share was invalid." },
|
||||
};
|
||||
|
||||
function fmtReason(raw: string): string {
|
||||
return reasonInfo[raw]?.label ?? raw;
|
||||
}
|
||||
function reasonTip(raw: string): string {
|
||||
return reasonInfo[raw]?.tip ?? "";
|
||||
}
|
||||
|
||||
// --- Rejection reason rows ---
|
||||
type ReasonRow = { reason: string; count: number; pct: number };
|
||||
|
||||
function reasonRows(reasons: Record<string, number> | undefined): ReasonRow[] {
|
||||
if (!reasons) return [];
|
||||
const entries = Object.entries(reasons);
|
||||
const total = entries.reduce((s, [, v]) => s + v, 0);
|
||||
if (total === 0) return [];
|
||||
return entries
|
||||
.map(([reason, count]) => ({ reason, count, pct: (count / total) * 100 }))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
}
|
||||
|
||||
const sessionReasons = $derived(reasonRows(data.reject_reasons_session));
|
||||
const alltimeReasons = $derived(reasonRows(data.reject_reasons_alltime));
|
||||
|
||||
// --- Difficulty distribution ---
|
||||
const bucketLabels = ["< 1M", "1M\u2013100M", "100M\u20131G", "1G\u2013100G", "100G\u20131T", "\u2265 1T"];
|
||||
|
||||
type BucketRow = {
|
||||
label: string;
|
||||
session: number; sessionPct: number;
|
||||
alltime: number; alltimePct: number;
|
||||
};
|
||||
|
||||
const bucketRows = $derived.by((): BucketRow[] => {
|
||||
const sd = data.diff_dist_session ?? [0, 0, 0, 0, 0, 0];
|
||||
const ad = data.diff_dist_alltime ?? [0, 0, 0, 0, 0, 0];
|
||||
const sTotal = sd.reduce((s: number, v: number) => s + v, 0);
|
||||
const aTotal = ad.reduce((s: number, v: number) => s + v, 0);
|
||||
return bucketLabels.map((label, i) => ({
|
||||
label,
|
||||
session: sd[i] ?? 0,
|
||||
sessionPct: sTotal > 0 ? ((sd[i] ?? 0) / sTotal) * 100 : 0,
|
||||
alltime: ad[i] ?? 0,
|
||||
alltimePct: aTotal > 0 ? ((ad[i] ?? 0) / aTotal) * 100 : 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const hasDistData = $derived(
|
||||
bucketRows.some(r => r.session > 0 || r.alltime > 0),
|
||||
);
|
||||
|
||||
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") clearSelection();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={onKey} />
|
||||
|
||||
<section class="page">
|
||||
<nav class="crumbs">
|
||||
<button type="button" class="back" onclick={clearSelection}>
|
||||
← Back to dashboard
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<header class="head">
|
||||
<div class="stat-label">Share Statistics</div>
|
||||
<h2>Difficulty & Rejections</h2>
|
||||
</header>
|
||||
|
||||
<!-- Average Difficulty Cards -->
|
||||
<section class="totals">
|
||||
<div class="card">
|
||||
<div class="stat-label">Session Avg Difficulty</div>
|
||||
<div class="stat-value">{data.avg_diff_session ? formatDifficulty(data.avg_diff_session) : "\u2014"}</div>
|
||||
<div class="stat-sub">{(data.diff_dist_session ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">All-time Avg Difficulty</div>
|
||||
<div class="stat-value">{data.avg_diff_alltime ? formatDifficulty(data.avg_diff_alltime) : "\u2014"}</div>
|
||||
<div class="stat-sub">{(data.diff_dist_alltime ?? []).reduce((s: number, v: number) => s + v, 0).toLocaleString()} accepted shares</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Session Rejected</div>
|
||||
<div class="stat-value">{(data.session_rejected ?? 0).toLocaleString()}</div>
|
||||
<div class="stat-sub">
|
||||
{#if (data.session_accepted ?? 0) > 0}
|
||||
{((data.session_rejected ?? 0) / ((data.session_accepted ?? 0) + (data.session_rejected ?? 0)) * 100).toFixed(2)}% reject rate
|
||||
{:else}
|
||||
no shares yet
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">All-time Rejected</div>
|
||||
<div class="stat-value">{(data.alltime_rejected ?? 0).toLocaleString()}</div>
|
||||
<div class="stat-sub">
|
||||
{#if (data.alltime_accepted ?? 0) > 0}
|
||||
{((data.alltime_rejected ?? 0) / ((data.alltime_accepted ?? 0) + (data.alltime_rejected ?? 0)) * 100).toFixed(2)}% reject rate
|
||||
{:else}
|
||||
no shares yet
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rejection Reasons -->
|
||||
<div class="section-pair">
|
||||
<section class="card">
|
||||
<h3>Rejection Reasons (Session)</h3>
|
||||
{#if sessionReasons.length === 0}
|
||||
<div class="empty">No rejected shares this session</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Reason</th><th class="num">Count</th><th class="num">%</th></tr></thead>
|
||||
<tbody>
|
||||
{#each sessionReasons as r}
|
||||
<tr title={reasonTip(r.reason)}>
|
||||
<td>{fmtReason(r.reason)}</td>
|
||||
<td class="num">{r.count.toLocaleString()}</td>
|
||||
<td class="num">{r.pct.toFixed(1)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="card">
|
||||
<h3>Rejection Reasons (All-time)</h3>
|
||||
{#if alltimeReasons.length === 0}
|
||||
<div class="empty">No rejected shares recorded</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>Reason</th><th class="num">Count</th><th class="num">%</th></tr></thead>
|
||||
<tbody>
|
||||
{#each alltimeReasons as r}
|
||||
<tr title={reasonTip(r.reason)}>
|
||||
<td>{fmtReason(r.reason)}</td>
|
||||
<td class="num">{r.count.toLocaleString()}</td>
|
||||
<td class="num">{r.pct.toFixed(1)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Distribution Table -->
|
||||
<section class="card">
|
||||
<h3>Difficulty Distribution</h3>
|
||||
{#if !hasDistData}
|
||||
<div class="empty">No accepted shares yet</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Range</th>
|
||||
<th class="num">Session</th>
|
||||
<th class="num">%</th>
|
||||
<th class="num">All-time</th>
|
||||
<th class="num">%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each bucketRows as row}
|
||||
<tr class:dim-row={row.session === 0 && row.alltime === 0}>
|
||||
<td>{row.label}</td>
|
||||
<td class="num">{row.session.toLocaleString()}</td>
|
||||
<td class="num">{row.sessionPct.toFixed(1)}%</td>
|
||||
<td class="num">{row.alltime.toLocaleString()}</td>
|
||||
<td class="num">{row.alltimePct.toFixed(1)}%</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
/* Page layout — identical to UserDetailPage / WorkerDetailPage */
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
.crumbs {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.back {
|
||||
font: inherit;
|
||||
color: var(--fg-dim);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.4em 0.85em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.back:hover {
|
||||
color: var(--fg);
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
/* Stat cards — matches .totals / .stats in other pages */
|
||||
.totals {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.totals { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.totals { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.section-pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.section-pair { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
color: var(--fg-dim);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
th {
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--fg-dim);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 0.5em 0.6em;
|
||||
font-size: 0.78em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
td {
|
||||
padding: 0.5em 0.6em;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.num {
|
||||
text-align: right !important;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
tr[title] { cursor: help; }
|
||||
tr[title]:hover td { color: var(--accent); }
|
||||
.dim-row td { opacity: 0.35; }
|
||||
|
||||
</style>
|
||||
@@ -12,6 +12,8 @@
|
||||
const USER_PREFIX = "#/user/";
|
||||
const WORKER_PREFIX = "#/worker/";
|
||||
const ACCELERATOR_HASH = "#/accelerator";
|
||||
const STATS_HASH = "#/stats";
|
||||
const BESTSHARE_HASH = "#/bestshare";
|
||||
|
||||
type Selection = { user: string | null; worker: string | null; page: string | null };
|
||||
|
||||
@@ -23,6 +25,12 @@ function readHash(): Selection {
|
||||
if (h === ACCELERATOR_HASH) {
|
||||
return { user: null, worker: null, page: "accelerator" };
|
||||
}
|
||||
if (h === STATS_HASH) {
|
||||
return { user: null, worker: null, page: "stats" };
|
||||
}
|
||||
if (h === BESTSHARE_HASH) {
|
||||
return { user: null, worker: null, page: "bestshare" };
|
||||
}
|
||||
if (h.startsWith(WORKER_PREFIX)) {
|
||||
const w = decodeURIComponent(h.slice(WORKER_PREFIX.length));
|
||||
return { user: null, worker: w || null, page: null };
|
||||
@@ -57,12 +65,22 @@ export function selectAccelerator(): void {
|
||||
window.location.hash = ACCELERATOR_HASH;
|
||||
}
|
||||
|
||||
export function selectStats(): void {
|
||||
window.location.hash = STATS_HASH;
|
||||
}
|
||||
|
||||
export function selectBestShare(): void {
|
||||
window.location.hash = BESTSHARE_HASH;
|
||||
}
|
||||
|
||||
export function clearSelection(): void {
|
||||
if (
|
||||
window.history.length > 1 &&
|
||||
(window.location.hash.startsWith(USER_PREFIX) ||
|
||||
window.location.hash.startsWith(WORKER_PREFIX) ||
|
||||
window.location.hash === ACCELERATOR_HASH)
|
||||
window.location.hash === ACCELERATOR_HASH ||
|
||||
window.location.hash === STATS_HASH ||
|
||||
window.location.hash === BESTSHARE_HASH)
|
||||
) {
|
||||
window.history.back();
|
||||
} else {
|
||||
|
||||
@@ -103,6 +103,8 @@ export type BlockRecord = {
|
||||
// Bitcoin network the block was mined on ("main", "test", "signet").
|
||||
// Absent for legacy rows recorded before this field was added.
|
||||
chain?: string;
|
||||
// Workername (address.worker) of the miner who found the block.
|
||||
miner?: string;
|
||||
};
|
||||
|
||||
export type HashratePoint = {
|
||||
@@ -122,6 +124,8 @@ export type Snapshot = {
|
||||
hashrate_hs_1h: number;
|
||||
hashrate_hs_24h: number;
|
||||
best_diff: number;
|
||||
best_share_hash?: string;
|
||||
best_share_net_diff?: number;
|
||||
acked_best_diff: number;
|
||||
cumulative_shares: number;
|
||||
next_block_reward_btc: number;
|
||||
@@ -149,6 +153,15 @@ export type Snapshot = {
|
||||
alltime_accepted: number;
|
||||
alltime_rejected: number;
|
||||
|
||||
// Share statistics: rejection reasons and difficulty distribution.
|
||||
reject_reasons_session?: Record<string, number>;
|
||||
reject_reasons_alltime?: Record<string, number>;
|
||||
// Difficulty distribution buckets: [<1M, 1M-100M, 100M-1G, 1G-100G, 100G-1T, >=1T]
|
||||
diff_dist_session: number[];
|
||||
diff_dist_alltime: number[];
|
||||
avg_diff_session: number;
|
||||
avg_diff_alltime: number;
|
||||
|
||||
// Block update latency diagnostics (ZMQ → mining.notify).
|
||||
latency_count: number;
|
||||
latency_avg_ms: number;
|
||||
|
||||
Reference in New Issue
Block a user