User page: hash-routed view, full block hash, aligned rewards
The per-user view was an overlay modal, but users asked for an actual page — focused, scrollable, addressable. Replace the modal with a hash-routed page: selectUser(addr) sets window.location.hash to #/user/<addr> and a hashchange listener syncs the selection state back, so browser back/forward and direct-link refresh all work without a routing library. App renders the dashboard or the UserDetailPage based on selection.user. Make the clickable BTC addresses in the Miners table obviously interactive: accent colour, always-on dashed underline, brighter on hover/focus. The previous styling rendered them as dim static text with a hover underline, so it wasn't obvious they were links. BlocksTable: show the full 64-char block hash as a link to mempool.space (auto-picks testnet4/signet based on snap.chain). word-break: break-all keeps the hash from blowing out the column width. Reward column is split into a tabular-num number span and a dim 0.75em "BTC" unit span, matching the Block reward tile in the overview, so numbers line up cleanly across rows.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
<script lang="ts">
|
||||
import { snap } from "../stores/snapshot.svelte";
|
||||
import { selection, clearSelection } from "../stores/selection.svelte";
|
||||
import {
|
||||
formatHashrate,
|
||||
formatDifficulty,
|
||||
formatAgo,
|
||||
detectHardware,
|
||||
} from "../format";
|
||||
import type { User, Worker, StratumClient } from "../types";
|
||||
|
||||
const address = $derived(selection.user);
|
||||
|
||||
const user = $derived.by<User | null>(() => {
|
||||
if (!address) return null;
|
||||
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null;
|
||||
});
|
||||
|
||||
const workerRows = $derived.by(() => {
|
||||
if (!address) return [];
|
||||
const workers = (snap.data?.workers ?? []).filter((w) => w.user === address);
|
||||
const clients = (snap.data?.clients ?? []).filter(
|
||||
(c: StratumClient) => c.address === address,
|
||||
);
|
||||
const clientByWorker = new Map<string, StratumClient>();
|
||||
for (const c of clients) {
|
||||
const wname = c.workername || `${c.address}.unnamed`;
|
||||
clientByWorker.set(wname, c);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const rows: Array<{
|
||||
worker: string;
|
||||
hardware: string;
|
||||
hashrate1m: number;
|
||||
hashrate1h: number;
|
||||
diff: number;
|
||||
bestRound: number;
|
||||
bestEver: number;
|
||||
lastShare: number;
|
||||
online: boolean;
|
||||
idle: boolean;
|
||||
}> = [];
|
||||
for (const w of workers as Worker[]) {
|
||||
const c = clientByWorker.get(w.worker);
|
||||
seen.add(w.worker);
|
||||
rows.push({
|
||||
worker: w.worker,
|
||||
hardware: c ? detectHardware(c.useragent) : "offline",
|
||||
hashrate1m: (c?.dsps1 ?? w.dsps1) * 2 ** 32,
|
||||
hashrate1h: (c?.dsps60 ?? w.dsps60) * 2 ** 32,
|
||||
diff: c?.diff ?? w.mindiff,
|
||||
bestRound: c?.bestdiff ?? w.bestdiff,
|
||||
bestEver: w.bestever,
|
||||
lastShare: c?.lastshare ?? w.lastshare,
|
||||
online: !!c,
|
||||
idle: !!(c?.idle ?? w.idle),
|
||||
});
|
||||
}
|
||||
for (const c of clients) {
|
||||
const wname = c.workername || `${c.address}.unnamed`;
|
||||
if (seen.has(wname)) continue;
|
||||
rows.push({
|
||||
worker: wname,
|
||||
hardware: detectHardware(c.useragent),
|
||||
hashrate1m: c.dsps1 * 2 ** 32,
|
||||
hashrate1h: c.dsps60 * 2 ** 32,
|
||||
diff: c.diff,
|
||||
bestRound: c.bestdiff,
|
||||
bestEver: 0,
|
||||
lastShare: c.lastshare,
|
||||
online: true,
|
||||
idle: c.idle,
|
||||
});
|
||||
}
|
||||
rows.sort((a, b) => b.hashrate1h - a.hashrate1h);
|
||||
return rows;
|
||||
});
|
||||
|
||||
const totals = $derived.by(() => {
|
||||
if (user) {
|
||||
return {
|
||||
hs1m: user.dsps1 * 2 ** 32,
|
||||
hs5m: user.dsps5 * 2 ** 32,
|
||||
hs1h: user.dsps60 * 2 ** 32,
|
||||
hs24h: user.dsps1440 * 2 ** 32,
|
||||
bestDiff: user.bestdiff,
|
||||
bestEver: user.bestever,
|
||||
workers: user.workers,
|
||||
lastShare: user.lastshare,
|
||||
};
|
||||
}
|
||||
let hs1m = 0, hs1h = 0;
|
||||
let bestDiff = 0, bestEver = 0, lastShare = 0;
|
||||
for (const r of workerRows) {
|
||||
hs1m += r.hashrate1m;
|
||||
hs1h += r.hashrate1h;
|
||||
if (r.bestRound > bestDiff) bestDiff = r.bestRound;
|
||||
if (r.bestEver > bestEver) bestEver = r.bestEver;
|
||||
if (r.lastShare > lastShare) lastShare = r.lastShare;
|
||||
}
|
||||
return {
|
||||
hs1m, hs5m: 0, hs1h, hs24h: 0,
|
||||
bestDiff, bestEver,
|
||||
workers: workerRows.length,
|
||||
lastShare,
|
||||
};
|
||||
});
|
||||
|
||||
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">User</div>
|
||||
<h2 class="addr mono">{address}</h2>
|
||||
</header>
|
||||
|
||||
<section class="totals grid-4">
|
||||
<div class="card">
|
||||
<div class="stat-label">Hashrate</div>
|
||||
<div class="stat-value">{formatHashrate(totals.hs1m)}</div>
|
||||
<div class="stat-sub">
|
||||
{#if totals.hs5m > 0}5m {formatHashrate(totals.hs5m)} · {/if}
|
||||
1h {formatHashrate(totals.hs1h)}
|
||||
{#if totals.hs24h > 0} · 24h {formatHashrate(totals.hs24h)}{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Best share (ever)</div>
|
||||
<div class="stat-value">{formatDifficulty(totals.bestEver || totals.bestDiff)}</div>
|
||||
<div class="stat-sub">round best {formatDifficulty(totals.bestDiff)}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Workers</div>
|
||||
<div class="stat-value">{totals.workers}</div>
|
||||
<div class="stat-sub">{workerRows.filter(r => r.online).length} online</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="stat-label">Last share</div>
|
||||
<div class="stat-value">{formatAgo(totals.lastShare)}</div>
|
||||
<div class="stat-sub">across all this user's workers</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h3>Workers</h3>
|
||||
{#if workerRows.length === 0}
|
||||
<div class="empty">No worker data yet for this address.</div>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Worker</th>
|
||||
<th>Hardware</th>
|
||||
<th class="num">Hashrate (1m)</th>
|
||||
<th class="num">Hashrate (1h)</th>
|
||||
<th class="num">Diff</th>
|
||||
<th class="num">Best (round)</th>
|
||||
<th class="num">Best (ever)</th>
|
||||
<th class="num">Last share</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each workerRows as r (r.worker)}
|
||||
<tr class:offline={!r.online} class:idle={r.idle}>
|
||||
<td class="mono">{r.worker}</td>
|
||||
<td>{r.hardware}</td>
|
||||
<td class="num">{formatHashrate(r.hashrate1m)}</td>
|
||||
<td class="num">{formatHashrate(r.hashrate1h)}</td>
|
||||
<td class="num">{formatDifficulty(r.diff)}</td>
|
||||
<td class="num">{formatDifficulty(r.bestRound)}</td>
|
||||
<td class="num">{formatDifficulty(r.bestEver)}</td>
|
||||
<td class="num">{formatAgo(r.lastShare)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</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);
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4em;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.addr {
|
||||
margin: 0;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty {
|
||||
color: var(--fg-dim);
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
tr.offline {
|
||||
opacity: 0.5;
|
||||
}
|
||||
tr.idle td {
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user