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:
+4
-2
@@ -1,13 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { connect, snap } from "./stores/snapshot.svelte";
|
import { connect, snap } from "./stores/snapshot.svelte";
|
||||||
|
import { selection } from "./stores/selection.svelte";
|
||||||
import Header from "./lib/Header.svelte";
|
import Header from "./lib/Header.svelte";
|
||||||
import PoolOverview from "./lib/PoolOverview.svelte";
|
import PoolOverview from "./lib/PoolOverview.svelte";
|
||||||
import HashrateChart from "./lib/HashrateChart.svelte";
|
import HashrateChart from "./lib/HashrateChart.svelte";
|
||||||
import MinersTable from "./lib/MinersTable.svelte";
|
import MinersTable from "./lib/MinersTable.svelte";
|
||||||
import BlocksTable from "./lib/BlocksTable.svelte";
|
import BlocksTable from "./lib/BlocksTable.svelte";
|
||||||
import BestShares from "./lib/BestShares.svelte";
|
import BestShares from "./lib/BestShares.svelte";
|
||||||
import UserDetailModal from "./lib/UserDetailModal.svelte";
|
import UserDetailPage from "./lib/UserDetailPage.svelte";
|
||||||
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
<div class="stat-sub bad">{snap.error}</div>
|
<div class="stat-sub bad">{snap.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if selection.user}
|
||||||
|
<UserDetailPage />
|
||||||
{:else}
|
{:else}
|
||||||
<PoolOverview />
|
<PoolOverview />
|
||||||
<HashrateChart />
|
<HashrateChart />
|
||||||
@@ -35,7 +38,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<UserDetailModal />
|
|
||||||
<BlockFoundAnimation />
|
<BlockFoundAnimation />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -4,9 +4,17 @@
|
|||||||
|
|
||||||
const blocks = $derived.by(() => {
|
const blocks = $derived.by(() => {
|
||||||
const b = snap.data?.recent_blocks ?? [];
|
const b = snap.data?.recent_blocks ?? [];
|
||||||
// Show newest first.
|
|
||||||
return [...b].reverse();
|
return [...b].reverse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// mempool.space mirror picked per network. If chain isn't known, the
|
||||||
|
// mainnet link still works as a best effort.
|
||||||
|
const explorerBase = $derived.by(() => {
|
||||||
|
const chain = snap.data?.chain?.chain ?? "main";
|
||||||
|
if (chain === "test" || chain === "testnet4") return "https://mempool.space/testnet4";
|
||||||
|
if (chain === "signet") return "https://mempool.space/signet";
|
||||||
|
return "https://mempool.space";
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -14,28 +22,49 @@
|
|||||||
{#if blocks.length === 0}
|
{#if blocks.length === 0}
|
||||||
<div class="empty">No blocks found yet.</div>
|
<div class="empty">No blocks found yet.</div>
|
||||||
{:else}
|
{:else}
|
||||||
<table>
|
<div class="table-wrap">
|
||||||
<thead>
|
<table>
|
||||||
<tr>
|
<thead>
|
||||||
<th>Height</th>
|
|
||||||
<th>Hash</th>
|
|
||||||
<th class="num">Reward</th>
|
|
||||||
<th class="num">Winning share</th>
|
|
||||||
<th class="num">When</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each blocks as b (b.height + "-" + b.found_at)}
|
|
||||||
<tr>
|
<tr>
|
||||||
<td class="mono">{b.height}</td>
|
<th>Height</th>
|
||||||
<td class="hash mono">{b.hash ? b.hash.slice(0, 16) + "…" : "—"}</td>
|
<th>Hash</th>
|
||||||
<td class="num">{b.reward_btc ? b.reward_btc.toFixed(4) + " BTC" : "—"}</td>
|
<th class="num">Reward</th>
|
||||||
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "—"}</td>
|
<th class="num">Winning share</th>
|
||||||
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
|
<th class="num">When</th>
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{#each blocks as b (b.height + "-" + b.found_at)}
|
||||||
|
<tr>
|
||||||
|
<td class="mono">{b.height}</td>
|
||||||
|
<td class="hash-cell">
|
||||||
|
{#if b.hash}
|
||||||
|
<a
|
||||||
|
class="hash mono"
|
||||||
|
href="{explorerBase}/block/{b.hash}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Open block on mempool.space"
|
||||||
|
>{b.hash}</a>
|
||||||
|
{:else}
|
||||||
|
<span class="hash mono">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="num">
|
||||||
|
{#if b.reward_btc}
|
||||||
|
<span class="reward-num">{b.reward_btc.toFixed(4)}</span>
|
||||||
|
<span class="unit">BTC</span>
|
||||||
|
{:else}
|
||||||
|
<span class="reward-num">—</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="num">{b.share_diff ? formatDifficulty(b.share_diff) : "—"}</td>
|
||||||
|
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -49,7 +78,36 @@
|
|||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
.table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.hash-cell {
|
||||||
|
/* Let the 64-char hash wrap inside the cell instead of stretching
|
||||||
|
* the whole table. */
|
||||||
|
max-width: 620px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
.hash {
|
.hash {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.85em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
a.hash {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed transparent;
|
||||||
|
}
|
||||||
|
a.hash:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.reward-num {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.unit {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 400;
|
||||||
|
margin-left: 0.25em;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -138,21 +138,24 @@
|
|||||||
}
|
}
|
||||||
.addr-btn {
|
.addr-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: var(--fg-dim);
|
color: var(--accent);
|
||||||
font-size: 0.8em;
|
font-size: 0.82em;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin-top: 0.1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-bottom: 1px dashed transparent;
|
border-bottom: 1px dashed var(--accent-dim);
|
||||||
|
transition: color 0.15s, border-bottom-color 0.15s;
|
||||||
}
|
}
|
||||||
.addr-btn:hover {
|
.addr-btn:hover,
|
||||||
color: var(--accent);
|
.addr-btn:focus-visible {
|
||||||
|
color: #ff9a5f;
|
||||||
border-bottom-color: var(--accent);
|
border-bottom-color: var(--accent);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
tr.offline {
|
tr.offline {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
|
|||||||
@@ -1,302 +0,0 @@
|
|||||||
<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);
|
|
||||||
|
|
||||||
// User row from snapshot.users matching the selected address.
|
|
||||||
const user = $derived.by<User | null>(() => {
|
|
||||||
if (!address) return null;
|
|
||||||
return (snap.data?.users ?? []).find((u) => u.user === address) ?? null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// All workers + live stratum clients for this user.
|
|
||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Any live client without a matching worker (rare — usually
|
|
||||||
// means workers[] hasn't refreshed yet).
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Per-user aggregates, falling back to summed worker values if the
|
|
||||||
// server hasn't populated the user row yet.
|
|
||||||
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 onBackdrop(ev: MouseEvent): void {
|
|
||||||
if (ev.target === ev.currentTarget) clearSelection();
|
|
||||||
}
|
|
||||||
function onKey(ev: KeyboardEvent): void {
|
|
||||||
if (ev.key === "Escape") clearSelection();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<svelte:window onkeydown={onKey} />
|
|
||||||
|
|
||||||
{#if address}
|
|
||||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
||||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
||||||
<div
|
|
||||||
class="backdrop"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
tabindex="-1"
|
|
||||||
onclick={onBackdrop}
|
|
||||||
>
|
|
||||||
<div class="modal card">
|
|
||||||
<header class="head">
|
|
||||||
<div class="title">
|
|
||||||
<div class="stat-label">User</div>
|
|
||||||
<div class="addr mono">{address}</div>
|
|
||||||
</div>
|
|
||||||
<button class="close" onclick={clearSelection} aria-label="Close">×</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section class="totals grid-3">
|
|
||||||
<div>
|
|
||||||
<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>
|
|
||||||
<div class="stat-label">Best share</div>
|
|
||||||
<div class="stat-value">{formatDifficulty(totals.bestEver || totals.bestDiff)}</div>
|
|
||||||
<div class="stat-sub">round {formatDifficulty(totals.bestDiff)}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="stat-label">Workers</div>
|
|
||||||
<div class="stat-value">{totals.workers}</div>
|
|
||||||
<div class="stat-sub">last share {formatAgo(totals.lastShare)}</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.backdrop {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
background: rgba(3, 6, 10, 0.72);
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 4rem 1rem;
|
|
||||||
z-index: 200;
|
|
||||||
animation: fade 0.2s ease-out;
|
|
||||||
}
|
|
||||||
.modal {
|
|
||||||
width: min(960px, 100%);
|
|
||||||
max-height: calc(100vh - 8rem);
|
|
||||||
overflow: auto;
|
|
||||||
border: 1px solid var(--accent-dim);
|
|
||||||
box-shadow: 0 0 40px rgba(255, 122, 58, 0.18);
|
|
||||||
animation: slide 0.25s ease-out;
|
|
||||||
}
|
|
||||||
.head {
|
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
.title {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
.addr {
|
|
||||||
font-size: 1.05rem;
|
|
||||||
font-weight: 600;
|
|
||||||
word-break: break-all;
|
|
||||||
margin-top: 0.3em;
|
|
||||||
}
|
|
||||||
.close {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--fg-dim);
|
|
||||||
width: 2rem;
|
|
||||||
height: 2rem;
|
|
||||||
padding: 0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.close:hover {
|
|
||||||
color: var(--fg);
|
|
||||||
border-color: var(--accent);
|
|
||||||
}
|
|
||||||
.totals {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
||||||
gap: 1rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-top: 1px solid var(--border);
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.totals {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
margin: 0 0 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
@keyframes fade {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
@keyframes slide {
|
|
||||||
from { transform: translateY(-12px); opacity: 0; }
|
|
||||||
to { transform: translateY(0); opacity: 1; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -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>
|
||||||
@@ -1,13 +1,49 @@
|
|||||||
// Cross-component selection state. Currently just tracks the user
|
// Simple hash-based routing for user pages. The URL hash is the
|
||||||
// whose detail modal is open; App renders the modal off this store
|
// source of truth:
|
||||||
// so any component in the tree can open it by setting `user`.
|
// #/ -> dashboard
|
||||||
|
// #/user/<address> -> per-user page
|
||||||
|
//
|
||||||
|
// selectUser / clearSelection just mutate the hash; a hashchange
|
||||||
|
// listener reads it back into reactive state so any component in the
|
||||||
|
// tree re-renders when the route changes.
|
||||||
|
|
||||||
export const selection = $state<{ user: string | null }>({ user: null });
|
const USER_PREFIX = "#/user/";
|
||||||
|
|
||||||
|
export const selection = $state<{ user: string | null }>({
|
||||||
|
user: readHash(),
|
||||||
|
});
|
||||||
|
|
||||||
|
function readHash(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const h = window.location.hash;
|
||||||
|
if (h.startsWith(USER_PREFIX)) {
|
||||||
|
const addr = decodeURIComponent(h.slice(USER_PREFIX.length));
|
||||||
|
return addr || null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncFromHash(): void {
|
||||||
|
selection.user = readHash();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
window.addEventListener("hashchange", syncFromHash);
|
||||||
|
}
|
||||||
|
|
||||||
export function selectUser(address: string): void {
|
export function selectUser(address: string): void {
|
||||||
selection.user = address;
|
// Set the hash; the hashchange listener updates selection.user so
|
||||||
|
// routing-style state stays in one place.
|
||||||
|
window.location.hash = USER_PREFIX + encodeURIComponent(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearSelection(): void {
|
export function clearSelection(): void {
|
||||||
selection.user = null;
|
// Prefer history.back when we arrived here via selectUser so the
|
||||||
|
// browser back arrow does the obvious thing; otherwise just drop
|
||||||
|
// the hash so a fresh page load starts on the dashboard.
|
||||||
|
if (window.history.length > 1 && window.location.hash.startsWith(USER_PREFIX)) {
|
||||||
|
window.history.back();
|
||||||
|
} else {
|
||||||
|
window.location.hash = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user