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:
@@ -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