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
+13 -1
View File
@@ -1,13 +1,16 @@
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Kamado Pool — top-level Makefile # Kamado Pool — top-level Makefile
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
.PHONY: help ckpool api api-test ckpool-shell up down logs clean .PHONY: help ckpool api api-test ckpool-shell ui ui-dev ui-check up down logs clean
help: help:
@echo "Kamado Pool targets:" @echo "Kamado Pool targets:"
@echo " make ckpool Build the ckpool-solo docker image" @echo " make ckpool Build the ckpool-solo docker image"
@echo " make api Build the kamado-api docker image" @echo " make api Build the kamado-api docker image"
@echo " make api-test Run Go tests for kamado-api (requires Go installed)" @echo " make api-test Run Go tests for kamado-api (requires Go installed)"
@echo " make ui Build the Svelte UI to ui/dist (requires node)"
@echo " make ui-dev Run the Vite dev server on :5173 with /api proxy"
@echo " make ui-check Run svelte-check type diagnostics"
@echo " make ckpool-shell Open a shell in the built ckpool image" @echo " make ckpool-shell Open a shell in the built ckpool image"
@echo " make up docker compose up -d --build" @echo " make up docker compose up -d --build"
@echo " make down docker compose down" @echo " make down docker compose down"
@@ -23,6 +26,15 @@ api:
api-test: api-test:
cd api && go test ./... -race cd api && go test ./... -race
ui:
cd ui && npm install && npm run build
ui-dev:
cd ui && npm install && npm run dev
ui-check:
cd ui && npm install && npm run check
ckpool-shell: ckpool ckpool-shell: ckpool
docker run --rm -it --entrypoint /bin/sh kamado/ckpool:dev docker run --rm -it --entrypoint /bin/sh kamado/ckpool:dev
+2 -1
View File
@@ -42,7 +42,8 @@ Three main components:
- [x] Phase 2a: CKPool socket client, bitcoind RPC, state aggregator, REST API - [x] Phase 2a: CKPool socket client, bitcoind RPC, state aggregator, REST API
- [x] Phase 2b: CKPool log tailer, block history, stdlib WebSocket push - [x] Phase 2b: CKPool log tailer, block history, stdlib WebSocket push
- [ ] Phase 2b.5: ZMQ block notifier, SQLite persistence (deferred until s9pk repo exists — need real Go build env for new deps) - [ ] Phase 2b.5: ZMQ block notifier, SQLite persistence (deferred until s9pk repo exists — need real Go build env for new deps)
- [ ] **Phase 3** — Svelte UI dashboard - [x] ckpool patch 0001: expose `bestever` in runtime socket JSON so the UI can show "this round" and "all-time" best share side by side
- [~] **Phase 3** — Svelte UI dashboard (skeleton: header, pool overview, miners table, blocks, best shares leaderboard; live WS updates)
- [ ] **Phase 4** — Monorepo Docker build, full stack integration - [ ] **Phase 4** — Monorepo Docker build, full stack integration
- [ ] **Phase 5** — Testing (regtest, testnet4), polish - [ ] **Phase 5** — Testing (regtest, testnet4), polish
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.vite/
*.log
.DS_Store
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark light" />
<title>Kamado Pool</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+21
View File
@@ -0,0 +1,21 @@
{
"name": "kamado-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "Kamado Pool dashboard — Svelte 5 + Vite, talks to kamado-api over REST and WebSocket.",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tsconfig/svelte": "^5.0.4",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.6.0",
"vite": "^5.4.0"
}
}
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import { onMount } from "svelte";
import { connect, snap } from "./stores/snapshot.svelte";
import Header from "./lib/Header.svelte";
import PoolOverview from "./lib/PoolOverview.svelte";
import MinersTable from "./lib/MinersTable.svelte";
import BlocksTable from "./lib/BlocksTable.svelte";
import BestShares from "./lib/BestShares.svelte";
onMount(() => {
connect();
});
</script>
<main>
<Header />
{#if !snap.data}
<div class="card placeholder">
<div class="stat-label">Status</div>
<div class="stat-value">Connecting to kamado-api…</div>
{#if snap.error}
<div class="stat-sub bad">{snap.error}</div>
{/if}
</div>
{:else}
<PoolOverview />
<div class="grid grid-2">
<BlocksTable />
<BestShares />
</div>
<MinersTable />
{/if}
</main>
<style>
main {
max-width: 1280px;
margin: 0 auto;
padding: 1.5rem 1.25rem 4rem;
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.placeholder {
text-align: center;
}
.bad {
color: var(--bad);
}
</style>
+151
View File
@@ -0,0 +1,151 @@
:root {
color-scheme: dark light;
--bg: #0b0e14;
--bg-alt: #11151d;
--bg-card: #151a24;
--bg-hover: #1c2230;
--border: #232a3a;
--fg: #e6e9ef;
--fg-dim: #8a92a6;
--accent: #ff7a3a;
--accent-dim: #b8501f;
--good: #5ce0a8;
--bad: #ff6b6b;
--warn: #f5c447;
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
}
body {
font-variant-numeric: tabular-nums;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
button {
font: inherit;
color: inherit;
background: var(--bg-card);
border: 1px solid var(--border);
padding: 0.5em 1em;
border-radius: 6px;
cursor: pointer;
}
button:hover {
background: var(--bg-hover);
}
code,
.mono {
font-family: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular,
Menlo, monospace;
font-size: 0.92em;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
}
.grid {
display: grid;
gap: 1rem;
}
.grid-4 {
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
}
.stat-label {
color: var(--fg-dim);
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.35em;
}
.stat-value {
font-size: 1.7rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.stat-sub {
color: var(--fg-dim);
font-size: 0.85em;
margin-top: 0.25em;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: 0.55rem 0.85rem;
border-bottom: 1px solid var(--border);
}
th {
font-weight: 500;
color: var(--fg-dim);
font-size: 0.82em;
text-transform: uppercase;
letter-spacing: 0.04em;
}
tbody tr:hover {
background: var(--bg-hover);
}
td.num,
th.num {
text-align: right;
}
.badge {
display: inline-block;
padding: 0.15em 0.55em;
border-radius: 999px;
font-size: 0.75em;
border: 1px solid var(--border);
background: var(--bg-alt);
}
.badge.good {
color: var(--good);
border-color: #2f5c48;
background: #0f2419;
}
.badge.bad {
color: var(--bad);
border-color: #5c2f2f;
background: #241010;
}
.badge.warn {
color: var(--warn);
border-color: #5c502f;
background: #241f10;
}
+108
View File
@@ -0,0 +1,108 @@
// Formatters for hashrate, share difficulty, time, and miner hardware
// detection from stratum user-agent strings. Pure functions, no DOM.
const HASHRATE_UNITS = ["H/s", "kH/s", "MH/s", "GH/s", "TH/s", "PH/s", "EH/s"];
export function formatHashrate(hs: number): string {
if (!hs || hs <= 0 || !isFinite(hs)) return "0 H/s";
let i = 0;
let v = hs;
while (v >= 1000 && i < HASHRATE_UNITS.length - 1) {
v /= 1000;
i++;
}
const digits = v >= 100 ? 0 : v >= 10 ? 1 : 2;
return `${v.toFixed(digits)} ${HASHRATE_UNITS[i]}`;
}
// Difficulty is a share-diff value — scale with k/M/G/T suffixes.
export function formatDifficulty(d: number): string {
if (!d || d <= 0 || !isFinite(d)) return "0";
const units = ["", "k", "M", "G", "T", "P"];
let i = 0;
let v = d;
while (v >= 1000 && i < units.length - 1) {
v /= 1000;
i++;
}
const digits = v >= 100 ? 1 : 2;
return `${v.toFixed(digits)}${units[i]}`;
}
export function formatUptime(seconds: number): string {
if (!seconds || seconds < 0) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
export function formatAgo(unixSeconds: number): string {
if (!unixSeconds) return "never";
const ageSec = Math.max(0, Date.now() / 1000 - unixSeconds);
if (ageSec < 60) return `${Math.floor(ageSec)}s ago`;
if (ageSec < 3600) return `${Math.floor(ageSec / 60)}m ago`;
if (ageSec < 86400) return `${Math.floor(ageSec / 3600)}h ago`;
return `${Math.floor(ageSec / 86400)}d ago`;
}
// Detect common Bitcoin mining hardware from the stratum useragent
// string. This is a best-effort heuristic; unknown agents fall back
// to a stripped version of the raw string. Order matters — check
// more specific tokens before generic ones.
export function detectHardware(ua: string): string {
if (!ua) return "unknown";
const s = ua.toLowerCase();
const rules: Array<[RegExp, string]> = [
[/bitaxe/, "Bitaxe"],
[/nerdqaxe/, "NerdQAxe+"],
[/nerdaxe/, "NerdAxe"],
[/nerdminer/, "NerdMiner"],
[/nerdoctaxe/, "NerdOctaxe"],
[/mccm/, "MCCM"],
[/lucky(?:miner)?/, "Lucky Miner"],
[/qaxe/, "QAxe"],
[/antminer/, "Antminer"],
[/whatsminer/, "Whatsminer"],
[/avalon/, "Avalon"],
[/cgminer/, "cgminer"],
[/bfgminer/, "bfgminer"],
[/bmminer/, "BMMiner"],
[/ckminer/, "ckminer"],
[/braiins/, "Braiins OS"],
[/micro[- ]?bt/, "MicroBT"],
[/esp32/, "ESP32"],
[/s9/, "Antminer S9"],
];
for (const [re, label] of rules) {
if (re.test(s)) return label;
}
// Fall back to the first whitespace-free token, truncated.
const token = ua.split(/\s+/)[0] ?? ua;
return token.length > 24 ? token.slice(0, 24) + "…" : token;
}
// Rough expected time to find a block, in seconds, given pool and
// network hashrate. Returns Infinity (displayed as "∞") if the pool
// isn't mining.
export function expectedBlockSeconds(
poolHs: number,
networkHs: number,
networkDifficulty: number,
): number {
if (!poolHs || poolHs <= 0) return Infinity;
// Expected hashes per block = difficulty * 2^32.
const hashesPerBlock = networkDifficulty * 2 ** 32;
return hashesPerBlock / poolHs;
}
export function formatDuration(seconds: number): string {
if (!isFinite(seconds)) return "∞";
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
if (seconds < 31536000) return `${(seconds / 86400).toFixed(1)}d`;
return `${(seconds / 31536000).toFixed(1)}y`;
}
+2
View File
@@ -0,0 +1,2 @@
/// <reference types="svelte" />
/// <reference types="vite/client" />
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatDifficulty } from "../format";
// Top 10 workers by all-time best share. `bestever` requires the
// Kamado ckpool patch (0001-expose-bestever-in-runtime-json.patch)
// — on an unpatched ckpool it will be zero everywhere and we fall
// back to the current round's best diff.
const rows = $derived.by(() => {
const ws = snap.data?.workers ?? [];
const enriched = ws.map((w) => ({
worker: w.worker,
bestRound: w.bestdiff,
bestEver: w.bestever || w.bestdiff,
}));
enriched.sort((a, b) => b.bestEver - a.bestEver);
return enriched.slice(0, 10);
});
</script>
<section class="card">
<h2>Best shares leaderboard</h2>
{#if rows.length === 0}
<div class="empty">No shares submitted yet.</div>
{:else}
<table>
<thead>
<tr>
<th>#</th>
<th>Worker</th>
<th class="num">Best (round)</th>
<th class="num">Best (ever)</th>
</tr>
</thead>
<tbody>
{#each rows as r, i (r.worker)}
<tr>
<td>{i + 1}</td>
<td class="mono">{r.worker}</td>
<td class="num">{formatDifficulty(r.bestRound)}</td>
<td class="num">{formatDifficulty(r.bestEver)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 0.5rem 0;
}
</style>
+56
View File
@@ -0,0 +1,56 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import { formatAgo } from "../format";
const blocks = $derived.by(() => {
const b = snap.data?.recent_blocks ?? [];
// Show newest first.
return [...b].reverse();
});
</script>
<section class="card">
<h2>Blocks found</h2>
{#if blocks.length === 0}
<div class="empty">
No blocks found yet. Any solve will appear here instantly via the
log tailer, and persist to SQLite once Phase 2b.5 ships.
</div>
{:else}
<table>
<thead>
<tr>
<th>Height</th>
<th>Hash</th>
<th class="num">When</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{#each blocks as b (b.height + "-" + b.found_at)}
<tr>
<td class="mono">{b.height}</td>
<td class="hash mono">{b.hash ? b.hash.slice(0, 16) + "…" : "—"}</td>
<td class="num">{formatAgo(new Date(b.found_at).getTime() / 1000)}</td>
<td>{b.source}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</section>
<style>
h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
font-weight: 600;
}
.empty {
color: var(--fg-dim);
padding: 0.5rem 0;
}
.hash {
color: var(--fg-dim);
}
</style>
+50
View File
@@ -0,0 +1,50 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
const statusClass = $derived.by(() => {
if (snap.status === "open") return "good";
if (snap.status === "connecting") return "warn";
return "bad";
});
const chain = $derived(snap.data?.chain?.chain ?? "—");
const height = $derived(snap.data?.chain?.blocks ?? 0);
</script>
<header class="bar">
<div class="brand">
<span class="logo">🔥</span>
<span class="name">Kamado Pool</span>
</div>
<div class="meta">
<span class="badge {statusClass}">ws: {snap.status}</span>
<span class="badge">{chain}</span>
<span class="badge">height {height}</span>
</div>
</header>
<style>
.bar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.01em;
}
.logo {
font-size: 1.4rem;
}
.meta {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
</style>
+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>
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import { snap } from "../stores/snapshot.svelte";
import {
formatHashrate,
formatUptime,
expectedBlockSeconds,
formatDuration,
} from "../format";
const data = $derived(snap.data!);
const miners = $derived(data.clients?.length ?? 0);
const workerCount = $derived(data.workers?.length ?? 0);
// Pool share of the network hashrate, used both for display and for
// the expected-block calculation. Network diff comes from bitcoind.
const poolShare = $derived.by(() => {
const net = data.network_hashrate_hs;
if (!net || net <= 0) return 0;
return data.hashrate_hs_1h / net;
});
const expected = $derived.by(() => {
const diff = data.chain?.difficulty ?? 0;
return expectedBlockSeconds(data.hashrate_hs_1h, data.network_hashrate_hs, diff);
});
</script>
<section class="grid grid-4">
<div class="card">
<div class="stat-label">Hashrate (1h)</div>
<div class="stat-value">{formatHashrate(data.hashrate_hs_1h)}</div>
<div class="stat-sub">
1m {formatHashrate(data.hashrate_hs_1m)} · 5m {formatHashrate(data.hashrate_hs_5m)}
· 24h {formatHashrate(data.hashrate_hs_24h)}
</div>
</div>
<div class="card">
<div class="stat-label">Miners</div>
<div class="stat-value">{miners}</div>
<div class="stat-sub">{workerCount} workers · {data.users?.length ?? 0} users</div>
</div>
<div class="card">
<div class="stat-label">Network</div>
<div class="stat-value">{formatHashrate(data.network_hashrate_hs)}</div>
<div class="stat-sub">
diff {data.chain?.difficulty.toExponential(2) ?? "—"} ·
share {(poolShare * 1e9).toFixed(2)} ppb
</div>
</div>
<div class="card">
<div class="stat-label">Expected block</div>
<div class="stat-value">{formatDuration(expected)}</div>
<div class="stat-sub">uptime {formatUptime(data.uptime_seconds)}</div>
</div>
</section>
+6
View File
@@ -0,0 +1,6 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
const app = mount(App, { target: document.getElementById("app")! });
export default app;
+75
View File
@@ -0,0 +1,75 @@
// Global snapshot store. Holds the latest Snapshot from kamado-api.
// Uses Svelte 5 runes ($state) so any component that imports `snap`
// re-renders automatically.
//
// Lifecycle:
// - connect() fetches the initial snapshot via REST so the UI has
// data to render before the socket is open.
// - then opens /api/ws and overwrites the state on every frame.
// - on close, backs off and reconnects. No jitter needed for now.
import type { Snapshot } from "../types";
type Status = "connecting" | "open" | "closed";
export const snap = $state<{
data: Snapshot | null;
status: Status;
error: string | null;
}>({
data: null,
status: "connecting",
error: null,
});
let socket: WebSocket | null = null;
let retryMs = 1000;
export async function connect(): Promise<void> {
// Initial REST fetch — populates the first paint and tells us if
// the API is reachable at all before we commit to a WebSocket.
try {
const res = await fetch("/api/snapshot", { cache: "no-store" });
if (res.ok) {
snap.data = (await res.json()) as Snapshot;
snap.error = null;
} else {
snap.error = `snapshot HTTP ${res.status}`;
}
} catch (err) {
snap.error = `snapshot fetch failed: ${err}`;
}
openSocket();
}
function openSocket(): void {
snap.status = "connecting";
const proto = location.protocol === "https:" ? "wss:" : "ws:";
const url = `${proto}//${location.host}/api/ws`;
socket = new WebSocket(url);
socket.addEventListener("open", () => {
snap.status = "open";
snap.error = null;
retryMs = 1000;
});
socket.addEventListener("message", (ev) => {
try {
snap.data = JSON.parse(ev.data as string) as Snapshot;
} catch (err) {
console.error("snapshot parse failed", err);
}
});
socket.addEventListener("close", () => {
snap.status = "closed";
socket = null;
setTimeout(openSocket, retryMs);
retryMs = Math.min(retryMs * 2, 15000);
});
socket.addEventListener("error", () => {
snap.error = "websocket error";
});
}
+107
View File
@@ -0,0 +1,107 @@
// Mirrors api/internal/state.Snapshot. Keep field names aligned; the
// Go side is authoritative.
export type PoolStats = {
start: number;
update: number;
workers: number;
users: number;
disconnected: number;
shares: number;
accepted: number;
rejected: number;
dsps1: number;
dsps5: number;
dsps15: number;
dsps60: number;
dsps360: number;
dsps1440: number;
dsps10080: number;
};
export type User = {
user: string;
id: number;
workers: number;
bestdiff: number;
bestever: number;
dsps1: number;
dsps5: number;
dsps60: number;
dsps1440: number;
dsps10080: number;
lastshare: number;
};
export type Worker = {
user: string;
worker: string;
id: number;
dsps1: number;
dsps5: number;
dsps60: number;
dsps1440: number;
lastshare: number;
bestdiff: number;
bestever: number;
mindiff: number;
idle: boolean;
};
export type StratumClient = {
id: number;
enonce1: string;
diff: number;
dsps1: number;
dsps5: number;
dsps60: number;
dsps1440: number;
lastshare: number;
starttime: number;
address: string;
subscribed: boolean;
authorised: boolean;
idle: boolean;
useragent: string;
workername: string;
userid: number;
bestdiff: number;
};
export type BlockchainInfo = {
chain: string;
blocks: number;
headers: number;
bestblockhash: string;
difficulty: number;
mediantime: number;
verificationprogress: number;
initialblockdownload: boolean;
};
export type BlockRecord = {
height: number;
hash?: string;
reward_btc?: number;
found_at: string; // RFC 3339
source: string;
};
export type Snapshot = {
generated_at: string;
pool: PoolStats | null;
uptime_seconds: number;
users: User[] | null;
workers: Worker[] | null;
clients: StratumClient[] | null;
hashrate_hs_1m: number;
hashrate_hs_5m: number;
hashrate_hs_1h: number;
hashrate_hs_24h: number;
chain: BlockchainInfo | null;
network_hashrate_hs: number;
recent_blocks?: BlockRecord[];
ckpool_ok: boolean;
bitcoin_ok: boolean;
last_error?: string;
};
+5
View File
@@ -0,0 +1,5 @@
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
};
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@tsconfig/svelte/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
// Dev server proxies /api (REST and WebSocket) to the running
// kamado-api container on localhost:8080. In production the Go
// server serves the built static files alongside /api itself, so
// there is nothing to proxy.
export default defineConfig({
plugins: [svelte()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8080",
ws: true,
changeOrigin: false,
},
},
},
build: {
outDir: "dist",
emptyOutDir: true,
target: "es2022",
},
});