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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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`;
|
||||||
|
}
|
||||||
Vendored
+2
@@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user