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,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";
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user