Add per-worker stats page with cumulative work and luck
Expose ckpool's per-worker cumulative shares via patch 0004, add a WorkerDetailPage with hashrate, status, best share with per-worker luck (bestever/shares*100), and total work with pool share percentage. Worker names are now clickable in MinersTable and UserDetailPage. Also centers numeric columns below their headers.
This commit is contained in:
@@ -69,6 +69,7 @@ type Worker struct {
|
|||||||
LastShare int64 `json:"lastshare"`
|
LastShare int64 `json:"lastshare"`
|
||||||
BestDiff float64 `json:"bestdiff"`
|
BestDiff float64 `json:"bestdiff"`
|
||||||
BestEver float64 `json:"bestever"`
|
BestEver float64 `json:"bestever"`
|
||||||
|
Shares int64 `json:"shares"`
|
||||||
MinDiff float64 `json:"mindiff"`
|
MinDiff float64 `json:"mindiff"`
|
||||||
Idle bool `json:"idle"`
|
Idle bool `json:"idle"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
diff --git a/src/stratifier.c b/src/stratifier.c
|
||||||
|
index c94ca645..a1b2c3e4 100644
|
||||||
|
--- a/src/stratifier.c
|
||||||
|
+++ b/src/stratifier.c
|
||||||
|
@@ -4129,11 +4129,12 @@ static json_t *workerinfo(const user_instance_t *user, const worker_instance_t *
|
||||||
|
{
|
||||||
|
json_t *val;
|
||||||
|
|
||||||
|
- JSON_CPACK(val, "{ss,ss,si,sf,sf,sf,sf,si,sf,sf,si,sb}",
|
||||||
|
+ JSON_CPACK(val, "{ss,ss,si,sf,sf,sf,sf,si,sf,sf,sI,si,sb}",
|
||||||
|
"user", user->username, "worker", worker->workername, "id", user->id,
|
||||||
|
"dsps1", worker->dsps1, "dsps5", worker->dsps5, "dsps60", worker->dsps60,
|
||||||
|
"dsps1440", worker->dsps1440, "lastshare", worker->last_share.tv_sec,
|
||||||
|
"bestdiff", worker->best_diff, "bestever", (double)worker->best_ever,
|
||||||
|
+ "shares", (int64_t)worker->shares,
|
||||||
|
"mindiff", worker->mindiff, "idle", worker->idle);
|
||||||
|
return val;
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
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 UserDetailPage from "./lib/UserDetailPage.svelte";
|
import UserDetailPage from "./lib/UserDetailPage.svelte";
|
||||||
|
import WorkerDetailPage from "./lib/WorkerDetailPage.svelte";
|
||||||
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
import BlockFoundAnimation from "./lib/BlockFoundAnimation.svelte";
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
<div class="stat-sub bad">{snap.error}</div>
|
<div class="stat-sub bad">{snap.error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{:else if selection.worker}
|
||||||
|
<WorkerDetailPage />
|
||||||
{:else if selection.user}
|
{:else if selection.user}
|
||||||
<UserDetailPage />
|
<UserDetailPage />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
+1
-1
@@ -140,7 +140,7 @@ tbody tr:hover {
|
|||||||
}
|
}
|
||||||
td.num,
|
td.num,
|
||||||
th.num {
|
th.num {
|
||||||
text-align: right;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { snap } from "../stores/snapshot.svelte";
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
import { selectUser } from "../stores/selection.svelte";
|
import { selectUser, selectWorker } from "../stores/selection.svelte";
|
||||||
import {
|
import {
|
||||||
formatHashrate,
|
formatHashrate,
|
||||||
formatDifficulty,
|
formatDifficulty,
|
||||||
@@ -119,7 +119,12 @@
|
|||||||
<tr class:offline={!r.online} class:idle={r.idle}>
|
<tr class:offline={!r.online} class:idle={r.idle}>
|
||||||
<td>
|
<td>
|
||||||
<div class="wname mono">
|
<div class="wname mono">
|
||||||
{r.workerName}
|
<button
|
||||||
|
type="button"
|
||||||
|
class="worker-btn"
|
||||||
|
onclick={() => selectWorker(r.workerName)}
|
||||||
|
title="View per-worker stats"
|
||||||
|
>{r.workerName}</button>
|
||||||
{#if r.tls}
|
{#if r.tls}
|
||||||
<span class="badge tls" title="Connected via TLS">
|
<span class="badge tls" title="Connected via TLS">
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
@@ -179,6 +184,23 @@
|
|||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.worker-btn {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed transparent;
|
||||||
|
transition: color 0.15s, border-bottom-color 0.15s;
|
||||||
|
}
|
||||||
|
.worker-btn:hover,
|
||||||
|
.worker-btn:focus-visible {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
.addr-btn {
|
.addr-btn {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { snap } from "../stores/snapshot.svelte";
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
import { selection, clearSelection } from "../stores/selection.svelte";
|
import { selection, clearSelection, selectWorker } from "../stores/selection.svelte";
|
||||||
import {
|
import {
|
||||||
formatHashrate,
|
formatHashrate,
|
||||||
formatDifficulty,
|
formatDifficulty,
|
||||||
@@ -223,7 +223,12 @@
|
|||||||
<tr class:offline={!r.online} class:idle={r.idle}>
|
<tr class:offline={!r.online} class:idle={r.idle}>
|
||||||
<td>
|
<td>
|
||||||
<div class="wname mono">
|
<div class="wname mono">
|
||||||
{r.worker}
|
<button
|
||||||
|
type="button"
|
||||||
|
class="worker-btn"
|
||||||
|
onclick={() => selectWorker(r.worker)}
|
||||||
|
title="View per-worker stats"
|
||||||
|
>{r.worker}</button>
|
||||||
{#if r.tls}
|
{#if r.tls}
|
||||||
<span class="badge tls" title="Connected via TLS">
|
<span class="badge tls" title="Connected via TLS">
|
||||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
@@ -350,6 +355,23 @@
|
|||||||
gap: 0.4em;
|
gap: 0.4em;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.worker-btn {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px dashed transparent;
|
||||||
|
transition: color 0.15s, border-bottom-color 0.15s;
|
||||||
|
}
|
||||||
|
.worker-btn:hover,
|
||||||
|
.worker-btn:focus-visible {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
.src-ip {
|
.src-ip {
|
||||||
color: var(--fg-dim);
|
color: var(--fg-dim);
|
||||||
font-size: 0.75em;
|
font-size: 0.75em;
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { snap } from "../stores/snapshot.svelte";
|
||||||
|
import { selection, clearSelection, selectUser } from "../stores/selection.svelte";
|
||||||
|
import {
|
||||||
|
formatHashrate,
|
||||||
|
formatDifficulty,
|
||||||
|
formatWork,
|
||||||
|
formatAgo,
|
||||||
|
detectHardware,
|
||||||
|
isOpenSource,
|
||||||
|
btcAddressOf,
|
||||||
|
} from "../format";
|
||||||
|
import type { Worker, StratumClient } from "../types";
|
||||||
|
|
||||||
|
const workername = $derived(selection.worker);
|
||||||
|
const btcAddress = $derived(workername ? btcAddressOf(workername) : "");
|
||||||
|
|
||||||
|
const worker = $derived.by<Worker | null>(() => {
|
||||||
|
if (!workername) return null;
|
||||||
|
return (snap.data?.workers ?? []).find((w) => w.worker === workername) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = $derived.by<StratumClient | null>(() => {
|
||||||
|
if (!workername) return null;
|
||||||
|
return (snap.data?.clients ?? []).find((c) => c.workername === workername) ?? null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const online = $derived(!!client);
|
||||||
|
const idle = $derived(client?.idle ?? worker?.idle ?? false);
|
||||||
|
const hardware = $derived(client ? detectHardware(client.useragent) : "offline");
|
||||||
|
const openSource = $derived(client ? isOpenSource(client.useragent) : false);
|
||||||
|
const tls = $derived(client?.server === 1);
|
||||||
|
|
||||||
|
const hs1m = $derived(client ? client.dsps1 * 2 ** 32 : 0);
|
||||||
|
const hs5m = $derived(client ? client.dsps5 * 2 ** 32 : 0);
|
||||||
|
const hs1h = $derived(client ? client.dsps60 * 2 ** 32 : 0);
|
||||||
|
const hs24h = $derived(client ? client.dsps1440 * 2 ** 32 : 0);
|
||||||
|
|
||||||
|
const bestEver = $derived(worker?.bestever ?? 0);
|
||||||
|
const bestSession = $derived(client?.bestdiff ?? 0);
|
||||||
|
const lastShare = $derived(client?.lastshare ?? worker?.lastshare ?? 0);
|
||||||
|
|
||||||
|
// Per-worker cumulative work (difficulty shares accepted)
|
||||||
|
const workerShares = $derived(worker?.shares ?? 0);
|
||||||
|
const workerHashes = $derived(workerShares * 2 ** 32);
|
||||||
|
|
||||||
|
// Per-worker luck: bestever / cumulative shares * 100
|
||||||
|
const luckPct = $derived.by(() => {
|
||||||
|
if (!bestEver || !workerShares || workerShares <= 0) return 0;
|
||||||
|
return (bestEver / workerShares) * 100;
|
||||||
|
});
|
||||||
|
const luckLabel = $derived.by(() => {
|
||||||
|
if (!bestEver || !workerShares) return "\u2014";
|
||||||
|
if (luckPct >= 1000) return `${(luckPct / 100).toFixed(1)}x`;
|
||||||
|
return luckPct.toFixed(0) + "%";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Share of pool's total work contributed by this worker
|
||||||
|
const poolShares = $derived(snap.data?.cumulative_shares ?? 0);
|
||||||
|
const workShare = $derived.by(() => {
|
||||||
|
if (!workerShares || !poolShares || poolShares <= 0) return 0;
|
||||||
|
return (workerShares / poolShares) * 100;
|
||||||
|
});
|
||||||
|
|
||||||
|
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">Worker</div>
|
||||||
|
<h2 class="wname mono">{workername}</h2>
|
||||||
|
<div class="head-meta">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="addr-btn mono"
|
||||||
|
onclick={() => selectUser(btcAddress)}
|
||||||
|
title="View per-user stats"
|
||||||
|
>{btcAddress}</button>
|
||||||
|
{#if tls}
|
||||||
|
<span class="badge tls" title="Connected via TLS">
|
||||||
|
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||||
|
<path d="M8 1a3.5 3.5 0 0 0-3.5 3.5V7H4a1.5 1.5 0 0 0-1.5 1.5v5A1.5 1.5 0 0 0 4 15h8a1.5 1.5 0 0 0 1.5-1.5v-5A1.5 1.5 0 0 0 12 7h-.5V4.5A3.5 3.5 0 0 0 8 1Zm2 6H6V4.5a2 2 0 1 1 4 0V7Z"/>
|
||||||
|
</svg>
|
||||||
|
<span>TLS</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if openSource}
|
||||||
|
<span class="oss" title="Open source hardware or firmware">★</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="stats">
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Hashrate</div>
|
||||||
|
<div class="stat-value">{formatHashrate(hs1m)}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
5m {formatHashrate(hs5m)} ·
|
||||||
|
1h {formatHashrate(hs1h)} ·
|
||||||
|
24h {formatHashrate(hs24h)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Status</div>
|
||||||
|
<div class="stat-value">
|
||||||
|
{#if !online}
|
||||||
|
<span class="status-text offline">Offline</span>
|
||||||
|
{:else if idle}
|
||||||
|
<span class="status-text idle">Idle</span>
|
||||||
|
{:else}
|
||||||
|
<span class="status-text online">Online</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
{hardware}
|
||||||
|
{#if client?.address}
|
||||||
|
· {client.address}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Best share</div>
|
||||||
|
<div class="stat-value">{formatDifficulty(bestEver)}</div>
|
||||||
|
<div
|
||||||
|
class="luck-sub"
|
||||||
|
class:lucky={luckPct > 100}
|
||||||
|
class:unlucky={luckPct > 0 && luckPct < 100}
|
||||||
|
title="Luck = best share / total work for this worker. 100% means your best share matches the work done. Above 100% is lucky, below is unlucky."
|
||||||
|
>
|
||||||
|
<span class="luck-value">{luckLabel}</span>
|
||||||
|
<span class="luck-label">luck</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="stat-label">Total work</div>
|
||||||
|
<div class="stat-value">{formatWork(workerHashes)}</div>
|
||||||
|
<div class="stat-sub">
|
||||||
|
{workShare.toFixed(1)}% of pool ·
|
||||||
|
last share {formatAgo(lastShare)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h3>Session</h3>
|
||||||
|
{#if !online}
|
||||||
|
<div class="empty">Worker is offline. Last seen {formatAgo(lastShare)}.</div>
|
||||||
|
{:else}
|
||||||
|
<div class="detail-grid">
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Session best</span>
|
||||||
|
<span class="detail-value">{formatDifficulty(bestSession)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Current diff</span>
|
||||||
|
<span class="detail-value">{formatDifficulty(client?.diff ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<span class="detail-label">Min diff</span>
|
||||||
|
<span class="detail-value">{formatDifficulty(worker?.mindiff ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
{#if client?.useragent}
|
||||||
|
<div class="detail-item wide">
|
||||||
|
<span class="detail-label">User agent</span>
|
||||||
|
<span class="detail-value mono ua">{client.useragent}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
.wname {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.head-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6em;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.addr-btn {
|
||||||
|
color: var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.9em;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed var(--accent-dim);
|
||||||
|
}
|
||||||
|
.addr-btn:hover {
|
||||||
|
color: #ff9a5f;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
.badge.tls {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3em;
|
||||||
|
font-size: 0.72em;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--good);
|
||||||
|
border: 1px solid #3a7558;
|
||||||
|
background: #112e22;
|
||||||
|
padding: 0.15em 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.badge.tls svg {
|
||||||
|
width: 0.9em;
|
||||||
|
height: 0.9em;
|
||||||
|
fill: currentColor;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.oss {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.1em;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
@media (max-width: 520px) {
|
||||||
|
.stats { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.status-text {
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.status-text.online { color: var(--good); }
|
||||||
|
.status-text.idle { color: var(--warn, #e6c455); }
|
||||||
|
.status-text.offline { color: var(--fg-dim); }
|
||||||
|
|
||||||
|
.luck-sub {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.4em;
|
||||||
|
margin-top: 0.45em;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
.luck-value {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.luck-label {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 400;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.luck-sub.lucky .luck-value {
|
||||||
|
color: var(--good);
|
||||||
|
text-shadow: 0 0 10px rgba(92, 224, 168, 0.35);
|
||||||
|
}
|
||||||
|
.luck-sub.unlucky .luck-value {
|
||||||
|
color: var(--bad);
|
||||||
|
text-shadow: 0 0 10px rgba(255, 107, 107, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.empty {
|
||||||
|
color: var(--fg-dim);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
.detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
.detail-item.wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.detail-grid { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
.detail-label {
|
||||||
|
display: block;
|
||||||
|
color: var(--fg-dim);
|
||||||
|
font-size: 0.82em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
.detail-value {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.ua {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,30 +1,38 @@
|
|||||||
// Simple hash-based routing for user pages. The URL hash is the
|
// Simple hash-based routing for detail pages. The URL hash is the
|
||||||
// source of truth:
|
// source of truth:
|
||||||
// #/ -> dashboard
|
// #/ -> dashboard
|
||||||
// #/user/<address> -> per-user page
|
// #/user/<address> -> per-user page
|
||||||
|
// #/worker/<workername> -> per-worker page
|
||||||
//
|
//
|
||||||
// selectUser / clearSelection just mutate the hash; a hashchange
|
// selectUser / selectWorker / clearSelection just mutate the hash;
|
||||||
// listener reads it back into reactive state so any component in the
|
// a hashchange listener reads it back into reactive state so any
|
||||||
// tree re-renders when the route changes.
|
// component in the tree re-renders when the route changes.
|
||||||
|
|
||||||
const USER_PREFIX = "#/user/";
|
const USER_PREFIX = "#/user/";
|
||||||
|
const WORKER_PREFIX = "#/worker/";
|
||||||
|
|
||||||
export const selection = $state<{ user: string | null }>({
|
type Selection = { user: string | null; worker: string | null };
|
||||||
user: readHash(),
|
|
||||||
});
|
|
||||||
|
|
||||||
function readHash(): string | null {
|
export const selection = $state<Selection>(readHash());
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
|
function readHash(): Selection {
|
||||||
|
if (typeof window === "undefined") return { user: null, worker: null };
|
||||||
const h = window.location.hash;
|
const h = window.location.hash;
|
||||||
if (h.startsWith(USER_PREFIX)) {
|
if (h.startsWith(WORKER_PREFIX)) {
|
||||||
const addr = decodeURIComponent(h.slice(USER_PREFIX.length));
|
const w = decodeURIComponent(h.slice(WORKER_PREFIX.length));
|
||||||
return addr || null;
|
return { user: null, worker: w || null };
|
||||||
}
|
}
|
||||||
return null;
|
if (h.startsWith(USER_PREFIX)) {
|
||||||
|
const u = decodeURIComponent(h.slice(USER_PREFIX.length));
|
||||||
|
return { user: u || null, worker: null };
|
||||||
|
}
|
||||||
|
return { user: null, worker: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncFromHash(): void {
|
function syncFromHash(): void {
|
||||||
selection.user = readHash();
|
const parsed = readHash();
|
||||||
|
selection.user = parsed.user;
|
||||||
|
selection.worker = parsed.worker;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
@@ -32,16 +40,19 @@ if (typeof window !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function selectUser(address: string): void {
|
export function selectUser(address: string): void {
|
||||||
// Set the hash; the hashchange listener updates selection.user so
|
|
||||||
// routing-style state stays in one place.
|
|
||||||
window.location.hash = USER_PREFIX + encodeURIComponent(address);
|
window.location.hash = USER_PREFIX + encodeURIComponent(address);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selectWorker(workername: string): void {
|
||||||
|
window.location.hash = WORKER_PREFIX + encodeURIComponent(workername);
|
||||||
|
}
|
||||||
|
|
||||||
export function clearSelection(): void {
|
export function clearSelection(): void {
|
||||||
// Prefer history.back when we arrived here via selectUser so the
|
if (
|
||||||
// browser back arrow does the obvious thing; otherwise just drop
|
window.history.length > 1 &&
|
||||||
// the hash so a fresh page load starts on the dashboard.
|
(window.location.hash.startsWith(USER_PREFIX) ||
|
||||||
if (window.history.length > 1 && window.location.hash.startsWith(USER_PREFIX)) {
|
window.location.hash.startsWith(WORKER_PREFIX))
|
||||||
|
) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
window.location.hash = "";
|
window.location.hash = "";
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export type Worker = {
|
|||||||
lastshare: number;
|
lastshare: number;
|
||||||
bestdiff: number;
|
bestdiff: number;
|
||||||
bestever: number;
|
bestever: number;
|
||||||
|
shares: number;
|
||||||
mindiff: number;
|
mindiff: number;
|
||||||
idle: boolean;
|
idle: boolean;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user