Phase 3: Svelte 5 UI skeleton

Svelte 5 + Vite + TypeScript dashboard that consumes kamado-api over
REST for the first paint and then subscribes to /api/ws for live
updates. Zero runtime deps beyond svelte itself; plain CSS, no
component library.

Layout:

- Header with brand, WebSocket status badge, chain + height
- PoolOverview: hashrate (1m/5m/1h/24h), miner count, network
  hashrate + diff, pool share (ppb), expected time to block, uptime
- BlocksTable: recent solves from the in-memory ring (will be
  SQLite-backed in Phase 2b.5)
- BestShares: top-10 workers by bestever, falling back to bestdiff
  when the ckpool patch isn't present
- MinersTable: joined view of stratum clients and workers with
  user-agent-based hardware detection (Bitaxe, NerdQAxe, Antminer,
  ...), hashrate, best-round, best-ever, last share

State is a single $state() snapshot store in svelte-runes form;
components read from it via $derived. The store does one initial
REST snapshot fetch, then owns the WebSocket with exponential
backoff reconnects.

Vite dev server on :5173 proxies /api and /api/ws to localhost:8080
so you can run `make ui-dev` alongside `make up` in development.
Production serving (bundled into the Go binary via embed, behind /
on :8080) lands in Phase 4.
This commit is contained in:
satoshi
2026-04-13 03:07:59 +03:00
parent 36c08647e2
commit d37a23bc57
20 changed files with 966 additions and 2 deletions
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatHashrate, formatDifficulty, formatAgo, detectHardware } from "../format";
import type { StratumClient, Worker } from "../types";
// Join StratumClient (live session data: useragent, IP, diff) with
// Worker (best-share data). Clients without a matching worker still
// render; workers without an active client are dimmed.
type Row = {
workerName: string;
user: string;
hardware: string;
hashrate1m: number;
hashrate1h: number;
bestDiff: number;
bestEver: number;
lastShare: number;
difficulty: number;
address: string;
idle: boolean;
online: boolean;
};
const rows = $derived.by<Row[]>(() => {
const clients = snap.data?.clients ?? [];
const workers = snap.data?.workers ?? [];
const byWorker = new Map<string, Worker>();
for (const w of workers) byWorker.set(w.worker, w);
const seen = new Set<string>();
const out: Row[] = [];
for (const c of clients as StratumClient[]) {
const wname = c.workername || `${c.address}.unnamed`;
seen.add(wname);
const w = byWorker.get(wname);
out.push({
workerName: wname,
user: c.address,
hardware: detectHardware(c.useragent),
hashrate1m: c.dsps1 * 2 ** 32,
hashrate1h: c.dsps60 * 2 ** 32,
bestDiff: c.bestdiff,
bestEver: w?.bestever ?? 0,
lastShare: c.lastshare,
difficulty: c.diff,
address: c.address,
idle: c.idle,
online: true,
});
}
for (const w of workers) {
if (seen.has(w.worker)) continue;
out.push({
workerName: w.worker,
user: w.user,
hardware: "offline",
hashrate1m: w.dsps1 * 2 ** 32,
hashrate1h: w.dsps60 * 2 ** 32,
bestDiff: w.bestdiff,
bestEver: w.bestever,
lastShare: w.lastshare,
difficulty: w.mindiff,
address: w.user,
idle: w.idle,
online: false,
});
}
out.sort((a, b) => b.hashrate1h - a.hashrate1h);
return out;
});
</script>
<section class="card">
<h2>Miners</h2>
{#if rows.length === 0}
<div class="empty">No miners connected.</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 rows as r (r.workerName)}
<tr class:offline={!r.online} class:idle={r.idle}>
<td>
<div class="wname mono">{r.workerName}</div>
<div class="addr">{r.address}</div>
</td>
<td>{r.hardware}</td>
<td class="num">{formatHashrate(r.hashrate1m)}</td>
<td class="num">{formatHashrate(r.hashrate1h)}</td>
<td class="num">{formatDifficulty(r.difficulty)}</td>
<td class="num">{formatDifficulty(r.bestDiff)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td>
<td class="num">{formatAgo(r.lastShare)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</section>
<style>
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.table-wrap {
overflow-x: auto;
}
.empty {
color: var(--fg-dim);
padding: 1rem 0;
}
.wname {
font-size: 0.95em;
}
.addr {
color: var(--fg-dim);
font-size: 0.8em;
font-family: "JetBrains Mono", ui-monospace, monospace;
}
tr.offline {
opacity: 0.4;
}
tr.idle td {
color: var(--fg-dim);
}
</style>