diff --git a/Makefile b/Makefile index 4299de0..707151e 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,16 @@ # ---------------------------------------------------------------------------- # 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: @echo "Kamado Pool targets:" @echo " make ckpool Build the ckpool-solo 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 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 up docker compose up -d --build" @echo " make down docker compose down" @@ -23,6 +26,15 @@ api: api-test: 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 docker run --rm -it --entrypoint /bin/sh kamado/ckpool:dev diff --git a/README.md b/README.md index c7df701..cbe00a1 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ Three main components: - [x] Phase 2a: CKPool socket client, bitcoind RPC, state aggregator, REST API - [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 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 5** — Testing (regtest, testnet4), polish diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..b01a18a --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.vite/ +*.log +.DS_Store diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..c05a201 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Kamado Pool + + +
+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..b0afe63 --- /dev/null +++ b/ui/package.json @@ -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" + } +} diff --git a/ui/src/App.svelte b/ui/src/App.svelte new file mode 100644 index 0000000..a7249a2 --- /dev/null +++ b/ui/src/App.svelte @@ -0,0 +1,51 @@ + + +
+
+ + {#if !snap.data} +
+
Status
+
Connecting to kamado-api…
+ {#if snap.error} +
{snap.error}
+ {/if} +
+ {:else} + +
+ + +
+ + {/if} +
+ + diff --git a/ui/src/app.css b/ui/src/app.css new file mode 100644 index 0000000..141a96d --- /dev/null +++ b/ui/src/app.css @@ -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; +} diff --git a/ui/src/format.ts b/ui/src/format.ts new file mode 100644 index 0000000..e41277a --- /dev/null +++ b/ui/src/format.ts @@ -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`; +} diff --git a/ui/src/global.d.ts b/ui/src/global.d.ts new file mode 100644 index 0000000..4078e74 --- /dev/null +++ b/ui/src/global.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/ui/src/lib/BestShares.svelte b/ui/src/lib/BestShares.svelte new file mode 100644 index 0000000..5f8ca63 --- /dev/null +++ b/ui/src/lib/BestShares.svelte @@ -0,0 +1,59 @@ + + +
+

Best shares leaderboard

+ {#if rows.length === 0} +
No shares submitted yet.
+ {:else} + + + + + + + + + + + {#each rows as r, i (r.worker)} + + + + + + + {/each} + +
#WorkerBest (round)Best (ever)
{i + 1}{r.worker}{formatDifficulty(r.bestRound)}{formatDifficulty(r.bestEver)}
+ {/if} +
+ + diff --git a/ui/src/lib/BlocksTable.svelte b/ui/src/lib/BlocksTable.svelte new file mode 100644 index 0000000..600bb1e --- /dev/null +++ b/ui/src/lib/BlocksTable.svelte @@ -0,0 +1,56 @@ + + +
+

Blocks found

+ {#if blocks.length === 0} +
+ No blocks found yet. Any solve will appear here instantly via the + log tailer, and persist to SQLite once Phase 2b.5 ships. +
+ {:else} + + + + + + + + + + + {#each blocks as b (b.height + "-" + b.found_at)} + + + + + + + {/each} + +
HeightHashWhenSource
{b.height}{b.hash ? b.hash.slice(0, 16) + "…" : "—"}{formatAgo(new Date(b.found_at).getTime() / 1000)}{b.source}
+ {/if} +
+ + diff --git a/ui/src/lib/Header.svelte b/ui/src/lib/Header.svelte new file mode 100644 index 0000000..7d2c772 --- /dev/null +++ b/ui/src/lib/Header.svelte @@ -0,0 +1,50 @@ + + +
+
+ + Kamado Pool +
+
+ ws: {snap.status} + {chain} + height {height} +
+
+ + diff --git a/ui/src/lib/MinersTable.svelte b/ui/src/lib/MinersTable.svelte new file mode 100644 index 0000000..c4441d9 --- /dev/null +++ b/ui/src/lib/MinersTable.svelte @@ -0,0 +1,144 @@ + + +
+

Miners

+ {#if rows.length === 0} +
No miners connected.
+ {:else} +
+ + + + + + + + + + + + + + + {#each rows as r (r.workerName)} + + + + + + + + + + + {/each} + +
WorkerHardwareHashrate (1m)Hashrate (1h)DiffBest (round)Best (ever)Last share
+
{r.workerName}
+
{r.address}
+
{r.hardware}{formatHashrate(r.hashrate1m)}{formatHashrate(r.hashrate1h)}{formatDifficulty(r.difficulty)}{formatDifficulty(r.bestDiff)}{formatDifficulty(r.bestEver)}{formatAgo(r.lastShare)}
+
+ {/if} +
+ + diff --git a/ui/src/lib/PoolOverview.svelte b/ui/src/lib/PoolOverview.svelte new file mode 100644 index 0000000..77cbe89 --- /dev/null +++ b/ui/src/lib/PoolOverview.svelte @@ -0,0 +1,58 @@ + + +
+
+
Hashrate (1h)
+
{formatHashrate(data.hashrate_hs_1h)}
+
+ 1m {formatHashrate(data.hashrate_hs_1m)} · 5m {formatHashrate(data.hashrate_hs_5m)} + · 24h {formatHashrate(data.hashrate_hs_24h)} +
+
+ +
+
Miners
+
{miners}
+
{workerCount} workers · {data.users?.length ?? 0} users
+
+ +
+
Network
+
{formatHashrate(data.network_hashrate_hs)}
+
+ diff {data.chain?.difficulty.toExponential(2) ?? "—"} · + share {(poolShare * 1e9).toFixed(2)} ppb +
+
+ +
+
Expected block
+
{formatDuration(expected)}
+
uptime {formatUptime(data.uptime_seconds)}
+
+
diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..745f7f6 --- /dev/null +++ b/ui/src/main.ts @@ -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; diff --git a/ui/src/stores/snapshot.svelte.ts b/ui/src/stores/snapshot.svelte.ts new file mode 100644 index 0000000..3ecc0e2 --- /dev/null +++ b/ui/src/stores/snapshot.svelte.ts @@ -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 { + // 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"; + }); +} diff --git a/ui/src/types.ts b/ui/src/types.ts new file mode 100644 index 0000000..51df75c --- /dev/null +++ b/ui/src/types.ts @@ -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; +}; diff --git a/ui/svelte.config.js b/ui/svelte.config.js new file mode 100644 index 0000000..d0e6448 --- /dev/null +++ b/ui/svelte.config.js @@ -0,0 +1,5 @@ +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; + +export default { + preprocess: vitePreprocess(), +}; diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..a400c5c --- /dev/null +++ b/ui/tsconfig.json @@ -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"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..456567e --- /dev/null +++ b/ui/vite.config.ts @@ -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", + }, +});