diff --git a/client/src/protoFleet/prototypes/README.md b/client/src/protoFleet/prototypes/README.md new file mode 100644 index 0000000000..5b266a0103 --- /dev/null +++ b/client/src/protoFleet/prototypes/README.md @@ -0,0 +1,92 @@ +# Single-miner view prototypes ("The Lab") + +> **Throwaway.** This whole directory exists to prototype three strategies for +> the single-miner view on the `migrate-single-miner-to-fleet` branch. It is +> **not** meant to merge to `main`. See +> `docs/plans/2026-07-28-single-miner-views-on-fleet-backend-plan.md`. + +## What's here + +A dev-only **Prototype Lab** at route `/lab` that previews three strategies +against one deliberately-distilled single-miner view (identity + 3 KPI tiles + +a hashboard/ASIC mini-grid + one control): + +1. **Fleet-native** (`fleetNative/`) — identity + KPIs sourced entirely from the + fleet server via the existing `ListMinerStateSnapshots` RPC (by /32 ipCidr) + over `/api-proxy`; connect to a miner (IP + credentials) from a null state and + watch the fleet-native view render, never touching the device. The ASIC grid + is **synthesized** — the fleet collects components in the collector + but discards them at persistence and exposes no RPC for them (see + `fleetAdapter.ts` for the exact file:line and the `prototype/v1` RPC that + would make it real; that backend RPC is documented-but-deferred, since each + server change needs a full image rebuild). +2. **Proxy, version-aware** (`proxyVersioned/`) — proxy straight to the device, + probe `/api/version`, and resolve the matching MDK adapter (v1 REST / v2 + consolidated). Both fold into the same snapshot and render the identical + ``, so v1 and v2 look the same and both match strategies 1 + and 3. The version changes only the _fetch_; the difference between this + strategy and the others is the data path (shown in the details modal), not + the view. In production the calls ride the minerproxy path + (`/api-proxy/miners/:id`). +3. **Adapter** (`adapter/`) — one generic view, swappable backend adapters + (fleet server, MDK v1 REST, MDK v2 consolidated) folded behind one + `SingleMinerAdapter` seam; backend chosen by a selector, MDK version by the + `/api/version` probe. + +`shared/` holds the pieces all three strategies reuse: the +`SingleMinerSnapshot` contract, the `SingleMinerAdapter` seam (`adapter.ts`), the +presentational ``, the mini `` (the dumbed-down +miners tab), the `` chrome, and mock data. + +## Design + +The pages are built from the ProtoFleet shared kit (`Button`, `Input`, `Select`, +`Card`, `Metric`, `StatusCircle`, `Chip`, and the diagnostic `AsicTablePreview` +heatmap for the ASIC grid) so the Lab reads in the product's design language +rather than bespoke Tailwind. + +Each strategy picks a miner in the way that best illustrates its point, then +renders the identical ``: + +- **S1** starts at a null state with a **Connect a miner** button that opens a + simple modal (IP + username + password + Connect). +- **S2** shows a two-row `` (one MDK v1 rig, one MDK v2); clicking a + row proxies to that miner and renders its view. +- **S3** offers an **Adapter context** dropdown (` switchContext(v as Context)} + /> + + {snapshot ? ( + } + > + + + ) : context === "fleet" ? ( + + ) : ( +
+
+ + {context === "mdkv2" ? "MDK v2 miner (direct)" : "MDK v1 miner (direct)"} + + + {busy + ? `Connecting to the ${context === "mdkv2" ? "v2" : "v1"} fake rig…` + : `Couldn't reach the ${context === "mdkv2" ? "v2" : "v1"} fake rig. Is it running?`} + +
+ {busy ? null : ( +
+ )} + + {error ? ( +
+ {error} +
+ Direct MDK contexts need the fake rigs (just lab-fakes). Fleet needs fleet-api up and an + authenticated session. +
+
+ ) : null} + + ); +} diff --git a/client/src/protoFleet/prototypes/adapter/http.ts b/client/src/protoFleet/prototypes/adapter/http.ts new file mode 100644 index 0000000000..5ace6109c8 --- /dev/null +++ b/client/src/protoFleet/prototypes/adapter/http.ts @@ -0,0 +1,66 @@ +/** + * Tiny fetch helpers for the direct browser→miner adapters. Deliberately + * dependency-free — the whole point of Strategy 3's MDK adapters is that they + * talk to the device with nothing between the browser and the miner. + */ + +export class MinerHttpError extends Error { + constructor( + readonly status: number, + readonly url: string, + body: string, + ) { + super(`${status} ${url}${body ? ` — ${body.slice(0, 200)}` : ""}`); + this.name = "MinerHttpError"; + } +} + +interface RequestOpts { + signal?: AbortSignal; + token?: string; +} + +async function request(method: string, url: string, body: unknown, opts: RequestOpts): Promise { + const headers: Record = {}; + if (body !== undefined) headers["Content-Type"] = "application/json"; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + + const res = await fetch(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: opts.signal, + }); + + if (!res.ok) { + throw new MinerHttpError(res.status, url, await res.text().catch(() => "")); + } + // 202/204 responses (reboot, logout) may carry no JSON body. + const text = await res.text(); + return (text ? JSON.parse(text) : undefined) as T; +} + +export function getJson(url: string, opts: RequestOpts = {}): Promise { + return request("GET", url, undefined, opts); +} + +export function postJson(url: string, body: unknown, opts: RequestOpts = {}): Promise { + return request("POST", url, body, opts); +} + +/** Strip a trailing slash so `${base}/api/...` never double-slashes. */ +export function normalizeBaseUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ""); + // Bare host/IP → default to http (fake rigs and lab miners are plain HTTP). + if (!/^https?:\/\//i.test(trimmed)) return `http://${trimmed}`; + return trimmed; +} + +/** Best-effort host label for the identity card. */ +export function hostOf(baseUrl: string): string { + try { + return new URL(baseUrl).host; + } catch { + return baseUrl; + } +} diff --git a/client/src/protoFleet/prototypes/adapter/mdkV1Adapter.ts b/client/src/protoFleet/prototypes/adapter/mdkV1Adapter.ts new file mode 100644 index 0000000000..0d71b88d86 --- /dev/null +++ b/client/src/protoFleet/prototypes/adapter/mdkV1Adapter.ts @@ -0,0 +1,211 @@ +/** + * MDK v1 adapter — talks to today's Proto REST surface (`/api/v1/*`). + * + * The honest part of this prototype: v1 spreads a miner across many endpoints + * (login, system, mining, hashboards) and exposes NO bulk per-chip data. So the + * ASIC mini-grid here is *synthesized* from each board's ASIC count around its + * average ASIC temperature — a faithful reflection of what a v1-only client can + * actually show without N-per-chip round trips. Contrast MdkV2Adapter, which + * gets real per-chip readings in one call. + */ +import type { SingleMinerAdapter } from "../shared/adapter"; +import { type FlowTracer, NO_TRACE } from "../shared/flowTrace"; +import type { + AsicCell, + AsicHealth, + HashboardSummary, + MinerControlAction, + MinerStatus, + SingleMinerSnapshot, +} from "../shared/types"; +import { getJson, hostOf, postJson } from "./http"; + +/** Wrap a fetch so it appears in the data-flow pane as a traced request. */ +function traced(tracer: FlowTracer, title: string, detail: string, p: Promise): Promise { + const req = tracer.request("miner", title, detail); + return p.then( + (v) => { + req.ok(); + return v; + }, + (e) => { + req.fail(e instanceof Error ? e.message : String(e)); + throw e; + }, + ); +} + +interface V1Login { + access_token: string; + refresh_token: string; +} +interface V1System { + "system-info": { + model?: string; + cb_sn: string; + os: { name: string; version: string; hostname: string }; + }; +} +interface V1Mining { + "mining-status": { + status: string; + hashrate_ghs: number; + power_usage_watts: number; + average_hb_temp_c: number; + average_asic_temp_c: number; + hashboards_installed: number; + }; +} +interface V1Hashboards { + "hashboards-info": Array<{ + slot: number; + hb_sn?: string; + mining_asic_count?: number; + }>; +} + +function mapStatus(raw: string): MinerStatus { + switch (raw) { + case "Mining": + return "mining"; + case "DegradedMining": + return "error"; + case "Stopped": + case "NoPools": + case "PoweringOn": + case "PoweringOff": + case "Curtailed": + return "paused"; + default: + return "offline"; + } +} + +/** Synthesize a per-chip grid around the board's average ASIC temp. */ +function synthAsics(count: number, avgTempC: number, seed: number): AsicCell[] { + return Array.from({ length: count }, (_, index) => { + const wobble = ((index * 7 + seed * 13) % 11) - 5; + const tempC = avgTempC + wobble; + let health: AsicHealth = "ok"; + if (tempC >= avgTempC + 4) health = "warn"; + if (tempC >= avgTempC + 8) health = "error"; + if ((index * 3 + seed) % 41 === 0) health = "off"; + return { + index, + tempC: health === "off" ? null : Math.round(tempC * 10) / 10, + hashrateThs: health === "off" ? 0 : Math.round((1.1 + wobble / 50) * 100) / 100, + health, + }; + }); +} + +export class MdkV1Adapter implements SingleMinerAdapter { + readonly source = "MDK v1 REST (direct)"; + private token: string | null = null; + + constructor( + private readonly baseUrl: string, + private readonly password: string, + ) {} + + private async ensureToken(signal?: AbortSignal, tracer: FlowTracer = NO_TRACE): Promise { + if (this.token) return this.token; + const login = await traced( + tracer, + "POST /api/v1/auth/login", + "password grant", + postJson(`${this.baseUrl}/api/v1/auth/login`, { password: this.password }, { signal }), + ); + this.token = login.access_token; + return this.token; + } + + async fetchSnapshot(signal?: AbortSignal, tracer: FlowTracer = NO_TRACE): Promise { + // The adapter layer: the view calls one generic "get snapshot", and this + // adapter maps it onto v1's specific REST surface (many endpoints). Not a + // network call itself — it's the translation the abstraction buys you. + tracer.adapter("Adapter → v1 REST", "generic snapshot getter mapped to login + system + mining + hashboards"); + + const token = await this.ensureToken(signal, tracer); + + // v1 requires several round trips — a real cost the data-flow pane shows. + const [system, mining, boards] = await Promise.all([ + traced( + tracer, + "GET /api/v1/system", + "identity", + getJson(`${this.baseUrl}/api/v1/system`, { signal, token }), + ), + traced( + tracer, + "GET /api/v1/mining", + "kpis", + getJson(`${this.baseUrl}/api/v1/mining`, { signal, token }), + ), + traced( + tracer, + "GET /api/v1/hashboards", + "board list", + getJson(`${this.baseUrl}/api/v1/hashboards`, { signal, token }), + ), + ]); + + // Folding v1's three REST docs into the shared view model (renames, unit + // conversions, status mapping) and synthesizing the grid is plain + // application logic — not traced. "Adapter" in the flow narration refers to + // the version-aware seam (probe.ts), not this per-field mapping. + const s = system["system-info"]; + const m = mining["mining-status"]; + const boardList = boards["hashboards-info"] ?? []; + const perBoardThs = boardList.length ? m.hashrate_ghs / 1000 / boardList.length : 0; + + const hashboards: HashboardSummary[] = boardList.map((b, i) => { + const asics = synthAsics(b.mining_asic_count ?? 0, m.average_asic_temp_c, b.slot || i + 1); + const live = asics.filter((a) => a.health !== "off"); + return { + serialNumber: b.hb_sn ?? `HB-${b.slot}`, + index: b.slot, + tempC: live.length ? Math.max(...live.map((a) => a.tempC ?? 0)) : null, + hashrateThs: Math.round(perBoardThs * 100) / 100, + asics, + }; + }); + + return { + identity: { + name: s.os.hostname || s.cb_sn, + model: s.model ?? "Proto", + firmware: s.os.version, + mdkVersion: "MDK v1", + macAddress: "—", // v1 /system does not expose MAC + serialNumber: s.cb_sn, + ipAddress: hostOf(this.baseUrl), + }, + status: mapStatus(m.status), + kpis: { + hashrateThs: m.hashrate_ghs / 1000, + tempC: m.average_hb_temp_c, + powerW: m.power_usage_watts, + }, + hashboards, + dataPath: [ + { label: "Browser", detail: "ProtoFleet client" }, + { label: "MDK v1 REST", detail: "login + system + mining + hashboards" }, + { label: "Adapter", detail: "maps v1 docs → view model" }, + ], + source: this.source, + updatedAt: new Date().toISOString(), + }; + } + + async control(action: MinerControlAction, tracer: FlowTracer = NO_TRACE): Promise { + const token = await this.ensureToken(undefined, tracer); + const path = + action === "reboot" + ? "/api/v1/system/reboot" + : action === "pause" + ? "/api/v1/mining/stop" + : "/api/v1/mining/start"; + await traced(tracer, `POST ${path}`, action, postJson(`${this.baseUrl}${path}`, {}, { token })); + } +} diff --git a/client/src/protoFleet/prototypes/adapter/mdkV2Adapter.ts b/client/src/protoFleet/prototypes/adapter/mdkV2Adapter.ts new file mode 100644 index 0000000000..2c4d5c5290 --- /dev/null +++ b/client/src/protoFleet/prototypes/adapter/mdkV2Adapter.ts @@ -0,0 +1,143 @@ +/** + * MDK v2 adapter — talks to the (faked) consolidated `GET /api/v2/miner`. + * + * v2 is deliberately divergent from v1: one call returns a wrapped envelope with + * camelCase fields, hashrate in GH/s, nested thermals, and a real per-chip + * `chips[]` array with a state enum. The adapter's job is to fold that different + * shape into the exact same SingleMinerSnapshot — no synthesis needed, because + * v2 actually reports per-chip data. This is the payoff of the adapter seam: + * two very different wire formats, one view. + */ +import type { SingleMinerAdapter } from "../shared/adapter"; +import { type FlowTracer, NO_TRACE } from "../shared/flowTrace"; +import type { AsicHealth, HashboardSummary, MinerStatus, SingleMinerSnapshot } from "../shared/types"; +import { getJson, hostOf } from "./http"; + +interface V2Chip { + pos: number; + tempC: number; + ghs: number; + state: "ONLINE" | "HOT" | "FAULT" | "OFFLINE" | string; +} +interface V2Board { + slot: number; + serial: string; + hashrateGhs: number; + thermals: { peakC: number; avgC: number }; + chips: V2Chip[]; +} +interface V2Envelope { + apiVersion: string; + data: { + device: { + displayName: string; + hardwareModel: string; + firmwareRev: string; + mdk: string; + netMac: string; + unitSerial: string; + lanIp: string; + }; + state: string; + performance: { + hashrateGhs: number; + powerWatts: number; + thermals: { peakC: number; avgC: number }; + }; + boards: V2Board[]; + }; + meta: { generatedAt: string; schema: string }; +} + +function mapState(state: string): MinerStatus { + switch (state) { + case "HASHING": + return "mining"; + case "IDLE": + return "paused"; + case "FAULT": + return "error"; + default: + return "offline"; + } +} + +function mapChipHealth(state: string): AsicHealth { + switch (state) { + case "ONLINE": + return "ok"; + case "HOT": + return "warn"; + case "FAULT": + return "error"; + case "OFFLINE": + return "off"; + default: + return "off"; + } +} + +export class MdkV2Adapter implements SingleMinerAdapter { + readonly source = "MDK v2 consolidated (direct)"; + + constructor(private readonly baseUrl: string) {} + + async fetchSnapshot(signal?: AbortSignal, tracer: FlowTracer = NO_TRACE): Promise { + // The adapter layer: the view's one generic "get snapshot" maps onto v2's + // single consolidated endpoint. Not a network call itself — the translation. + tracer.adapter("Adapter → v2 consolidated", "generic snapshot getter mapped to GET /api/v2/miner"); + + const req = tracer.request("miner", "GET /api/v2/miner", "one consolidated envelope"); + let env: V2Envelope; + try { + env = await getJson(`${this.baseUrl}/api/v2/miner`, { signal }); + req.ok(`${env.data.boards.length} boards · per-chip`); + } catch (e) { + req.fail(e instanceof Error ? e.message : String(e)); + throw e; + } + const d = env.data; + + // Folding v2's envelope into the shared view model is plain application + // logic — not traced. "Adapter" in the flow narration means the + // version-aware seam (probe.ts), not this per-field mapping. + const hashboards: HashboardSummary[] = d.boards.map((b) => ({ + serialNumber: b.serial, + index: b.slot, + tempC: b.thermals.peakC, + hashrateThs: Math.round((b.hashrateGhs / 1000) * 100) / 100, + asics: b.chips.map((c) => ({ + index: c.pos, + tempC: c.state === "OFFLINE" ? null : c.tempC, + hashrateThs: Math.round((c.ghs / 1000) * 1000) / 1000, + health: mapChipHealth(c.state), + })), + })); + + return { + identity: { + name: d.device.displayName, + model: d.device.hardwareModel, + firmware: d.device.firmwareRev, + mdkVersion: `MDK v${d.device.mdk}`, + macAddress: d.device.netMac, + serialNumber: d.device.unitSerial, + ipAddress: d.device.lanIp || hostOf(this.baseUrl), + }, + status: mapState(d.state), + kpis: { + hashrateThs: d.performance.hashrateGhs / 1000, + tempC: d.performance.thermals.peakC, + powerW: d.performance.powerWatts, + }, + hashboards, + dataPath: [ + { label: "Browser", detail: "ProtoFleet client" }, + { label: "MDK v2", detail: "GET /api/v2/miner (consolidated)" }, + { label: "Adapter", detail: "folds envelope → snapshot" }, + ], + source: this.source, + updatedAt: env.meta.generatedAt, + }; + } +} diff --git a/client/src/protoFleet/prototypes/adapter/probe.ts b/client/src/protoFleet/prototypes/adapter/probe.ts new file mode 100644 index 0000000000..26e6442586 --- /dev/null +++ b/client/src/protoFleet/prototypes/adapter/probe.ts @@ -0,0 +1,55 @@ +/** + * Version probe — the seam that lets one client pick the right adapter for a + * given device. Mirrors the fake rig's public `GET /api/version`. + */ +import type { SingleMinerAdapter } from "../shared/adapter"; +import { type FlowTracer, NO_TRACE } from "../shared/flowTrace"; +import { getJson, hostOf } from "./http"; +import { MdkV1Adapter } from "./mdkV1Adapter"; +import { MdkV2Adapter } from "./mdkV2Adapter"; + +interface VersionResponse { + mdkVersion: string; + apiVersions: string[]; + firmwareRev: string; +} + +export interface ProbeResult { + adapter: SingleMinerAdapter; + mdkVersion: string; + firmwareRev: string; +} + +/** + * Ask the device what it speaks, then resolve the matching adapter. Falls back + * to MDK v1 for older firmware that predates the `/api/version` probe. + */ +export async function probeAndResolve( + baseUrl: string, + password: string, + signal?: AbortSignal, + tracer: FlowTracer = NO_TRACE, +): Promise { + const req = tracer.request("miner", "GET /api/version", "probe firmware generation"); + let version: VersionResponse | undefined; + try { + version = await getJson(`${baseUrl}/api/version`, { signal }); + req.ok(`MDK v${version.mdkVersion} · fw ${version.firmwareRev}`); + } catch { + // Pre-probe firmware — assume the legacy v1 REST surface. + req.ok("no probe → legacy v1"); + tracer.seam("Version seam → MDK v1", "legacy fallback — no /api/version probe"); + return { adapter: new MdkV1Adapter(baseUrl, password), mdkVersion: "1", firmwareRev: "unknown" }; + } + + const speaksV2 = version.apiVersions.includes("v2") || version.mdkVersion === "2"; + const adapter = speaksV2 ? new MdkV2Adapter(baseUrl) : new MdkV1Adapter(baseUrl, password); + // The version seam: the probed firmware decides which client/adapter renders. + tracer.seam( + `Version seam → MDK v${speaksV2 ? "2" : "1"}`, + "probed firmware decides which single-miner client to render", + ); + return { adapter, mdkVersion: version.mdkVersion, firmwareRev: version.firmwareRev }; +} + +export { hostOf }; diff --git a/client/src/protoFleet/prototypes/fleetNative/FleetNativePage.tsx b/client/src/protoFleet/prototypes/fleetNative/FleetNativePage.tsx new file mode 100644 index 0000000000..57fa7cd3a2 --- /dev/null +++ b/client/src/protoFleet/prototypes/fleetNative/FleetNativePage.tsx @@ -0,0 +1,120 @@ +/** + * Strategy 1 — Fleet-native single-miner view. + * + * The whole point is the *experience*: connect to a miner (IP + credentials), + * and watch the single-miner view render — sourced entirely from the fleet + * server via `ListMinerStateSnapshots` (over /api-proxy), never touching the + * device. Because it's fleet-native it's self-explanatory: the same fleet + * components you'd see anywhere in ProtoFleet. + * + * Identity + KPIs are real fleet data. The ASIC grid is synthesized — see + * fleetAdapter.ts for exactly why (fleet collects components but discards them + * at persistence; a `prototype/v1` RPC would make the grid real). + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { useFlowTrace } from "../shared/FlowPane"; +import { MinerViewFrame } from "../shared/MinerViewFrame"; +import { SingleMinerDetails } from "../shared/SingleMinerDetails"; +import { SingleMinerView } from "../shared/SingleMinerView"; +import type { SingleMinerSnapshot } from "../shared/types"; +import { FleetAdapter } from "./fleetAdapter"; +import Button, { sizes as buttonSizes, variants as buttonVariants } from "@/shared/components/Button"; +import Input from "@/shared/components/Input"; +import Modal, { sizes as modalSizes } from "@/shared/components/Modal"; + +export default function FleetNativePage() { + const [ip, setIp] = useState(""); + const [username, setUsername] = useState("admin"); + const [password, setPassword] = useState("admin1234"); + const [snapshot, setSnapshot] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const [connectOpen, setConnectOpen] = useState(false); + const abortRef = useRef(null); + const trace = useFlowTrace(); + + const connect = useCallback(async () => { + if (!ip) return; + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setBusy(true); + setError(null); + trace.reset(); + try { + // Fleet-native: credentials frame the "connect to a miner" experience; + // the fleet already knows the device, so the RPC resolves it by IP. + const snap = await new FleetAdapter(ip).fetchSnapshot(ctrl.signal, trace.makeTracer("fleet-native")); + setSnapshot(snap); + setConnectOpen(false); + } catch (e) { + if (!ctrl.signal.aborted) setError(e instanceof Error ? e.message : String(e)); + } finally { + if (abortRef.current === ctrl) setBusy(false); + } + }, [ip, trace]); + + useEffect(() => () => abortRef.current?.abort(), []); + + return ( + <> + {snapshot ? ( + setConnectOpen(true) }} + details={} + > + + + ) : ( +
+
+ No miner connected + + Connect to a miner to render its single-miner view, fleet-native. + +
+
+ )} + + setConnectOpen(false)} + title="Connect to a miner" + size={modalSizes.standard} + divider + buttons={[ + { text: "Cancel", variant: buttonVariants.secondary, dismissModalOnClick: true }, + { + text: "Connect", + variant: buttonVariants.primary, + onClick: connect, + disabled: !ip || busy, + loading: busy, + }, + ]} + > +
+ setIp(v)} autoFocus /> + setUsername(v)} /> + setPassword(v)} + /> + {error ? {error} : null} +
+
+ + ); +} diff --git a/client/src/protoFleet/prototypes/fleetNative/fleetAdapter.ts b/client/src/protoFleet/prototypes/fleetNative/fleetAdapter.ts new file mode 100644 index 0000000000..27080334cf --- /dev/null +++ b/client/src/protoFleet/prototypes/fleetNative/fleetAdapter.ts @@ -0,0 +1,153 @@ +/** + * Fleet-native adapter (Strategy 1). + * + * Identity + KPIs are sourced *entirely from the fleet server* via the existing + * FleetManagementService.ListMinerStateSnapshots RPC (over /api-proxy) — no + * direct call to the miner. That's the strategy's whole thesis: one backend, + * any miner, regardless of on-device OS/firmware. + * + * The ASIC mini-grid is the honest catch. The fleet collector already builds a + * full DeviceMetrics (HashBoards → ASICs) in memory + * (server/.../plugins/mappers/sdk_mapper.go) but the persistence layer DROPS the + * component arrays (server/.../timescaledb/telemetry_store.go:366, device-level + * scalars only), and NO client-facing RPC exposes them. So here the grid is + * SYNTHESIZED from the device-level temperature — a faithful picture of what a + * fleet-native view can show *today*. Making it real needs a new `prototype/v1` + * Connect RPC that calls miner.GetDeviceMetrics(ctx) on demand (bypassing the + * lossy DB); the mechanism exists (interfaces/miner.go:59), it's just unwired. + */ +import type { SingleMinerAdapter } from "../shared/adapter"; +import { type FlowTracer, NO_TRACE } from "../shared/flowTrace"; +import type { AsicCell, AsicHealth, HashboardSummary, MinerStatus, SingleMinerSnapshot } from "../shared/types"; +import { fleetManagementClient } from "@/protoFleet/api/clients"; +import { + DeviceStatus, + type MinerStateSnapshot, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +const SYNTH_BOARDS = 3; +const SYNTH_ASICS_PER_BOARD = 66; + +function current(arr: { value: number }[]): number | null { + return arr.length ? arr[0].value : null; +} + +function mapStatus(snapshot: MinerStateSnapshot): MinerStatus { + switch (snapshot.deviceStatus) { + case DeviceStatus.ERROR: + return "error"; + case DeviceStatus.OFFLINE: + case DeviceStatus.INACTIVE: + return "offline"; + case DeviceStatus.MAINTENANCE: + case DeviceStatus.UPDATING: + return "paused"; + case DeviceStatus.ONLINE: + return (current(snapshot.hashrate) ?? 0) > 0 ? "mining" : "paused"; + default: + return "offline"; + } +} + +/** Synthesize a per-chip grid around the device-level temperature. */ +function synthGrid(deviceTempC: number | null, deviceHashrateThs: number | null): HashboardSummary[] { + const baseTemp = deviceTempC ?? 65; + const perBoardThs = (deviceHashrateThs ?? 0) / SYNTH_BOARDS; + return Array.from({ length: SYNTH_BOARDS }, (_, board) => { + const boardBase = baseTemp + board * 2; + const asics: AsicCell[] = Array.from({ length: SYNTH_ASICS_PER_BOARD }, (_, index) => { + const wobble = ((index * 7 + board * 13) % 11) - 5; + const tempC = boardBase + wobble; + let health: AsicHealth = "ok"; + if (tempC >= boardBase + 4) health = "warn"; + if (tempC >= boardBase + 8) health = "error"; + if ((index * 3 + board) % 41 === 0) health = "off"; + return { + index, + tempC: health === "off" ? null : Math.round(tempC * 10) / 10, + hashrateThs: health === "off" ? 0 : Math.round((perBoardThs / SYNTH_ASICS_PER_BOARD) * 1000) / 1000, + health, + }; + }); + const live = asics.filter((a) => a.health !== "off"); + return { + serialNumber: `HB-${board}`, + index: board, + tempC: live.length ? Math.max(...live.map((a) => a.tempC ?? 0)) : null, + hashrateThs: Math.round(perBoardThs * 100) / 100, + asics, + }; + }); +} + +export class FleetAdapter implements SingleMinerAdapter { + readonly source = "Fleet server (Connect RPC)"; + + /** `target` is an IP (matched as /32) — the single-miner-mode entry value. */ + constructor(private readonly target: string) {} + + async fetchSnapshot(signal?: AbortSignal, tracer: FlowTracer = NO_TRACE): Promise { + // The adapter layer: the view's generic "get snapshot" maps onto the fleet + // backend's RPC. Shown in S3 (fleet is one adapter among three); suppressed + // in S1, where the view is framed as reading the fleet proto directly. + tracer.adapter("Adapter → fleet RPC", "generic snapshot getter mapped to ListMinerStateSnapshots (by IP)"); + + const req = tracer.request("fleet", "ListMinerStateSnapshots", `Connect RPC · ipCidrs=[${this.target}/32]`); + let res; + try { + res = await fleetManagementClient.listMinerStateSnapshots( + { filter: { ipCidrs: [this.target] }, pageSize: 1 }, + { signal }, + ); + req.ok(`${res.miners.length} miner(s)`); + } catch (e) { + req.fail(e instanceof Error ? e.message : String(e)); + throw e; + } + const m = res.miners[0]; + if (!m) throw new Error(`No fleet miner found at ${this.target}`); + + // Fleet-native has no adapter: the view is built directly on the fleet proto. + // Per-ASIC data isn't in the snapshot today, but in production the collector + // would persist it — so for the demo we treat the grid as fleet-sourced and + // don't call out the gap in the flow narration. + tracer.note( + "fleet-miner", + "Fleet server ⇽ miner", + "device telemetry collected out-of-band; the RPC reads the stored snapshot", + ); + + const hashrateThs = current(m.hashrate); + const tempC = current(m.temperature); + const powerKw = current(m.powerUsage); + + return { + identity: { + name: m.name || m.deviceIdentifier, + model: m.model || m.driverName || "—", + firmware: m.firmwareVersion || "—", + mdkVersion: "via fleet", + macAddress: m.macAddress || "—", + serialNumber: m.serialNumber || "—", + ipAddress: m.ipAddress || this.target, + }, + status: mapStatus(m), + kpis: { + hashrateThs, + tempC, + powerW: powerKw === null ? null : powerKw * 1000, // snapshot power is kW + }, + hashboards: synthGrid(tempC, hashrateThs), + dataPath: [ + { label: "ProtoFleet client", detail: "React" }, + { label: "Fleet server", detail: "ListMinerStateSnapshots (Connect)" }, + { label: "TimescaleDB", detail: "device-level scalars" }, + { label: "ASIC grid *", detail: "FPO — placeholder grid" }, + ], + dataPathNote: + "* For-placement-only: the ASIC grid is faked from the device temperature. Fleet stores only device-level scalars, so going fleet-native for real requires adding per-ASIC collection + persistence (or an on-demand metrics RPC) on the server.", + source: this.source, + updatedAt: new Date().toISOString(), + }; + } +} diff --git a/client/src/protoFleet/prototypes/lab/LabIndex.tsx b/client/src/protoFleet/prototypes/lab/LabIndex.tsx new file mode 100644 index 0000000000..63be5212cc --- /dev/null +++ b/client/src/protoFleet/prototypes/lab/LabIndex.tsx @@ -0,0 +1,65 @@ +/** Landing page for the Prototype Lab — one card per strategy. */ +import { Link } from "react-router-dom"; + +interface StrategyCard { + path: string; + title: string; + pitch: string; + dataPath: string; + directToMiner: string; +} + +const STRATEGIES: StrategyCard[] = [ + { + path: "/lab/fleet-native", + title: "1 · Fleet-native", + pitch: + "Fleet server collects & normalizes all data (via plugins). The view renders like any ProtoFleet component off a Connect RPC.", + dataPath: "client → fleet server → plugin → miner", + directToMiner: "Single-miner mode: a local ProtoFleet + app server you pair to one miner by IP.", + }, + { + path: "/lab/proxy", + title: "2 · Proxy (versioned)", + pitch: + "Reverse-proxy to the miner (like today), but detect firmware/MDK version and render the matching per-version client.", + dataPath: "client → fleet proxy → miner REST", + directToMiner: "Renders the real per-version client against a live miner.", + }, + { + path: "/lab/adapter", + title: "3 · Adapter", + pitch: + "One generic view, swappable backend adapters (fleet, MDK v1, MDK v2). Adapters can talk straight to a miner — no app server.", + dataPath: "client → adapter → { fleet RPC | miner v1 REST | miner v2 REST }", + directToMiner: "MDK v1/v2 adapters call the miner REST directly from the browser.", + }, +]; + +export default function LabIndex() { + return ( +
+

+ Three strategies for rendering a single-miner view, each against the same distilled surface (identity + 3 KPIs + + hashboard/ASIC mini-grid + one control). The ASIC grid is the deliberate stressor — it's the one thing the fleet + server doesn't expose today. +

+
+ {STRATEGIES.map((s) => ( + + {s.title} + {s.pitch} + + {s.dataPath} + + {s.directToMiner} + + ))} +
+
+ ); +} diff --git a/client/src/protoFleet/prototypes/lab/LabLayout.tsx b/client/src/protoFleet/prototypes/lab/LabLayout.tsx new file mode 100644 index 0000000000..d2085f9eb3 --- /dev/null +++ b/client/src/protoFleet/prototypes/lab/LabLayout.tsx @@ -0,0 +1,63 @@ +/** Minimal chrome for the Prototype Lab — a header + tab nav + data-flow pane. */ +import { useEffect } from "react"; +import { Link, Outlet, useLocation } from "react-router-dom"; + +import { FlowPane, FlowTraceProvider, useFlowTrace } from "../shared/FlowPane"; + +const TABS = [ + { path: "/lab", label: "Overview", exact: true }, + { path: "/lab/fleet-native", label: "1 · Fleet-native" }, + { path: "/lab/proxy", label: "2 · Proxy (versioned)" }, + { path: "/lab/adapter", label: "3 · Adapter" }, +]; + +export default function LabLayout() { + return ( + + + + ); +} + +function LabShell() { + const { pathname } = useLocation(); + const { open, reset } = useFlowTrace(); + // Clear the data-flow trace when switching prototypes — each tab starts clean. + useEffect(() => () => reset(), [pathname, reset]); + return ( +
+ +
+
+
+ + Prototype + +

Single-miner view · The Lab

+
+ +
+
+ +
+
+
+ ); +} diff --git a/client/src/protoFleet/prototypes/proxyVersioned/ProxyVersionedPage.tsx b/client/src/protoFleet/prototypes/proxyVersioned/ProxyVersionedPage.tsx new file mode 100644 index 0000000000..72305acbfe --- /dev/null +++ b/client/src/protoFleet/prototypes/proxyVersioned/ProxyVersionedPage.tsx @@ -0,0 +1,147 @@ +/** + * Strategy 2 — Proxy to miner, version-aware (the fleet-side experience). + * + * This is the ProtoFleet miners tab, dumbed down to two rigs running different + * MDK versions. Click either and its single-miner view renders with requests + * proxied straight to that miner — v1 and v2 both fold into the same snapshot + * and render the identical , matching strategies 1 and 3. The + * firmware version only changes the *fetch* (probed via `/api/version`), never + * the view. + * + * In the Lab the adapters hit the fake rigs directly; in production the calls + * ride the minerproxy path (/api-proxy/miners/:id), so no browser CORS/TLS and + * no direct device exposure. + */ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { probeAndResolve } from "../adapter/probe"; +import type { SingleMinerAdapter } from "../shared/adapter"; +import { useFlowTrace } from "../shared/FlowPane"; +import type { FlowTracer } from "../shared/flowTrace"; +import { type MinerListItem, MinersList } from "../shared/MinersList"; +import { MinerViewFrame } from "../shared/MinerViewFrame"; +import { SingleMinerDetails } from "../shared/SingleMinerDetails"; +import { SingleMinerView } from "../shared/SingleMinerView"; +import type { MinerControlAction, SingleMinerSnapshot } from "../shared/types"; + +interface Rig extends MinerListItem { + baseUrl: string; + password: string; +} + +const RIGS: Rig[] = [ + { + id: "rig-01", + name: "Proto Rig 01", + mdkVersion: "MDK v1", + ipAddress: "localhost:18081", + firmware: "1.8.0", + baseUrl: "http://localhost:18081", + password: "admin1234", + }, + { + id: "rig-02", + name: "Proto Rig 02", + mdkVersion: "MDK v2", + ipAddress: "localhost:18082", + firmware: "1.4.2", + baseUrl: "http://localhost:18082", + password: "admin1234", + }, +]; + +export default function ProxyVersionedPage() { + const [connection, setConnection] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const trace = useFlowTrace(); + + const load = useCallback(async (adapter: SingleMinerAdapter, rowId: string | null, tracer?: FlowTracer) => { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setBusyId(rowId); + setError(null); + try { + setSnapshot(await adapter.fetchSnapshot(ctrl.signal, tracer)); + setConnection(adapter); + } catch (e) { + if (!ctrl.signal.aborted) setError(e instanceof Error ? e.message : String(e)); + } finally { + if (abortRef.current === ctrl) setBusyId(null); + } + }, []); + + const openRig = useCallback( + async (item: MinerListItem) => { + const rig = RIGS.find((r) => r.id === item.id); + if (!rig) return; + setBusyId(rig.id); + setError(null); + trace.reset(); + const tracer = trace.makeTracer("proxy"); + try { + const resolved = await probeAndResolve(rig.baseUrl, rig.password, undefined, tracer); + await load(resolved.adapter, rig.id, tracer); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + setBusyId(null); + } + }, + [load, trace], + ); + + // A control action is a POST; append it (and the refetch that follows) to the + // running trace so the pane shows writes riding the same proxy path as reads. + // No reset — the trace only clears on a new connection or prototype switch. + const runControl = useCallback( + async (action: MinerControlAction) => { + if (!connection?.control) return; + const tracer = trace.makeTracer("proxy"); + await connection.control(action, tracer); + await load(connection, null, tracer); + }, + [connection, load, trace], + ); + + const back = useCallback(() => { + abortRef.current?.abort(); + setSnapshot(null); + setConnection(null); + setError(null); + }, []); + + useEffect(() => () => abortRef.current?.abort(), []); + + if (snapshot) { + return ( + } + > + + + ); + } + + return ( +
+

+ Two fake rigs on different MDK versions. Click one to open its single-miner view — requests proxy to the actual + miner, and both versions render the same view. +

+ + {error ? ( +
+ {error} +
+ Needs the fake rigs (just lab-fakes, v1 :18081 / v2 :18082). +
+
+ ) : null} +
+ ); +} diff --git a/client/src/protoFleet/prototypes/shared/FlowPane.tsx b/client/src/protoFleet/prototypes/shared/FlowPane.tsx new file mode 100644 index 0000000000..58a263dcbc --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/FlowPane.tsx @@ -0,0 +1,192 @@ +/** + * The collapsible "Data flow" drawer + its provider. + * + * Pages pull a tracer from context (`useFlowTrace().makeTracer(transport)`), + * reset before a connect, and hand the tracer to the adapter. The adapter emits + * events as it runs; this pane renders them live, color-coded by hop (see + * flowTrace.ts) — an at-a-glance narration of what each strategy actually does. + */ +import { createContext, ReactNode, useCallback, useContext, useMemo, useRef, useState } from "react"; + +import { CHANNEL_META, type FlowChannel, type FlowEvent, type FlowTracer, type Transport } from "./flowTrace"; +import { ArrowRight, Dismiss } from "@/shared/assets/icons"; +import Button, { sizes as buttonSizes, variants as buttonVariants } from "@/shared/components/Button"; + +interface FlowTraceContextValue { + events: FlowEvent[]; + open: boolean; + setOpen: (open: boolean) => void; + reset: () => void; + makeTracer: (transport: Transport) => FlowTracer; +} + +const FlowTraceContext = createContext(null); + +export function useFlowTrace(): FlowTraceContextValue { + const ctx = useContext(FlowTraceContext); + if (!ctx) throw new Error("useFlowTrace must be used within a FlowTraceProvider"); + return ctx; +} + +export function FlowTraceProvider({ children }: { children: ReactNode }) { + const [events, setEvents] = useState([]); + const [open, setOpen] = useState(true); + const idRef = useRef(0); + + const add = useCallback((e: FlowEvent) => setEvents((prev) => [...prev, e]), []); + const update = useCallback( + (id: number, patch: Partial) => + setEvents((prev) => prev.map((e) => (e.id === id ? { ...e, ...patch } : e))), + [], + ); + const reset = useCallback(() => setEvents([]), []); + + const makeTracer = useCallback( + (transport: Transport): FlowTracer => ({ + request: (target, title, detail) => { + const id = (idRef.current += 1); + add({ + id, + channel: target === "fleet" ? "smv-fleet" : "smv-miner", + title, + detail, + method: /^(GET|POST|PUT|PATCH|DELETE)\b/.exec(title)?.[0], + proxied: target === "miner" && transport === "proxy", + status: "pending", + }); + return { + ok: (d) => update(id, { status: "ok", detail: d ?? detail }), + fail: (m) => update(id, { status: "error", detail: m ?? detail }), + }; + }, + seam: (title, detail) => add({ id: (idRef.current += 1), channel: "seam", title, detail, status: "ok" }), + adapter: (title, detail) => { + // The adapter mapping is the story for the S3 abstraction prototype (its + // fleet + direct contexts). S2 (proxy) leads with the version seam, and + // S1 (fleet-native) reads the proto directly with no adapter — suppress + // the adapter row in both so each prototype's story stays distinct. + if (transport === "proxy" || transport === "fleet-native") return; + add({ id: (idRef.current += 1), channel: "adapter", title, detail, status: "ok" }); + }, + note: (channel, title, detail) => add({ id: (idRef.current += 1), channel, title, detail, status: "ok" }), + }), + [add, update], + ); + + const value = useMemo(() => ({ events, open, setOpen, reset, makeTracer }), [events, open, reset, makeTracer]); + + return {children}; +} + +const STATUS_GLYPH: Record = { + pending: { mark: "running…", className: "text-text-primary-30" }, + ok: { mark: "✓", className: "text-intent-success-fill" }, + error: { mark: "✕", className: "text-intent-critical-fill" }, +}; + +function LegendItem({ channel }: { channel: FlowChannel }) { + const meta = CHANNEL_META[channel]; + return ( +
+ + {meta.label} +
+ ); +} + +const METHOD_BADGE: Record = { + GET: "bg-surface-10 text-text-primary-50", + POST: "bg-text-primary text-surface-base", + PUT: "bg-text-primary text-surface-base", + PATCH: "bg-text-primary text-surface-base", + DELETE: "bg-text-primary text-surface-base", +}; + +function EventRow({ event }: { event: FlowEvent }) { + const meta = CHANNEL_META[event.channel]; + const status = STATUS_GLYPH[event.status]; + // A write (POST/…) gets a solid pill; a read (GET) a muted one — so mutations + // stand out from the reads around them regardless of channel color. + const method = event.method; + const title = method ? event.title.slice(method.length).trim() : event.title; + return ( +
+
+ + {method ? ( + {method} + ) : null} + {title} + + {status.mark} +
+ {event.detail ? {event.detail} : null} +
+ {meta.label} + {event.proxied ? ( + via fleet proxy + ) : null} +
+
+ ); +} + +/** Fixed right drawer; width is managed by the parent shell's padding. */ +export function FlowPane() { + const { events, open, setOpen } = useFlowTrace(); + + if (!open) { + return ( + + ); + } + + return ( + + ); +} diff --git a/client/src/protoFleet/prototypes/shared/MinerViewFrame.tsx b/client/src/protoFleet/prototypes/shared/MinerViewFrame.tsx new file mode 100644 index 0000000000..b33546649b --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/MinerViewFrame.tsx @@ -0,0 +1,75 @@ +/** + * Chrome around a rendered single-miner view. + * + * The miner view itself is the clean canvas (`children`). This frame adds only a + * thin top bar: an optional left action to step back to the picker, and a + * right-aligned "Details" trigger that tucks the informational chrome (identity + * + data path) into a modal so it never crowds the actual view. + */ +import { ReactNode, useState } from "react"; + +import { ArrowLeftCompact, Info } from "@/shared/assets/icons"; +import Button, { sizes as buttonSizes, variants as buttonVariants } from "@/shared/components/Button"; +import Modal, { sizes as modalSizes } from "@/shared/components/Modal"; + +export interface MinerViewFrameProps { + /** Short label for what's mounted, shown in the top bar. */ + title: string; + /** Optional "back to the picker" affordance (list / connect form). */ + leftAction?: { label: string; onClick: () => void }; + /** Informational chrome shown behind the "Details" trigger. */ + details?: ReactNode; + /** The clean single-miner-view canvas. */ + children: ReactNode; +} + +export function MinerViewFrame({ title, leftAction, details, children }: MinerViewFrameProps) { + const [detailsOpen, setDetailsOpen] = useState(false); + + return ( +
+
+
+ {leftAction ? ( +
+ {details ? ( +
+ + {details ? ( + setDetailsOpen(false)} + title="Miner details" + size={modalSizes.large} + divider + buttons={[{ text: "Done", variant: buttonVariants.secondary, dismissModalOnClick: true }]} + > + {details} + + ) : null} + + {/* The single-miner view sits on the app surface, set off from the grey + prototype chrome above so it reads as an app preview. */} +
{children}
+
+ ); +} diff --git a/client/src/protoFleet/prototypes/shared/MinersList.tsx b/client/src/protoFleet/prototypes/shared/MinersList.tsx new file mode 100644 index 0000000000..6c78e47392 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/MinersList.tsx @@ -0,0 +1,82 @@ +/** + * A deliberately dumbed-down "Miners tab" — a small tabular list that stands in + * for ProtoFleet's fleet miners table. Each row (name · IP · firmware) opens + * that miner's single-miner view. Strategy 2 lists two fake rigs (one per MDK + * version); Strategy 3 lists the first-party rigs the fleet has discovered. + */ +import { STATUS_META } from "./status"; +import type { MinerStatus } from "./types"; +import { ArrowRight } from "@/shared/assets/icons"; +import Card, { cardType } from "@/shared/components/Card"; +import Chip from "@/shared/components/Chip"; +import StatusCircle, { variants as statusVariants } from "@/shared/components/StatusCircle"; + +export interface MinerListItem { + id: string; + name: string; + /** Badge, e.g. "MDK v1" — the axis strategy 2 makes visible. */ + mdkVersion?: string; + ipAddress?: string; + /** Firmware revision string, shown in its own column. */ + firmware?: string; + status?: MinerStatus; +} + +export interface MinersListProps { + title: string; + items: MinerListItem[]; + onSelect: (item: MinerListItem) => void; + /** Row id currently connecting, if any. */ + busyId?: string | null; + emptyMessage?: string; +} + +const COLS = "grid grid-cols-[1.6fr_1fr_1fr_auto] items-center gap-4 px-4"; + +export function MinersList({ title, items, onSelect, busyId, emptyMessage }: MinersListProps) { + return ( + + {items.length === 0 ? ( +
{emptyMessage ?? "No miners to show."}
+ ) : ( +
+
+ Name + IP address + Firmware + +
+ {items.map((m) => { + const status = m.status ? STATUS_META[m.status] : null; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/client/src/protoFleet/prototypes/shared/SingleMinerDetails.tsx b/client/src/protoFleet/prototypes/shared/SingleMinerDetails.tsx new file mode 100644 index 0000000000..672f729562 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/SingleMinerDetails.tsx @@ -0,0 +1,57 @@ +/** + * The informational chrome for a connected miner — identity + the "how did this + * data get here" data-path ribbon. Deliberately split out of + * so it can be tucked into the MinerViewFrame "Details" modal instead of + * crowding the actual view. + */ +import { ReactNode } from "react"; + +import type { DataPathStep, SingleMinerSnapshot } from "./types"; + +function IdentityRow({ label, value }: { label: string; value: ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} + +function DataPathRibbon({ steps, source, note }: { steps: DataPathStep[]; source: string; note?: string }) { + return ( +
+
Data path — {source}
+
+ {steps.map((step, i) => ( +
+
+
{step.label}
+ {step.detail ?
{step.detail}
: null} +
+ {i < steps.length - 1 ? : null} +
+ ))} +
+ {note ?
{note}
: null} +
+ ); +} + +export function SingleMinerDetails({ snapshot }: { snapshot: SingleMinerSnapshot }) { + const { identity } = snapshot; + return ( +
+
+
Identity
+ + + + + + + {identity.ipAddress ? : null} +
+ +
+ ); +} diff --git a/client/src/protoFleet/prototypes/shared/SingleMinerView.tsx b/client/src/protoFleet/prototypes/shared/SingleMinerView.tsx new file mode 100644 index 0000000000..ae507866eb --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/SingleMinerView.tsx @@ -0,0 +1,143 @@ +/** + * The shared, backend-agnostic single-miner view — the clean "hero" canvas. + * + * It renders purely from a `SingleMinerSnapshot`: status + three KPIs + the + * hashboard/ASIC mini-grid + one control. It knows nothing about where the data + * came from — the informational chrome (identity, data-path) lives in + * , surfaced through the MinerViewFrame "Details" modal so + * it never crowds the actual view. + * + * Strategy 1 (fleet-native) and Strategy 3 (adapter) both feed this exact + * component; only the data plumbing differs between them. + */ +import { useState } from "react"; + +import { toAsicData } from "./asicData"; +import { STATUS_META } from "./status"; +import type { MinerControlAction, SingleMinerActions, SingleMinerSnapshot } from "./types"; +import AsicTablePreview from "@/shared/components/AsicTablePreview"; +import Button, { sizes as buttonSizes, variants as buttonVariants } from "@/shared/components/Button"; +import Card, { cardType } from "@/shared/components/Card"; +import Metric from "@/shared/components/Metric"; +import StatusCircle, { variants as statusVariants } from "@/shared/components/StatusCircle"; + +function formatNumber(value: number | null, digits = 1): string { + if (value === null || Number.isNaN(value)) return "—"; + return value.toLocaleString(undefined, { maximumFractionDigits: digits }); +} + +function kpiValue(value: number | null, unit: string) { + return ( + + {formatNumber(value)} + {unit} + + ); +} + +function AsicGrid({ snapshot }: { snapshot: SingleMinerSnapshot }) { + if (snapshot.hashboards.length === 0) { + return ( +
+ No hashboard / ASIC data available from this backend. +
+ ); + } + return ( +
+ {snapshot.hashboards.map((hb) => ( + {hb.serialNumber}} + > +
+ {formatNumber(hb.hashrateThs)} TH/s · {formatNumber(hb.tempC, 0)} °C +
+ +
+ ))} +
+ ); +} + +const CONTROL_LABELS: Record = { + reboot: "Reboot", + pause: "Pause mining", + resume: "Resume mining", +}; + +function ControlBar({ snapshot, actions }: { snapshot: SingleMinerSnapshot; actions: SingleMinerActions }) { + const [pending, setPending] = useState(null); + if (!actions.onControl) return null; + + const primary: MinerControlAction = snapshot.status === "mining" ? "pause" : "resume"; + const buttons: MinerControlAction[] = [primary, "reboot"]; + + const run = async (action: MinerControlAction) => { + setPending(action); + try { + await actions.onControl?.(action); + } finally { + setPending(null); + } + }; + + return ( +
+ {buttons.map((action, i) => ( +
+ ); +} + +export interface SingleMinerViewProps { + snapshot: SingleMinerSnapshot; + actions?: SingleMinerActions; +} + +export function SingleMinerView({ snapshot, actions = {} }: SingleMinerViewProps) { + const status = STATUS_META[snapshot.status]; + return ( +
+ {/* Status header — identity/data-path live behind the details modal */} +
+
+ {snapshot.identity.name} + + + {status.label} + +
+ +
+ + {/* KPI tiles — one row, evenly spread */} + + + + + + + {/* ASIC mini-grid — the stressor; one card per hashboard, 3 to a row */} + + + {snapshot.updatedAt ? ( +
+ Updated {new Date(snapshot.updatedAt).toLocaleTimeString()} +
+ ) : null} +
+ ); +} diff --git a/client/src/protoFleet/prototypes/shared/adapter.ts b/client/src/protoFleet/prototypes/shared/adapter.ts new file mode 100644 index 0000000000..1a96ede9ce --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/adapter.ts @@ -0,0 +1,25 @@ +/** + * The adapter seam shared by Strategy 1 (fleet-native) and Strategy 3. + * + * An adapter is the only backend-specific code in the abstraction strategy: + * it knows how to reach one kind of backend (fleet server, MDK v1 REST, MDK v2 + * consolidated) and map it into the single `SingleMinerSnapshot` contract that + * renders. Everything downstream is identical. + */ +import type { FlowTracer } from "./flowTrace"; +import type { MinerControlAction, SingleMinerSnapshot } from "./types"; + +export interface SingleMinerAdapter { + /** Human label for the backend, surfaced in the data-path ribbon. */ + readonly source: string; + /** + * One read → one normalized snapshot. Pass a `tracer` to narrate the calls + * and transforms into the data-flow pane (optional; defaults to no tracing). + */ + fetchSnapshot(signal?: AbortSignal, tracer?: FlowTracer): Promise; + /** + * Optional — not every backend exposes controls to the prototype. Pass a + * `tracer` to narrate the POST into the data-flow pane. + */ + control?(action: MinerControlAction, tracer?: FlowTracer): Promise; +} diff --git a/client/src/protoFleet/prototypes/shared/asicData.ts b/client/src/protoFleet/prototypes/shared/asicData.ts new file mode 100644 index 0000000000..9028819d8c --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/asicData.ts @@ -0,0 +1,26 @@ +/** + * Maps the prototype's per-hashboard ASIC cells onto the shared + * grid contract ({ row, col, value }). + * + * The prototype snapshot only carries a flat 0-based `index` per board, so we + * lay the chips out into a roughly-wide grid. `value` is the chip temperature + * (the heatmap axis AsicTablePreview colors on); an "off" chip becomes `null` + * so the shared component renders it as an empty cell. + */ +import type { AsicCell } from "./types"; +import type { AsicData } from "@/shared/components/AsicTablePreview"; + +/** Columns for a board of `count` chips — wide-ish so boards read as strips. */ +export function gridColumns(count: number): number { + if (count <= 0) return 1; + return Math.max(1, Math.ceil(Math.sqrt(count * 4))); +} + +export function toAsicData(asics: AsicCell[]): AsicData[] { + const cols = gridColumns(asics.length); + return asics.map((asic, i) => ({ + row: Math.floor(i / cols), + col: i % cols, + value: asic.health === "off" ? null : asic.tempC, + })); +} diff --git a/client/src/protoFleet/prototypes/shared/flowTrace.ts b/client/src/protoFleet/prototypes/shared/flowTrace.ts new file mode 100644 index 0000000000..6b65438187 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/flowTrace.ts @@ -0,0 +1,109 @@ +/** + * Data-flow tracing for the Lab. + * + * Each strategy narrates its own plumbing: as an adapter runs, it emits flow + * events (API requests, adapter transforms, proxy hops) that the collapsible + * renders like a color-coded network tab. The colors encode *which + * server the call ultimately targets*, so the three strategies read differently + * at a glance even though they render the identical view. + */ + +/** Which hop an event belongs to — drives its color. */ +export type FlowChannel = + | "smv-fleet" // ProtoFleet client → Fleet server (Connect RPC) + | "fleet-miner" // Fleet server → miner (server-side telemetry collection) + | "smv-miner" // ProtoFleet client → miner (REST, direct or via proxy) + | "seam" // version-aware seam: probe fw, pick which client to render (S2, no network) + | "adapter"; // adapter layer: map the generic data getter → backend API calls (S3, no network) + +/** How the page reaches the miner — decides proxy annotation + which story to tell. */ +export type Transport = + | "fleet" // S3 fleet context: FleetAdapter maps the getter → fleet RPC (adapter row shown) + | "fleet-native" // S1: the view reads the fleet proto directly, no adapter (row suppressed) + | "proxy" // S2: version seam + proxied miner calls (adapter row suppressed) + | "direct"; // S3 direct MDK contexts: adapter maps getter → REST (adapter row shown) + +export interface FlowEvent { + id: number; + channel: FlowChannel; + /** Primary line, e.g. "GET /api/v2/miner" or "Select MDK v2 adapter". */ + title: string; + detail?: string; + /** HTTP verb parsed from the title, when the event is a request. */ + method?: string; + /** True when the miner call rides the fleet minerproxy. */ + proxied?: boolean; + status: "pending" | "ok" | "error"; +} + +export interface FlowRequestHandle { + ok(detail?: string): void; + fail(message?: string): void; +} + +/** The narrow surface adapters use to narrate themselves. */ +export interface FlowTracer { + /** A network call. `target` is the server it's ultimately bound for. */ + request(target: "fleet" | "miner", title: string, detail?: string): FlowRequestHandle; + /** The version-aware seam: probe firmware, pick which client to render (S2). */ + seam(title: string, detail?: string): void; + /** The adapter layer: map the view's generic data getter → backend API calls (S3). */ + adapter(title: string, detail?: string): void; + /** A non-request annotation on a specific channel (e.g. server-side collection). */ + note(channel: FlowChannel, title: string, detail?: string): void; +} + +/** No-op tracer so adapters can run untraced (tests, control refresh). */ +export const NO_TRACE: FlowTracer = { + request: () => ({ ok: () => {}, fail: () => {} }), + seam: () => {}, + adapter: () => {}, + note: () => {}, +}; + +export interface ChannelMeta { + label: string; + /** dot / bar background, text color, tint background utility classes. */ + dot: string; + text: string; + bar: string; + tint: string; +} + +export const CHANNEL_META: Record = { + "smv-fleet": { + label: "Client → Fleet server", + dot: "bg-intent-info-fill", + text: "text-intent-info-fill", + bar: "border-intent-info-fill", + tint: "bg-intent-info-10", + }, + "fleet-miner": { + label: "Fleet server → Miner (collection)", + dot: "bg-[#8b5cf6]", + text: "text-[#8b5cf6]", + bar: "border-[#8b5cf6]", + tint: "bg-[#8b5cf6]/10", + }, + "smv-miner": { + label: "Client → Miner (REST)", + dot: "bg-intent-success-fill", + text: "text-intent-success-fill", + bar: "border-intent-success-fill", + tint: "bg-intent-success-10", + }, + seam: { + label: "Version seam", + dot: "bg-[#14b8a6]", + text: "text-[#14b8a6]", + bar: "border-[#14b8a6]", + tint: "bg-[#14b8a6]/10", + }, + adapter: { + label: "Adapter layer", + dot: "bg-intent-warning-fill", + text: "text-intent-warning-fill", + bar: "border-intent-warning-fill", + tint: "bg-intent-warning-10", + }, +}; diff --git a/client/src/protoFleet/prototypes/shared/mockData.ts b/client/src/protoFleet/prototypes/shared/mockData.ts new file mode 100644 index 0000000000..8fd7c16df9 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/mockData.ts @@ -0,0 +1,77 @@ +/** + * Mock snapshot builders for the Lab. Used to stand up the shared view before + * any strategy is wired to real data, and as a deterministic fallback. + */ +import type { AsicCell, AsicHealth, HashboardSummary, SingleMinerSnapshot } from "./types"; + +function buildAsics(count: number, baseTemp: number, seed: number): AsicCell[] { + return Array.from({ length: count }, (_, index) => { + // Deterministic pseudo-variation so the grid looks alive without randomness + // (Math.random is banned in some contexts and hurts reproducibility). + const wobble = ((index * 7 + seed * 13) % 11) - 5; + const tempC = baseTemp + wobble; + let health: AsicHealth = "ok"; + if (tempC >= baseTemp + 4) health = "warn"; + if (tempC >= baseTemp + 8) health = "error"; + if ((index * 3 + seed) % 37 === 0) health = "off"; + return { + index, + tempC: health === "off" ? null : tempC, + hashrateThs: health === "off" ? 0 : 1.1 + wobble / 50, + health, + }; + }); +} + +function buildHashboards(boards: number, asicsPerBoard: number): HashboardSummary[] { + return Array.from({ length: boards }, (_, index) => { + const asics = buildAsics(asicsPerBoard, 62 + index * 2, index + 1); + const live = asics.filter((a) => a.health !== "off"); + const hashrateThs = live.reduce((sum, a) => sum + (a.hashrateThs ?? 0), 0); + const tempC = live.length ? Math.max(...live.map((a) => a.tempC ?? 0)) : null; + return { + serialNumber: `HB-${1000 + index}`, + index, + tempC, + hashrateThs, + asics, + }; + }); +} + +export interface MockSnapshotOptions { + name?: string; + mdkVersion?: string; + source: string; + dataPath: SingleMinerSnapshot["dataPath"]; + boards?: number; + asicsPerBoard?: number; + ipAddress?: string; +} + +export function buildMockSnapshot(opts: MockSnapshotOptions): SingleMinerSnapshot { + const hashboards = buildHashboards(opts.boards ?? 3, opts.asicsPerBoard ?? 66); + const hashrateThs = hashboards.reduce((sum, hb) => sum + (hb.hashrateThs ?? 0), 0); + const tempC = Math.max(...hashboards.map((hb) => hb.tempC ?? 0)); + return { + identity: { + name: opts.name ?? "proto-sim-01", + model: "Proto Alpha", + firmware: "1.4.2", + mdkVersion: opts.mdkVersion ?? "MDK v1", + macAddress: "02:42:0a:ff:00:01", + serialNumber: "PROTO-SIM-0001", + ipAddress: opts.ipAddress, + }, + status: "mining", + kpis: { + hashrateThs, + tempC, + powerW: 3200 + Math.round(hashrateThs * 4), + }, + hashboards, + dataPath: opts.dataPath, + source: opts.source, + updatedAt: undefined, + }; +} diff --git a/client/src/protoFleet/prototypes/shared/status.ts b/client/src/protoFleet/prototypes/shared/status.ts new file mode 100644 index 0000000000..2112b966e3 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/status.ts @@ -0,0 +1,15 @@ +/** + * Shared mapping from the prototype's `MinerStatus` onto the design-system + * StatusCircle status + a human label. Used by both the miner view header and + * the mini miners list so status reads consistently everywhere. + */ +import type { MinerStatus } from "./types"; +import { statuses } from "@/shared/components/StatusCircle"; +import type { StatusCircleStatus } from "@/shared/components/StatusCircle/constants"; + +export const STATUS_META: Record = { + mining: { label: "Mining", circle: statuses.normal }, + paused: { label: "Paused", circle: statuses.warning }, + offline: { label: "Offline", circle: statuses.inactive }, + error: { label: "Error", circle: statuses.error }, +}; diff --git a/client/src/protoFleet/prototypes/shared/types.ts b/client/src/protoFleet/prototypes/shared/types.ts new file mode 100644 index 0000000000..ca2b693f68 --- /dev/null +++ b/client/src/protoFleet/prototypes/shared/types.ts @@ -0,0 +1,86 @@ +/** + * The distilled single-miner contract shared by the prototype strategies. + * + * Deliberately minimal: identity + three KPIs + a hashboard/ASIC mini-grid + + * one control. The ASIC grid is the intentional stressor — it's the one piece + * the fleet server does not expose today, so every strategy has to prove how it + * would source per-component data. + * + * Every strategy (fleet-native, adapter) maps its backend into this shape and + * renders the same . The proxy strategy deliberately does NOT + * use this — its whole point is rendering per-version clients verbatim. + */ + +export type MinerStatus = "mining" | "paused" | "offline" | "error"; + +/** Health of a single ASIC cell, drives the mini-grid color. */ +export type AsicHealth = "ok" | "warn" | "error" | "off"; + +export interface AsicCell { + /** 0-based position within the hashboard. */ + index: number; + tempC: number | null; + hashrateThs: number | null; + health: AsicHealth; +} + +export interface HashboardSummary { + serialNumber: string; + /** 0-based slot index on the control board. */ + index: number; + tempC: number | null; + hashrateThs: number | null; + asics: AsicCell[]; +} + +export interface MinerIdentity { + name: string; + model: string; + firmware: string; + /** e.g. "MDK v1" / "MDK v2" — the axis the proxy/adapter strategies branch on. */ + mdkVersion: string; + macAddress: string; + serialNumber: string; + /** Present when we reached the device directly (adapter / single-miner mode). */ + ipAddress?: string; +} + +export interface MinerKpis { + hashrateThs: number | null; + tempC: number | null; + powerW: number | null; +} + +/** One hop in the "how did this data get here" ribbon. */ +export interface DataPathStep { + label: string; + /** Short note, e.g. "Connect RPC", "REST /api/v1", "reverse proxy". */ + detail?: string; +} + +export type MinerControlAction = "reboot" | "pause" | "resume"; + +export interface SingleMinerSnapshot { + identity: MinerIdentity; + status: MinerStatus; + kpis: MinerKpis; + hashboards: HashboardSummary[]; + /** Left→right chain rendered by the data-path ribbon. */ + dataPath: DataPathStep[]; + /** + * Optional caveat rendered beneath the data-path ribbon — used to flag an + * FPO (for-placement-only) step, e.g. a synthesized ASIC grid, and what a + * production build would actually require. Mark the step it refers to with a + * trailing "*". + */ + dataPathNote?: string; + /** Human label for where this snapshot came from, e.g. "Fleet server". */ + source: string; + /** ISO timestamp of the reading, if known. */ + updatedAt?: string; +} + +/** Handlers a strategy wires to the view's control button(s). */ +export interface SingleMinerActions { + onControl?: (action: MinerControlAction) => void | Promise; +} diff --git a/client/src/protoFleet/router.tsx b/client/src/protoFleet/router.tsx index 17f457604c..d27dab0769 100644 --- a/client/src/protoFleet/router.tsx +++ b/client/src/protoFleet/router.tsx @@ -94,6 +94,16 @@ const SettingsIntegrations = lazy(importSettingsIntegrations); const SettingsUpdates = lazy(importSettingsUpdates); const SiteDetailPage = lazy(importSiteDetailPage); const BuildingPage = lazy(importBuildingPage); + +// --- Prototype Lab (throwaway; migrate-single-miner-to-fleet branch only) --- +// Dev-only preview surface for the single-miner-view strategies. Lazily loaded +// and self-contained under prototypes/, so removal is: delete this block + +// prototypes/. Not wired into routePrefetch (prefetch is just an optimization). +const LabLayout = lazy(() => import("./prototypes/lab/LabLayout")); +const LabIndex = lazy(() => import("./prototypes/lab/LabIndex")); +const FleetNativePage = lazy(() => import("./prototypes/fleetNative/FleetNativePage")); +const ProxyVersionedPage = lazy(() => import("./prototypes/proxyVersioned/ProxyVersionedPage")); +const AdapterPage = lazy(() => import("./prototypes/adapter/AdapterPage")); const FleetLayout = lazy(importFleetLayout); const FleetBuildingsPage = lazy(importFleetBuildingsPage); const FleetSitesPage = lazy(importFleetSitesPage); @@ -376,6 +386,22 @@ const router = createBrowserRouter([ // Error routes (fullscreen) createRoute("/fleet-down", , { fullscreen: true }), + + // --- Prototype Lab (throwaway) --- + { + path: "/lab", + element: ( + + + + ), + children: [ + { index: true, element: }, + { path: "fleet-native", element: }, + { path: "proxy", element: }, + { path: "adapter", element: }, + ], + }, ]); export default router; diff --git a/docs/plans/2026-07-28-single-miner-views-on-fleet-backend-plan.md b/docs/plans/2026-07-28-single-miner-views-on-fleet-backend-plan.md new file mode 100644 index 0000000000..2e422f658f --- /dev/null +++ b/docs/plans/2026-07-28-single-miner-views-on-fleet-backend-plan.md @@ -0,0 +1,422 @@ +--- +title: "Migrate single-miner views to render from the fleet backend" +date: 2026-07-28 +status: draft +type: plan +--- + +# Migrate single-miner views to render from the fleet backend + +## Context & goal + +Today the single-miner UI ("ProtoOS") is built to talk **directly to on-miner +HTTP/gRPC APIs**. It is served two ways: + +1. **Standalone on the miner** — ProtoOS is bundled onto the device (ProtoOS + firmware) and served by the miner's embedded web/API server. Browser hits + `/api/v1/*` on the miner itself. +2. **Embedded inside ProtoFleet** — the fleet UI opens a single-miner view under + `/miners/:id/*` (`client/src/protoFleet/components/SingleMinerWrapper/`). It + reuses the *same* ProtoOS React app, but points the API client at the fleet + server, which **reverse-proxies** every `/api/v1/*` call through to the live + miner (`server/internal/handlers/minerproxy/handler.go` — handles login, + token caching, TLS to the device). + +So even the "fleet" single-miner view is really ProtoOS-over-a-proxy. Both modes +have a hard dependency on the miner being **online, reachable, and running a +ProtoOS-compatible API**. + +**Goal:** render single-miner views from **fleet-collected data** instead of +proxying to the miner. Benefits the user called out: + +- We could stop serving ProtoOS on the miner (or reduce it to a thin status + page), simplifying the device image. +- We could render a single-miner view for **any** miner regardless of OS / + firmware / vendor — including offline miners (from last-known data) and + non-ProtoOS devices that a plugin can normalize. + +The central risk the user flagged — **API/data discrepancies**, especially ASIC +data — is real and is the gating constraint. This document maps the current +implementation, quantifies the gap, and proposes a phased plan. + +--- + +## Related discovery docs + +- [Backend-agnostic ProtoOS via a canonical resolver layer](./2026-07-29-protoos-backend-agnostic-abstraction-discovery.md) + — could one UI render from either the on-miner REST API or the fleet RPCs via + a resolver map? (Sound seam; blocked by missing fleet capabilities for a full + port.) +- [ProtoFleet Single-Miner mode](./2026-07-29-protofleet-single-miner-mode-discovery.md) + — boot ProtoFleet pointed at one device as a ProtoOS-on-miner replacement. + (Small as a client mode over the standard server; large as a lite appliance.) + +## Locked assumptions (rev 2 — 2026-07-28) + +Per direction, the following are now fixed and shape the plan below: + +1. **ProtoOS-on-miner goes away.** No requirement to keep the full app on the + device. A simpler on-device status page may be built later as a **separate** + effort — out of scope here. +2. **Fold ProtoOS into ProtoFleet.** We collapse from two client apps to one. + The single-miner experience becomes a first-class part of ProtoFleet, not a + proxied embed of a separate app. Directory structure simplifies accordingly. +3. **Target = full parity** with today's ProtoOS single-miner view, with the + explicit allowance to **reduce data granularity/freshness** to whatever + ProtoFleet can reasonably support (e.g. 10-min device-level history instead of + 15s real-time; aggregated instead of per-ASIC where necessary). + +Consequence of #1: the `minerproxy` reverse-proxy and all per-miner web-auth +(`login`/`refresh`/pairing) are **removed**, not migrated. Nothing renders by +talking to the miner's `/api/v1/*` anymore; everything comes from fleet RPCs. + +## Current architecture (as-is) + +### ProtoOS data layer + +ProtoOS is a REST client generated from the miner's OpenAPI spec +(`client/src/protoOS/api/generatedApi.ts`), wrapped in ~37 hooks under +`client/src/protoOS/api/hooks/`. The base URL is injected by +`MinerHostingContext` (`mode: "direct" | "fleet"`); in fleet mode the base URL +points at the fleet proxy per device. + +Data domains and their miner endpoints: + +| Domain | ProtoOS hook / endpoint | R/W | +| --- | --- | --- | +| Current telemetry (miner/hashboard/asic/psu) | `getCurrentTelemetry` `GET /telemetry?level=…` (15s poll) | R | +| Per-hashboard ASIC stats | `getHashboardStatus` `GET /hashboards/{hb_sn}` | R | +| Per-ASIC detail | `getAsicStatus` `GET /hashboards/{hb_sn}/{asic_id}` | R | +| Historical timeseries (miner/hb/asic) | `getTimeSeries` `POST /timeseries` (1m/5m/15m) | R | +| Mining status / target | `getMiningStatus`, `getMiningTarget` | R | +| Hardware inventory | `getHardware` `GET /hardware` | R | +| Network config | `getNetwork` / `setNetworkConfig` | R/W | +| Cooling | `getCooling` / `setCoolingMode` | R/W | +| Pools | `listPools` / `createPools` / `editPool` / `deletePool` / `testPoolConnection` | R/W | +| System info / status / tag | `getSystemInfo`, `getSystemStatus`, `getSystemTag`/`setSystemTag` | R/W | +| Errors / logs | `getErrors`, `getSystemLogs`, `downloadLogs` | R | +| Control | `startMining`, `stopMining`, `rebootSystem`, `locateSystem`, firmware/PSU update | W | +| Auth | `login`, `setPassword`, `changePassword` | R/W | + +The on-miner API behind these (proto in `proto-rig-api/grpc/`) is richer than +the REST surface: `MinerDataApi`, `MinerCommandApi`, `MinerSystemApi`, +`MinerTelemetryApi` (server-streaming `StreamMetrics` / `StreamLogs`), +`MinerDebugApi`, plus `GetAsicMetadata` (binning/wafer/die) and NATS async +health streams. + +### Fleet backend data layer + +- **Collector:** `server/internal/domain/telemetry/service.go` polls each device + every **10 minutes** (`defaultDevicePollInterval`), via plugins. A 5s + broadcaster and on-demand `RefreshMiners` RPC exist for freshness. +- **In-memory model:** the collector builds a full + `models/v2.DeviceMetrics` — including `HashBoards[] → ASICs[]`, `PSUMetrics[]`, + `FanMetrics[]`, per-component temps/voltages/frequency + (`server/internal/domain/telemetry/models/v2/component_metrics.go`). +- **Persistence (the crux):** + `server/internal/infrastructure/timescaledb/telemetry_store.go` maps that + model into `InsertDeviceMetricsParams` — and writes **only device-level + scalars**: `hash_rate_hs, temp_c, fan_rpm, power_w, efficiency_jh, voltage_v, + current_a, inlet/outlet/ambient_temp_c, chip_count, chip_frequency_mhz, + health`. **The `HashBoards`/`ASICs`/`PSUMetrics`/`FanMetrics` arrays are + discarded — they are never persisted to any table or JSON column.** +- **Storage:** TimescaleDB hypertable `device_metrics` (raw 30d) + continuous + aggregates `device_metrics_hourly` (90d) / `device_metrics_daily` (3y), plus + `device_status_*` for temp-distribution/uptime histograms. +- **Per-device read RPCs today:** `FleetManagementService.LookupMinerByIdentifier` + and `ListMinerStateSnapshots` (identity, placement [site/building/rack, #793], + status, and current power/temp/hashrate/efficiency measurements); + `TelemetryService.GetCombinedMetrics` / `StreamCombinedMetricUpdates` + (historical + live device-level candles); `GetMinerPoolAssignments`, + `GetMinerCoolingMode`. +- **Control RPCs today:** `MinerCommandService` already has `Reboot`, + `Start/StopMining`, `SetCoolingMode`, `SetPowerTarget`, `UpdateMiningPools`, + `UpdateMinerPassword`, `BlinkLED`, `DownloadLogs`, `FirmwareUpdate`, `Unpair` + — async, batch-based (`batch_identifier` + `StreamCommandBatchUpdates`). + +--- + +## Data gap analysis (fleet vs. miner) + +This is the heart of the migration. Grouped by how hard the gap is to close. + +### A. Already available on fleet (low effort — rebind hooks) + +- Device-level current + historical hashrate / temp / power / efficiency + (`GetCombinedMetrics`, `MinerStateSnapshot`). +- Identity, firmware version, placement, status. +- Pool assignments (read: `GetMinerPoolAssignments`). +- Cooling mode (read: `GetMinerCoolingMode`). +- Control actions (write): reboot, start/stop, cooling, power target, pools, + password, LED, logs download, firmware — via `MinerCommandService`. + +### B. Collected but thrown away (medium effort — persist what we already build) + +The collector already produces this every poll; we just don't store it: + +- **Per-hashboard** hashrate/temp/voltage/current/inlet/outlet/chip-count/freq. +- **Per-ASIC** temp / frequency / voltage / hashrate. +- **Per-PSU** input/output power/voltage/current, hotspot temp, efficiency. +- **Per-fan** RPM / percent / temp. + +Closing this means: add storage (see options below), stop discarding the arrays +in `telemetry_store.go`, and add read RPCs. **But note the cadence problem:** +even persisted, this data would be 10-minute-granular, not the ~15s real-time / +1m timeseries ProtoOS shows today. Per-ASIC timeseries at 10-min resolution over +3 years is also a large cardinality increase (chips-per-board × boards × +devices) — needs a deliberate schema + retention decision, not a naive column +add. + +### C. Not collected at all (high effort — new collection + storage) + +- **Real-time telemetry** (ProtoOS polls the miner every 15s; fleet is 10-min). + Live per-ASIC/hashboard charts and the "temperature/:serial" ASIC heatmap have + no fleet equivalent at that fidelity without either (a) an on-demand + passthrough to the miner, or (b) a much faster/streaming collector. +- **ASIC metadata** (`GetAsicMetadata`: lot/wafer/die/binning) — not collected. +- **Detailed error/recovery stats** (`MinerDebugApi`) — not collected. +- **Live log streaming** (`StreamLogs`) — fleet has `DownloadLogs` (batch) only. +- **Pool connection test** (`testPoolConnection`) — inherently a live miner op. +- **Network config read/write** (`getNetwork`/`setNetwork`) — no fleet RPC. +- **Full hardware inventory** (`getHardware` per-slot firmware/bootloader/chip + IDs) — only partially represented on fleet. + +### D. Structurally live-only (should probably stay a miner call — or drop) + +Some operations are meaningless against stored data and only make sense against +the live device: `testPoolConnection`, `locateSystem` (LED), initial onboarding +(network setup, first-boot password), firmware upload streaming. These argue for +keeping *some* thin device-reachability path even post-migration, OR accepting +that these live only in an on-miner status page. + +--- + +## Full ProtoOS → ProtoFleet API parity map + +This is the exhaustive inventory of the API calls the ProtoOS single-miner view +**actually makes** (the 27 live endpoints behind its ~37 hooks; the generated +`curtailment`/`ssh`/`secure`/`unlock`/per-`hbSn` `hashrate|power|efficiency` +endpoints are dead generated code, not wired into any screen), mapped to the +ProtoFleet fleet-server analog and the work to reach parity. + +Legend: ✅ exists on fleet · 🟡 partial / semantics differ · 🔴 no analog. + +### Reads — telemetry & metrics + +| # | ProtoOS call (endpoint) | Shows | Fleet analog | Parity | Work to reach parity | +| --- | --- | --- | --- | --- | --- | +| 1 | `getCurrentTelemetry` `GET /telemetry?level=miner,hashboard,asic,psu` (15s) | Live device + per-hashboard/ASIC/PSU telemetry | Device-level: `MinerStateSnapshot`, `TelemetryService.StreamCombinedMetricUpdates` | 🟡 | Device-level ✅ (accept ~10-min / 5s-broadcast freshness). Per-hashboard/ASIC/PSU **not persisted** → Phase 3 (persist component arrays) or live passthrough. | +| 2 | `getTimeSeries` `POST /timeseries` (miner/hb/asic, 1m/5m/15m) | Historical charts | `TelemetryService.GetCombinedMetrics` (device-level, 10-min raw / hourly / daily) | 🟡 | Device-level ✅ at reduced granularity. Per-hb/ASIC timeseries 🔴 → Phase 3. | +| 3 | `getHashboards` `GET /hashboards`, `getHashboardStatus` `GET /hashboards/{hbSn}` (+ per-ASIC grid) | Per-board status + ASIC heatmap grid | none | 🔴 | Persist per-component snapshots + new read RPC (Phase 3); live ASIC grid via passthrough (Phase 4). | + +### Reads — status, identity, errors + +| # | ProtoOS call | Shows | Fleet analog | Parity | Work to reach parity | +| --- | --- | --- | --- | --- | --- | +| 4 | `getMiningStatus` `GET /mining` | Mining / paused / curtailed | `MinerStateSnapshot.device_status` (+ curtailment service state) | 🟡 | Map ProtoOS status enum → fleet `device_status`; surface curtailed-vs-stopped distinction. | +| 5 | `getSystemStatus` `GET /system/status` | Per-component health rollup | `MinerStateSnapshot` (`device_status`, `temperature_status`) | 🟡 | Device-level ✅; per-component health needs Phase 3 data. | +| 6 | `getSystemInfo` `GET /system` | Firmware, model, control-board, serials | `MinerStateSnapshot` (model, manufacturer, firmware_version, mac, serial) | 🟡 | Core fields ✅; control-board/MPU/bootloader detail 🔴 (see #8). | +| 7 | `getErrors` `GET /errors` | Active alerts | `ErrorQueryService.ListMinerErrors` / `Watch` (stream) | ✅ | Wire hook; verify alert shape/severity parity. | +| 8 | `getHardware` `GET /hardware` | Per-slot hashboard/PSU/fan firmware, bootloader, chip IDs | none (partial in unpersisted component model) | 🔴 | Collect + persist inventory (firmware/bootloader/chip IDs not collected today) + new RPC. | + +### Config reads & writes + +| # | ProtoOS call | Shows / does | Fleet analog | Parity | Work to reach parity | +| --- | --- | --- | --- | --- | --- | +| 9 | `getCooling` / `setCoolingMode` `GET|PUT /cooling` | Fan mode/speed read + set | Read `FleetManagementService.GetMinerCoolingMode`; write `MinerCommandService.SetCoolingMode` | ✅ | Wire; write is async batch (`batch_identifier` + `StreamCommandBatchUpdates`). | +| 10 | `getNetwork` / `setNetworkConfig` `GET|PUT /network` | IP/gateway/DNS/MAC/DHCP read + **write** | Read `NetworkInfoService.GetNetworkInfo` (IP/gateway/subnet/ipv6 only); write = `UpdateNetworkNickname` only | 🟡 read / 🔴 write | Read: add DNS/MAC/DHCP-flag fields. **Write of static-IP/DHCP has no fleet RPC** — this is a live/on-device op; likely belongs to the separate status page (assumption 1), not fleet. | +| 11 | `listPools` `GET /pools` | Miner's current pool config | `FleetManagementService.GetMinerPoolAssignments` | ✅ | Wire. | +| 12 | `createPools`/`editPool`/`deletePool` `POST|PUT|DELETE /pools[/id]` | Set/replace pools | `MinerCommandService.UpdateMiningPools` (replace-set) | ✅ | Wire; current embed already routes pool writes through Fleet (read-only ProtoOS UI). Reconcile add/edit/delete UX → single replace-set call. | +| 13 | `testPoolConnection` `POST /pools/test-connection` | Live stratum reachability from the miner | `PoolsService.ValidatePool` | 🟡 | `ValidatePool` validates a pool definition, not necessarily a live test **from that miner**. Verify semantics; true per-miner test may be live-only (drop or Phase 4 passthrough). | +| 14 | `getMiningTarget` / `editMiningTarget` `GET|PUT /mining/target` | Power target / performance mode read + set | Write `MinerCommandService.SetPowerTarget` (`PerformanceMode`); **no read RPC** | 🟡 | Write ✅. Read 🔴 → add a getter or surface current mode/target on `MinerStateSnapshot`. | +| 15 | `getSystemTag` / `setSystemTag` / delete `/system/tag` | User label on the miner | `FleetManagementService.RenameMiners` / `UpdateWorkerNames` | 🟡 | Decide mapping: ProtoOS "system tag" vs fleet device name vs pool worker name. Likely fold tag → fleet device name. | + +### Control (writes) + +| # | ProtoOS call | Does | Fleet analog | Parity | Work to reach parity | +| --- | --- | --- | --- | --- | --- | +| 16 | `startMining` / `stopMining` `POST /mining/start|stop` | Resume/pause mining | `MinerCommandService.StartMining` / `StopMining` | ✅ | Wire (async batch). | +| 17 | `rebootSystem` `POST /system/reboot` | Reboot | `MinerCommandService.Reboot` | ✅ | Wire. | +| 18 | `locateSystem` `POST /system/locate` | LED locate | `MinerCommandService.BlinkLED` | ✅ | Wire. | +| 19 | `updateCheck` + `postUpdateSystem` `POST /system/update/check|/update` | Check + apply firmware | `MinerCommandService.FirmwareUpdate` (staged artifact) | 🟡 | Wire apply. "Check for update" semantics differ (fleet stages an artifact) — reconcile UX. | +| 20 | `postUpdatePsu` `POST /power-supplies/update` | PSU firmware update | none | 🔴 | Add PSU-firmware command or drop from parity. | + +### Logs + +| # | ProtoOS call | Does | Fleet analog | Parity | Work to reach parity | +| --- | --- | --- | --- | --- | --- | +| 21 | `getSystemLogs` / `downloadLogs` `GET /system/logs` | Inline log tail + CSV download (miner_sw/pool/os) | `MinerCommandService.DownloadLogs` (async artifact); `ServerLogService`/`ActivityService` for fleet-side | 🟡 | Download ✅ (async, not inline tail). Live tail (`StreamLogs`) has no fleet analog — accept batch-only or Phase 4. | + +### Auth & pairing (collapse, do not migrate) + +| # | ProtoOS call | Was for | Fleet handling | Parity | Work | +| --- | --- | --- | --- | --- | --- | +| 22 | `login` / `refresh` / `logout` `/auth/*` | Per-miner web session | `AuthService.Authenticate` / `Logout` + fleet RBAC | ✅ collapses | Remove per-miner auth from the view entirely. | +| 23 | `setPassword` / `changePassword` `/auth/*` | The miner's own web password | `MinerCommandService.UpdateMinerPassword` (miner pw); `AuthService.UpdatePassword` (fleet user pw) | ✅ | Keep as a "change miner password" action; drop first-boot set-password (→ status page). | +| 24 | `getPairingInfo` / auth-key `/pairing/*` | Device↔fleet pairing | `PairingService` / `FleetNodeAdminService` | ✅ collapses | Remove from single-miner view (pairing is a fleet flow already). | + +**Onboarding flow** (`/onboarding/*`: network setup, verify, first password, pool +selection) is inherently on-device / pre-pairing → out of scope per assumption 1 +(belongs to the future status page). + +### Parity summary by effort + +- **Rebind only (analog exists, ✅):** errors (7), cooling (9), pool read (11), + pool write (12), power-target write (14 write), start/stop (16), reboot (17), + locate (18), auth/pairing collapse (22–24). → **Phase 1–2.** +- **Small backend add (🟡, extend existing):** device status/info mapping (4–6), + network read fields (10 read), power-target read (14 read), system-tag mapping + (15), firmware-update UX (19), logs download (21). → **Phase 1–2 + minor proto.** +- **Real backend work (🔴, collect+persist+RPC):** per-hashboard/ASIC/PSU + telemetry — current (1) & historical (2), hashboard/ASIC detail screen (3), + hardware inventory (8). → **Phase 3** (+ Phase 4 passthrough for live fidelity). +- **Likely drop or defer:** static-IP/DHCP write (10 write), live pool test (13), + PSU firmware (20), live log tail (21) — mostly live-only ops that fit the + separate on-device status page, not the fleet view. + +**Headline:** ~18 of 24 domains are ✅/🟡 (rebind or small extend). The genuine +backend build is concentrated in the **per-component telemetry/inventory** rows +(1, 2, 3, 8) — exactly the ASIC-data concern. Everything else is largely wiring. + +## Client restructuring (fold ProtoOS into ProtoFleet) + +With assumption 2, the two-app split (and AGENTS.md rule 5's import boundary) +collapses. Rough shape of the client work, independent of backend parity: + +- Retire `client/src/protoOS` as a standalone app; move its **presentational** + components (charts, gauges, ASIC grid, diagnostics panels, layout) into + ProtoFleet (or `shared/` where genuinely shared). +- Replace the ProtoOS data layer (generated REST client + `MinerHostingContext` + `mode`) with ProtoFleet's Connect-RPC clients. The `direct`/`fleet` mode split + disappears — there is only fleet. +- Remove `SingleMinerWrapper`'s proxy embed; the single-miner view becomes a + native ProtoFleet route rendering from fleet RPCs. +- Delete `server/internal/handlers/minerproxy` once no screen proxies. +- Collapse the two Vite entry points / build outputs to one; update + `routePrefetch.ts` + `router.tsx` (AGENTS.md rule 9) for the merged routes. +- E2E: `test-e2e-protoos` folds into `test-e2e-fleet`. + +This is a sizable but mostly mechanical refactor; it can proceed in parallel with +Phase 1 backend wiring since it doesn't depend on the ASIC-data work. + +## Strategic decisions required + +These are product/architecture calls that shape the plan. Recommendations given, +but they need sign-off before build. + +1. **What happens to ProtoOS-on-miner?** + - Option 1a: Replace with a **thin status page** on the device (health, + hashrate, "managed by Fleet", onboarding/network setup). Recommended — keeps + first-boot / offline-from-fleet usable without shipping the whole app. + - Option 1b: Remove entirely, require ProtoFleet for everything. Simpler + device image but breaks any air-gapped / pre-pairing workflow. + +2. **ASIC / per-component detail: match, degrade, or passthrough?** + - Option 2a (**recommended, hybrid**): persist per-component snapshots at the + 10-min cadence for historical/offline views; for the *live* ASIC heatmap + keep an **on-demand passthrough** to the miner when it's reachable + (reuse/trim the existing minerproxy or a single `RefreshMiner` detail RPC). + Best fidelity, honest about freshness, degrades gracefully when offline. + - Option 2b: fleet-only, drop real-time ASIC views; show 10-min historical + per-ASIC. Simplest, but a visible regression for on-miner users. + - Option 2c: build a true streaming collector to match 15s fidelity fleet-wide. + Highest cost (storage cardinality, ingest load); likely overkill. + +3. **Multi-OS / vendor-agnostic rendering.** The fleet `DeviceMetrics` model is + already plugin-normalized, so a fleet-rendered view naturally generalizes to + non-ProtoOS devices — *if* the view is driven by the normalized model rather + than ProtoOS-specific REST shapes. This is an argument for a **new + fleet-native single-miner view** rather than re-pointing ProtoOS's REST hooks. + +4. **New view vs. re-point existing ProtoOS hooks.** Re-pointing 37 REST hooks at + fleet-shaped RPCs is a lot of shim work and permanently couples the fleet view + to ProtoOS's device-centric API shape (blocking decision #3). Recommendation: + build the embedded single-miner view as **fleet-native** (Connect-RPC against + `FleetManagementService` / `TelemetryService` / `MinerCommandService`), + reusing ProtoOS *presentational* components where the boundary allows (shared/ + cannot import protoOS/; presentational pieces may need to move to `shared/`). + +--- + +## Recommended approach + +**Hybrid, fleet-native, incremental.** Concretely: + +- Build the ProtoFleet single-miner view against fleet RPCs, not the proxy. +- Persist per-component (hashboard/ASIC/PSU/fan) snapshots we already collect, so + historical + offline detail works fleet-side. +- Keep a **thin on-demand passthrough** for genuinely-live operations + (real-time ASIC heatmap, pool test, LED, network setup) that degrades to + "unavailable — miner offline" instead of blocking the whole view. +- Reduce ProtoOS-on-miner to a status/onboarding page once the fleet view + reaches parity for managed miners. + +This preserves the multi-OS benefit, kills the hard dependency on a live +ProtoOS-compatible miner for the common case, and is honest about data freshness. + +--- + +## Phased plan + +**Phase 0 — Alignment (this doc).** Lock decisions 1–4 with product/design. Define +"parity" explicitly per screen (what fidelity/freshness is acceptable fleet-side). + +**Phase 1 — Fleet-native read view (no proxy) for what already exists.** +Build the embedded single-miner shell rendering identity, placement, status, and +device-level hashrate/temp/power/efficiency (current + historical) from +`MinerStateSnapshot` + `GetCombinedMetrics`. Read-only pools/cooling. This alone +lets the common dashboard render for offline miners and non-ProtoOS devices. + +**Phase 2 — Wire control through `MinerCommandService`.** Reboot, start/stop, +cooling, power target, pool edit, password, LED, logs, firmware — replace +proxied writes with the existing async batch RPCs + `StreamCommandBatchUpdates` +for status. (Pools already read-only-via-Fleet in the current embed — extend.) + +**Phase 3 — Persist per-component telemetry.** Stop discarding +`HashBoards/ASICs/PSU/Fan` in `telemetry_store.go`; design storage + retention +(likely a separate hypertable / JSONB snapshot keyed by device+time, with a +tighter retention than device scalars given cardinality). Add read RPCs. Render +per-hashboard / per-ASIC historical detail + the temperature-detail screen from +fleet data. + +**Phase 4 — Live-detail passthrough (bounded).** For the real-time ASIC heatmap +and live-only ops, add a single narrow on-demand path (trimmed minerproxy or a +`RefreshMinerDetail` RPC) that fetches fresh detail when the miner is reachable +and degrades cleanly when not. Fill remaining gaps (network config RPC, hardware +inventory, error stats) as product deems necessary. + +**Phase 5 — Shrink ProtoOS-on-miner.** Once managed-miner parity is reached, +replace the on-device app with the thin status/onboarding page (decision 1a) and +retire the full embedded proxy for managed devices. + +--- + +## Open questions / risks + +- **Freshness contract.** ProtoOS shows ~15s-fresh data; fleet is 10-min. Every + screen needs an explicit "how fresh must this be" answer. Some screens will + visibly regress unless we do Phase 4 passthrough. +- **Per-ASIC storage cardinality.** chips/board × boards × devices × 10-min × + 3y is large. Needs a schema/retention design review before Phase 3. +- **Offline semantics.** A fleet-rendered view must clearly distinguish + "last known (stale)" from "live". Design work required. +- **Onboarding / first-boot / air-gapped.** These are inherently on-device; + decision 1 determines whether they survive and where. +- **Component boundary.** Reusing ProtoOS presentational components from a + fleet-native view means promoting them to `shared/` (AGENTS.md rule 5). +- **Auth model shift.** Proxy handles miner login/token today; fleet-native view + relies on fleet RBAC + `MinerCommandService` authz instead — verify coverage + (e.g. `GetDeviceIdentifiersByOrgWithFilter` pairing defaults). +- **Non-ProtoOS coverage depends on plugins** actually populating the normalized + `DeviceMetrics` for those devices — verify per driver. + +## Key references + +- Proxy (as-is fleet path): `server/internal/handlers/minerproxy/handler.go` +- Embedded shell: `client/src/protoFleet/components/SingleMinerWrapper/` +- Collector + poll cadence: `server/internal/domain/telemetry/service.go` +- Discarded component model: `server/internal/domain/telemetry/models/v2/component_metrics.go` +- Persistence (scalar-only): `server/internal/infrastructure/timescaledb/telemetry_store.go` +- Fleet per-device RPCs: `proto/fleetmanagement/v1/`, `proto/telemetry/v1/`, `proto/minercommand/v1/` +- On-miner API surface: `proto-rig-api/grpc/*.proto` +- ProtoOS hooks: `client/src/protoOS/api/hooks/` diff --git a/docs/plans/2026-07-29-protofleet-single-miner-mode-discovery.md b/docs/plans/2026-07-29-protofleet-single-miner-mode-discovery.md new file mode 100644 index 0000000000..85d4f0212b --- /dev/null +++ b/docs/plans/2026-07-29-protofleet-single-miner-mode-discovery.md @@ -0,0 +1,138 @@ +--- +title: "Discovery: ProtoFleet Single-Miner mode" +date: 2026-07-29 +status: draft +type: plan +--- + +# Discovery: ProtoFleet Single-Miner mode + +> Sub-discovery of +> [Fleet-native single-miner view](./2026-07-28-single-miner-views-on-fleet-backend-plan.md). +> Explores: if the single-miner view becomes fleet-native, can we start +> ProtoFleet in a "Single-Miner mode" that connects to one device and renders +> just that miner's view — as the replacement for ProtoOS-on-miner? + +## TL;DR + +**The single-miner *experience* is essentially already built and this is much +closer to a thin client flag than a new server profile — with one caveat.** +There are two very different interpretations, and they differ wildly in cost: + +- **(A) "Point the normal fleet server at one device."** Near-zero backend work. + Pair one device over the LAN; the existing `/miners/:id` route + + `minerproxy` already render its single-miner view. The mode is mostly a + **client entry-point + nav-gating + runtime config** change. +- **(B) "A stripped-down single-miner appliance"** (a true ProtoOS-on-miner + replacement running on/near the device). **Medium-to-large.** Blocked by two + hard server dependencies with no lite path today: **TimescaleDB/Postgres is + unconditional** (no embedded/in-memory option) and the **proto plugin is + mandatory**. A `MODE` config stub exists but is dead code — no working profile + system to hang a "lite" mode on. + +Recommendation: scope Single-Miner mode as **(A) a client mode over the standard +server** first; treat (B) as a separate infrastructure track if we want a genuine +on-device appliance. + +## What already exists + +- **Fleet-native single-miner view is live.** `client/src/protoFleet/router.tsx` + mounts `/miners/:id` → `SingleMinerWrapper`, which renders the ProtoOS routes + in `mode="fleet"`. (Today it proxies to the device via `minerproxy`; the main + plan's job is to make it render from fleet data instead — but the *route and + shell already exist*.) +- **The server can already reach one device with no fleet-node fabric.** + Discovery (`ipscanner`) + pairing (`pairingDomain` + `plugins.NewPairer`) probe + and pair a device over the LAN, server-local. `minerproxy` `resolveTarget` + reads the device row's `IpAddress` and proxies straight to it. Fleet nodes are + only needed for devices the server can't route to directly. +- **Single-device is already a supported dev/demo shape.** The `virtual` plugin + (`plugin/virtual/`, `ENABLE_VIRTUAL_MINERS`, `VIRTUAL_MINER_COUNT`) simulates N + miners — set count to 1 for a single device end-to-end. Fake device servers + (`server/fake-antminer/`, `server/fake-proto-rig/`) exist for dev. + +## Client bootstrap — where a mode flag would live + +- Entry `client/src/protoFleet/main.tsx` → `mainWrapper.tsx` → `router.tsx`. +- **Backend selection is not runtime-configurable today.** Transport + (`client/src/protoFleet/api/transport.ts`) hardcodes `baseUrl` to + `${API_PROXY_BASE}/` = `/api-proxy` (`api/constants.ts`); dev Vite proxies it to + `FLEET_PROXY_URL` (default `http://localhost:4000`, `client/vite.config.ts`). + The client always assumes one fleet server on the same origin. +- **Runtime config hook already exists:** `window.__RUNTIME_CONFIG__` + (`client/src/shared/observability/runtimeConfig.ts`), rendered by nginx at + container start — so a prebuilt artifact can be reconfigured without a rebuild. + This is the natural place for a `SINGLE_MINER` flag + target device id. +- Build-time flags live in `client/src/protoFleet/constants/featureFlags.ts` + (`VITE_INFRASTRUCTURE_DEVICES_ENABLED`, `VITE_ALERTS_ENABLED`); polling in + `constants/polling.ts` (`VITE_POLL_INTERVAL_MS`). + +## Startup gating that Single-Miner mode must pass + +- **First boot:** `GetFleetInitStatus` → `status.adminCreated` + (`router.tsx` `authLoader`/`welcomeLoader`; also a health probe in + `App.tsx` → `/fleet-down` on failure). Admin created via `CreateAdminLogin`. +- **Post-login onboarding:** `GetFleetOnboardingStatus` → + `{devicePaired, poolConfigured}` (`api/useOnboardedStatus.ts`), gating + `/onboarding/miners|security|settings`. Server derives this from device/pool + rows (`onboardingDomain.NewService(deviceStore, poolStore, userStore)`). +- **Implication:** a single-miner appliance still needs an admin + one paired + device. For a smooth appliance UX you'd want to **auto-pair the one device** + and **bypass the pool-config onboarding step** — new gating logic, but the RPCs + already exist. + +## Server bootstrap & the (B) blockers + +- Entry `server/cmd/fleetd/main.go`; config `server/cmd/fleetd/config.go` (Kong, + env + `/etc/fleetd/config.yaml`, prefixes `DB_`, `HTTP_`, `AUTH_`, `SESSION_`, + `PLUGINS_`, `TELEMETRY_`, `TIMESCALEDB_`, …). +- **Dead mode stub:** `config.go` `Mode string enum:"server,agent,combined" + default:"combined" env:"MODE"` is defined but **never read** at startup. No + execution-mode branching exists to build on. +- **Hard deps (won't boot without):** + - **Postgres/TimescaleDB** — `db.ConnectAndMigrate(&config.DB)`; every store is + built on it; one DB serves relational + time-series. **No embedded/in-memory + option** — Postgres is unconditional. + - **proto plugin** — `main.go` fatally errors if `DriverNameProto` isn't loaded + ("proto plugin is required"). +- **Not required:** **no NATS anywhere** (command dispatch is a DB-backed queue, + `queue.NewDatabaseMessageQueue`); metrics/Grafana/alerts, system monitoring, + OTel, MQTT curtailment are all optional/gated. Fleet nodes are separate + binaries (`server/cmd/fleetnode/`), not part of core. +- **Minimum footprint** = `fleetd` + one TimescaleDB/Postgres + the proto plugin + binary — i.e. the existing `just dev` / `dev.sh` footprint. That's the floor + for interpretation (B) unless we build a lite profile. + +## Lift estimate + +| Track | Scope | Lift | +| --- | --- | --- | +| Client mode (A) | Boot flag → land on `/miners/:id` for the one device, hide fleet-scope nav, target device via `window.__RUNTIME_CONFIG__` | **Small** (routing/entry + nav gating + runtime knob) | +| Onboarding/auth | Still pass init + onboarding gates; auto-pair the device, bypass pool-config step | **Small–medium** (RPCs exist; new gating) | +| Lite server appliance (B) | Shed/shrink the TimescaleDB + proto-plugin hard deps; build a real `MODE=single`/lite profile | **Medium–large** (unconditional Postgres has no embedded path; `MODE` stub is non-functional) | + +## Recommendation & open questions + +- **Recommend:** define Single-Miner mode as **interpretation (A)** — a client + mode running against a standard (possibly co-located) fleet server, landing + directly on the one device's fleet-native view with fleet-scope nav hidden. + This is small and rides entirely on machinery that already exists. +- Depends on the main plan reaching fleet-native parity for the single-miner view + (otherwise (A) still leans on `minerproxy`, which is fine as an interim). +- **(B) is a separate infra decision:** is the goal a lightweight *appliance* + that runs on/near the miner? If so, the Postgres/plugin footprint — not the UI — + is the real project, and it overlaps with "what replaces ProtoOS-on-miner" + (main-plan assumption 1, explicitly deferred). +- Open questions: how is the target device selected/persisted in (A)? Does the + appliance auto-pair on first boot? Do we hide *all* fleet nav or keep a minimal + settings surface? Single-user vs full RBAC on an appliance? + +## Key references + +- `client/src/protoFleet/router.tsx`, `.../SingleMinerWrapper/SingleMinerWrapper.tsx` +- `client/src/protoFleet/api/transport.ts` + `constants.ts`; `client/vite.config.ts` +- `client/src/shared/observability/runtimeConfig.ts`; `client/src/protoFleet/constants/featureFlags.ts` +- `client/src/protoFleet/api/{useAuth,useOnboardedStatus}.ts`; `proto/onboarding/v1/onboarding.proto` +- `server/cmd/fleetd/main.go` (deps: DB connect, proto-plugin required, pairer, minerproxy registration), `server/cmd/fleetd/config.go` (`Mode` stub) +- `server/internal/handlers/minerproxy/handler.go`; `server/internal/domain/{pairing,ipscanner}` +- `plugin/virtual/`; `server/fake-antminer/`, `server/fake-proto-rig/` diff --git a/docs/plans/2026-07-29-protoos-backend-agnostic-abstraction-discovery.md b/docs/plans/2026-07-29-protoos-backend-agnostic-abstraction-discovery.md new file mode 100644 index 0000000000..ea5fe05ae2 --- /dev/null +++ b/docs/plans/2026-07-29-protoos-backend-agnostic-abstraction-discovery.md @@ -0,0 +1,165 @@ +--- +title: "Discovery: backend-agnostic ProtoOS via a canonical resolver layer" +date: 2026-07-29 +status: draft +type: plan +--- + +# Discovery: backend-agnostic ProtoOS via a canonical resolver layer + +> Sub-discovery of +> [Fleet-native single-miner view](./2026-07-28-single-miner-views-on-fleet-backend-plan.md). +> Explores: could ProtoOS be refactored to be backend-agnostic — a canonical +> list of data-fetching functions, with each backend supplying a resolver per +> item — so the same UI renders from either the on-miner REST API or the +> ProtoFleet Connect-RPC server? + +## TL;DR + +The theory is **architecturally sound and partially pre-built**, but it +**overestimates the fleet Connect-RPC server as a drop-in second data source.** +The blocker is not transformation difficulty — it's **missing capabilities**: +the fleet protos have no per-hashboard / per-ASIC / per-fan / per-PSU / pool-live +/ fan-control data to resolve to. A clean abstraction is achievable for a +**degraded fleet-sourced subset** (the ~4 headline metrics + pool config + coarse +cooling), gated by capability flags. "Render all of ProtoOS from fleet RPCs" is +blocked by the proto surface, not by code structure. + +## The finding that reframes the theory + +The existing `mode: "direct" | "fleet"` seam in +`client/src/protoOS/contexts/MinerHostingContext/MinerHostingContext.tsx` is +**not** "REST vs fleet-RPC." Both modes build the *identical* generated REST +`Api` class; the only per-mode branch is auth +(`securityWorker: mode === "direct" ? securityWorker : undefined`). Today's +"fleet" mode is the **same on-miner REST API reverse-proxied** through the fleet +server (`SingleMinerWrapper` sets `baseUrl=/api-proxy/miners/:id`). That's why it +was cheap — the data shapes are byte-identical. + +So a true fleet-RPC backend would be a **third** backend with fundamentally +different message shapes — a new axis, not a widening of the existing toggle. + +The `api` field in `MinerHostingContextType` is a concrete generated REST client, +not an interface — it's the object every hook consumes via +`const { api } = useMinerHosting()`. That is the wrong seam for the abstraction +(see below). + +## What already helps + +The Zustand store (`client/src/protoOS/store/`, see `store/README.md`) already +defines **backend-neutral canonical domain types**: `Measurement`, +`MetricTelemetry`, `MetricTimeSeries`, `AsicHardwareData`, +`HashboardHardwareData`, `FanTelemetryData`. This is effectively the +proto-agnostic domain model the theory needs — it already exists. Both the +current REST transforms and a hypothetical fleet transform target these types. + +## Data-shape reality (the crux) + +| Domain | REST shape (today) | Fleet-RPC counterpart | Verdict | +| --- | --- | --- | --- | +| Headline metrics (hashrate/temp/power/eff) | `TelemetryData.miner`, `MinerStateSnapshot` | `common.v1.Measurement` (+ `MinerStateSnapshot`) | ✅ thin remap: enum↔string units, **kW↔W scaling**, Timestamp↔ISO. Maps onto store `Measurement`. | +| Per-hashboard telemetry | `TelemetryData.hashboards[]`, `getHashboardStatus` | **none** (grep `hashboard` in fleet protos = 0 hits) | 🔴 no source data | +| Per-ASIC grid | `hashboards[].asics[]` | **none** | 🔴 no source data | +| Per-fan / per-PSU telemetry | `hashboards[].psus[]`, cooling `fans[]` | **none** | 🔴 no source data | +| Timeseries at hashboard/asic level | `getTimeSeries` `levels` | `GetCombinedMetrics` (4 types, **device-aggregated**, `device_count`) | 🟡 device-level only | +| Pools | `Pool` (~20 live-stat fields) | `PoolAssignment {pool_id,url,username}` | 🟡 config only, zero runtime stats | +| Cooling | fan control mode `Off/Auto/Manual` + fan RPMs | `CoolingMode` = medium `AIR/IMMERSION/MANUAL` | 🔴 different axis; only `MANUAL` overlaps by name | +| Mining target | `MiningTargetResponse` (watts + mode) | no read RPC | 🔴 needs new RPC | + +Note: a `GetBatchMinerTelemetry` RPC is *referenced in a comment* +(`fleetmanagement.proto:209`) but **does not exist** — only `GetCombinedMetrics` +and `StreamCombinedMetricUpdates` do. + +**Verdict:** for the 4 headline metrics it's a thin field+unit remap. For +everything that makes ProtoOS a *single-miner diagnostic tool*, the fleet server +has nothing to resolve to. Missing-capability problem, not a transform problem — +the same root cause as rows 1/2/3/8 in the main parity map. + +## Where the abstraction should sit + +**Not at the hook level and not at the `api` object.** Lowest-surface seam is a +**repository/resolver layer beneath the hooks, expressed in the store's canonical +domain types** — e.g. `getLatestTelemetry(): MinerTelemetrySnapshot`, +`getTimeSeries(range): MetricTimeSeries[]`, `getPools(): PoolDomain[]`, +`getCooling(): CoolingDomain`. Each backend supplies a resolver; hooks call the +repository and hydrate the store exactly as they do now. Store-backed KPI +components then become backend-agnostic for free. + +Why not the `api` object: it's a concrete REST class with dozens of +OpenAPI-shaped methods; abstracting there forces a fleet backend to impersonate +the entire OpenAPI surface (high surface area, impossible for missing caps). + +**The real work / prerequisite:** the transform logic that populates the +canonical types is currently **inlined inside the hooks** (`processHashboards` in +`useTelemetry.ts`, ASIC/voltage backfill in `useHashboardStatus.ts`, fan padding +in `useCoolingStatus.ts`). Several hooks carry `[STORE_REFACTOR]` TODOs +acknowledging the muddy layering. Extracting these into a `RestResolver` is +needed regardless and is the bulk of the effort. + +**Wrinkle — dual read paths.** Components read from *both* the store (KPI tiles: +`Hashrate/Efficiency/PowerUsage/Temperature/Cooling`) *and* raw hook return +values (`useHashboardStatus`, diagnostics, pools table). The raw-response +consumers are exactly the ones with no fleet data — so they'd stay REST-only +behind a capability flag, which is actually convenient. + +## Risks / wrinkles + +- **Polling vs streaming.** All ProtoOS reads are `usePoll` snapshots (15s/30s). + Fleet telemetry is a server stream (`StreamCombinedMetricUpdates`, min 10s). A + request→single-response repository fits REST but is awkward for streaming; the + interface must express subscriptions as first-class or lose streaming's point. +- **Auth divergence.** Direct = miner JWT via `securityWorker`; fleet-proxy = + ambient session; true fleet-RPC = org-scoped fleet session (`site_ids`, + `include_unassigned`). The ProtoOS `useAuth`/login-modal machinery is + miner-JWT-specific — dead weight on a fleet-RPC backend (already partly gated + by `isFleetHosted`). +- **Identity model.** REST addresses one miner implicitly via base URL; fleet + RPCs require an explicit `device_identifier` on every call. Available in + `MinerHostingContext.metadata`/`baseUrl` today but implicit. +- **Leaky params.** REST-only (`levels`, `aggregation`, `hbSn`, fan mode, pool + priority) vs fleet-only (`DeviceSelector`, `site_ids`, pagination, org scope) — + a canonical signature can't carry both without an "extras" bag. +- **Write/command asymmetry.** REST writes hit the miner directly; fleet writes + route through `minercommand`/`fleetnodegateway` with different shapes, not + covered by the read-oriented fleet RPCs. + +## Opportunities / suggestions to improve the strategy + +1. **Define the canonical layer in store-domain types, not OpenAPI method names.** + The store types already exist and are neutral — anchor the resolver interface + there. +2. **Make it capability-gated, not all-or-nothing.** A resolver advertises which + canonical functions it supports; the UI degrades (hide ASIC grid / pool stats) + when a capability is absent, rather than pretending. This is the honest way to + support fleet + on-miner + future non-ProtoOS backends with one UI. +3. **Do the hook-transform extraction first — it's a no-regrets refactor.** + It pays down the `[STORE_REFACTOR]` debt and unblocks the abstraction whether + or not the fleet backend is ever added. +4. **Reconsider whether this abstraction is even the right vehicle.** Given the + main plan's decision to *fold ProtoOS into ProtoFleet* and go fleet-native, + the abstraction layer competes with "just build fleet-native views on the + fleet RPCs." The abstraction is most valuable if we want **one UI that serves + both an on-device build (full REST fidelity) and a fleet build (degraded)** + simultaneously — i.e. if ProtoOS-on-miner does *not* fully go away. If it does + go away (main-plan assumption 1), the abstraction's payoff shrinks to "ease + the migration," and a direct fleet-native rebuild may be simpler. +5. **Treat streaming as the primary fleet contract**, adapting REST polling *up* + to a subscribe-shaped interface, rather than adapting the stream *down* to + polling. + +## Honest bottom line + +Sound seam, real pre-existing scaffolding (store domain types + transport seam), +but the fleet RPC surface can only feed a degraded subset. Pursue only if we want +a single UI spanning full-fidelity on-device *and* degraded fleet backends at +once; otherwise the fold-into-ProtoFleet + fleet-native rebuild path likely +dominates. Either way, extracting hook transforms into resolvers is a +no-regrets first step. + +## Key references + +- Transport seam: `client/src/protoOS/contexts/MinerHostingContext/MinerHostingContext.tsx` +- Canonical domain types: `client/src/protoOS/store/types.ts`, `store/README.md` +- Representative hooks: `client/src/protoOS/api/hooks/{useTelemetry,useTimeSeries,useHashboardStatus,usePoolsInfo,useCoolingStatus,useMiningTarget}.ts` +- Embed proof: `client/src/protoFleet/components/SingleMinerWrapper/SingleMinerWrapper.tsx` +- Fleet protos: `proto/telemetry/v1/telemetry.proto`, `proto/fleetmanagement/v1/fleetmanagement.proto`, `proto/common/v1/{measurement,cooling}.proto` diff --git a/docs/rfcs/0003-single-miner-view-strategies.md b/docs/rfcs/0003-single-miner-view-strategies.md new file mode 100644 index 0000000000..377439dcc9 --- /dev/null +++ b/docs/rfcs/0003-single-miner-view-strategies.md @@ -0,0 +1,424 @@ +# RFC 0003: Single-miner view — sourcing & versioning strategies + +- **Status**: draft +- **Author(s)**: Matt Flesher (flesher) +- **Created**: 2026-08-06 +- **Last updated**: 2026-08-06 + +## Summary + +We need to decide how the **single-miner view** sources and versions its data — +and, as a consequence, how far we can consolidate our client footprint. Today +that view is the ProtoOS React app talking directly to a miner's `/api/v1/*` +REST surface — either served from the device itself or reverse-proxied through +the fleet server. That model couples the UI to a device being online, reachable, +and running a specific firmware generation. + +This RFC defines the objective, then evaluates three strategies for how the +single-miner view sources and versions its data: + +1. **Fleet-native** — render entirely from fleet-collected data (Connect RPCs), + never touching the device. +2. **Proxy, version-aware** — proxy to the device, probe firmware, and dispatch + to a per-version client. +3. **Adapter layer** — one generic view behind an adapter seam, with swappable + backends (fleet, MDK v1 REST, MDK v2 consolidated), optionally combined with + proxying. + +Each strategy is illustrated by a working prototype in the throwaway **Prototype +Lab** (`/lab`) on branch `migrate-single-miner-to-fleet` +(`client/src/protoFleet/prototypes/`). This document describes the **production +architecture** each prototype approximates — with diagrams, pros, and cons — so +we can pick a direction. + +One framing note up front, because it drives the trade-offs: only **fleet-native** +truly collapses us to a single client app. The proxy and adapter strategies still +require shipping a client bundle onto the miner for each firmware generation we +support, plus the fleet bundle — so we continue to build and ship multiple +clients, and the win there is streamlined *maintenance* (shared view code), not a +single-client end state. + +## Motivation + +### The objective + +We need to: + +1. **Deliver a rich single-miner UI experience** — the identity + KPIs + a + hashboard/ASIC grid + controls that operators rely on — whether it is served + from the miner (as today) or from a local ProtoFleet "single-miner mode." +2. **Accommodate proto rigs on varying firmware** — different MDK API + generations (v1 REST spread across many endpoints; v2 consolidated envelope + with real per-chip data) must all render the same experience. +3. **Simplify development and maintenance of the single-miner view** — one view + to build and maintain instead of a matrix of app × firmware × transport. + +### Why now + +Today's single-miner UI ("ProtoOS") is served two ways, and **both** are really +ProtoOS-talking-to-a-device: + +- **Standalone on the miner** — ProtoOS is bundled onto the device image and + served by the miner's embedded web server; the browser hits `/api/v1/*` on the + miner. +- **Embedded in ProtoFleet** — the fleet UI opens `/miners/:id/*` + (`client/src/protoFleet/components/SingleMinerWrapper/`), reusing the *same* + ProtoOS app but pointing its API client at the fleet server, which + **reverse-proxies** every `/api/v1/*` call to the live miner + (`server/internal/handlers/minerproxy/handler.go`). + +Both modes hard-depend on the miner being online, reachable, and running a +ProtoOS-compatible API. The +[migration plan](../plans/2026-07-28-single-miner-views-on-fleet-backend-plan.md) +sets the direction that frames this RFC: reduce or remove ProtoOS-on-miner, make +the single-miner experience first-class in ProtoFleet, and target full parity +with the explicit allowance to reduce data granularity/freshness to what the +fleet can support. How fully we reach a single-client end state depends on which +strategy we pick. + +### A note on hashboard / ASIC data + +One known constraint recurs across the strategies, so it's worth stating once and +then addressing in context below. Today's fleet server **does not collect or +store hashboard- or ASIC-level data** — the collector builds a full +`DeviceMetrics` (`HashBoards[] → ASICs[]`, PSU/fan arrays) in memory each poll, +but persistence keeps only device-level scalars +(`server/internal/infrastructure/timescaledb/telemetry_store.go`), and no +client-facing RPC exposes the component arrays. This is not a blocker so much as +a scoping decision: any strategy that sources the ASIC grid from the fleet needs +us to decide *how much* component-level collection and storage to build; any +strategy that reads it from the device directly already has it. Each strategy +below states where it lands. + +## Detailed design + +### The shared contract + +The abstraction strategies (1 and 3) normalize every backend into one +`SingleMinerSnapshot` and render one presentational `` (the +prototype implements this under `client/src/protoFleet/prototypes/shared/`): + +```ts +interface SingleMinerSnapshot { + identity: MinerIdentity; // name, model, firmware, mdkVersion, mac, serial, ip? + status: MinerStatus; // mining | paused | offline | error + kpis: MinerKpis; // hashrateThs, tempC, powerW + hashboards: HashboardSummary[]; // board → AsicCell[] (index, tempC, hashrateThs, health) + dataPath: DataPathStep[]; // "how did this data get here" ribbon + source: string; +} +``` + +The seam that backends implement: + +```ts +interface SingleMinerAdapter { + readonly source: string; + fetchSnapshot(signal?, tracer?): Promise; + control?(action, tracer?): Promise; // optional; not every backend writes +} +``` + +The whole thesis of the abstraction approach: **map many backends into one +snapshot, render one view.** The proxy strategy (2) can reuse this same seam for +its fetch, but its conceptual point is that a per-version *client* could render +verbatim if we wanted divergence. + +The prototype distills the view to a deliberately minimal slice — identity + 3 +KPI tiles + a hashboard/ASIC mini-grid + one control — chosen so the ASIC grid +(the component-level data discussed above) is always in frame while we compare +strategies. + +--- + +### Strategy 1 — Fleet-native + +**Thesis:** one backend, any miner, regardless of on-device OS/firmware. The view +is built directly on fleet RPCs and never touches the device. This is the only +strategy that truly collapses us to a **single client app** — there is no +per-version bundle to ship on the miner. + +Identity + KPIs come from the existing +`FleetManagementService.ListMinerStateSnapshots` RPC (resolved by IP) over the +fleet server; the same fleet primitives used everywhere else in ProtoFleet. The +device is never contacted, so the view renders for offline, unreachable, and +non-ProtoOS miners alike — anything the fleet has collected. + +The prototype (`client/src/protoFleet/prototypes/fleetNative/`) frames this as a +"connect to a miner" flow (IP + credentials) to make the point tangible, but in +production this is just the single-miner surface of ProtoFleet reading fleet data. + +```mermaid +flowchart LR + UI["<SingleMinerView>"] --> FA["FleetAdapter"] + FA -->|"ListMinerStateSnapshots
(Connect over /api-proxy)"| FS["Fleet server"] + FS --> DB[("TimescaleDB
device-level scalars")] + FA -.->|"synthesized from
device temp (FPO)"| GRID["ASIC grid *"] + GRID --> UI + Miner["Miner"] -.->|"out-of-band
10-min collector poll"| FS + classDef fpo fill:#fff3cd,stroke:#d39e00,color:#664d03; + class GRID fpo; +``` + +Because the fleet does not persist component-level data today (see the note +above), the ASIC grid is the open scope question for this strategy. The prototype +takes a shortcut — it synthesizes the grid from the device-level temperature +(`fleetAdapter.ts` `synthGrid`) purely to fill the view. For production we'd need +to decide how much component data to build into the fleet: persist the arrays the +collector already produces (a schema + retention decision, since per-ASIC +timeseries is high-cardinality), and/or add an on-demand metrics RPC that reads +the device on request. The mechanism to fetch it exists on the server +(`interfaces/miner.go` `GetDeviceMetrics`); it's the collection/storage choice +that's the work. + +**Pros** + +- **Only true single-client outcome** — no per-version bundle on the miner; + strongest answer to objective 3. +- Renders **any** miner regardless of firmware/vendor — the fleet already + normalizes across plugins. Objective 2 falls out for free. +- Works for **offline / unreachable** miners from last-known data. +- **Simplest client** — one data path, one set of fleet components, no device + auth/CORS/TLS in the browser, no per-miner reverse proxy to operate. +- Self-explanatory: the same fleet primitives used everywhere else in ProtoFleet. + +**Cons** + +- **Component-level (ASIC/hashboard) data needs to be built into the fleet** to + reach parity — a collection + storage + RPC effort whose depth we'd need to + scope. Until then the grid is reduced or synthesized. +- **Reduced freshness** — ~10-minute collector cadence (a 5s broadcast / + on-demand `RefreshMiners` help but don't match ProtoOS's ~15s real-time). +- **Live operations become fleet-mediated, not on-device.** Actions like LED + locate or firmware upload still work — but as the fleet does them today: + dispatch a command and open a status stream that's polled, rather than a direct + device call. That's parity with fleet's current behavior, at lower liveness than + hitting the device. A few operations that are inherently live-from-this-device + (e.g. `testPoolConnection`) may not fit at all. +- **Single-miner mode needs a one-click install.** Running ProtoFleet pointed at + one device as a ProtoOS-on-miner replacement means packaging the fleet server + + client as an easy local install — non-trivial given our Docker-based + environment, and a prerequisite for the "served locally" half of objective 1. + +--- + +### Strategy 2 — Proxy to miner, version-aware + +**Thesis:** the client talks to the live device through the fleet's reverse +proxy, and a version probe (`GET /api/version`) selects the right client for that +firmware generation. This is the closest evolution of today's fleet embed, made +explicit with a version seam so multiple firmware generations coexist. + +In production, the client resolves the miner's firmware generation up front and +dispatches to the matching per-version client. Requests ride the minerproxy path +(`/api-proxy/miners/:id`, `server/internal/handlers/minerproxy/handler.go`), +which handles device auth/token caching and TLS — so there's no browser +CORS/TLS and no direct device exposure. The firmware version changes only the +data path; the rendered view is the same across versions. Firmware that predates +the probe falls back to the legacy (v1) client. + +Because reads come straight from the device, this strategy has **no fleet +component-data gap** — v2 firmware returns real per-chip data in a single +consolidated call, so the ASIC grid is genuinely live; older v1 firmware, which +spreads data across many endpoints and exposes no bulk per-chip readings, is the +one place a grid would be reduced or approximated (a limitation of that firmware, +not of the fleet). + +The prototype +(`client/src/protoFleet/prototypes/proxyVersioned/`, `adapter/probe.ts`) +illustrates this with two fake rigs on different MDK versions; selecting one +probes it, picks the v1 or v2 path, and renders the identical view. + +```mermaid +flowchart LR + UI["Single-miner view"] --> PROBE["Version probe
GET /api/version"] + PROBE -->|"speaks v2"| V2["MDK v2 client"] + PROBE -->|"legacy / no probe"| V1["MDK v1 client"] + V1 -->|"login + system
+ mining + hashboards"| PX["minerproxy
/api-proxy/miners/:id"] + V2 -->|"GET /api/v2/miner
(one envelope, real chips)"| PX + PX --> M["Miner (live)"] + V1 --> UI + V2 --> UI +``` + +**Pros** + +- **Live, real-time data** at ProtoOS fidelity — including a **real ASIC grid** + where the firmware exposes it (v2). Best serves objective 1 today, with no fleet + backend work. +- **Explicit versioning** via the probe — new firmware generations plug in as a + new per-version client without touching the shared view. Directly serves + objective 2. +- Closest to the current fleet-embed behavior — lowest conceptual migration risk. + +**Cons** + +- **Hard dependency on device reachability** — offline/unreachable miners render + nothing. Doesn't serve the "any miner, any state" goal. +- **Does not consolidate the client footprint.** We still build and ship a client + bundle per firmware generation (plus the fleet bundle), so this only partially + serves objective 3 — the win is shared view code, not one app. +- Keeps the **minerproxy** reverse proxy alive (token caching, TLS to device, + per-miner web auth) — a component the migration plan hoped to reduce or remove. +- Per-vendor / non-ProtoOS devices need their own client or aren't supported. + +--- + +### Strategy 3 — Adapter layer (backend-agnostic) + +**Thesis:** one generic view, swappable backends behind a single adapter seam. +Each backend — fleet server, MDK v1 REST, MDK v2 consolidated — implements the +same `fetchSnapshot`/`control` contract and folds its very different shape into +the identical snapshot. This is strategies 1 and 2 unified under one abstraction: +the seam is the only backend-specific code, everything downstream is the same +view. + +Crucially, the adapter layer and the proxy strategy **compose**. The seam handles +version/API mapping in one place; where the fleet can serve the data, a fleet +adapter reads it; where it can't — notably component-level ASIC data — an adapter +can proxy the request through to the miner. That gives a single place to reason +about data sourcing: fleet-native by default, device passthrough where fidelity +requires it, without the view ever knowing the difference. A fleet-only adapter +would still inherit the fleet component-data gap (we'd have to build that +collection); the adapter+proxy combination sidesteps it by forwarding those +specific reads to the device. + +The prototype (`client/src/protoFleet/prototypes/adapter/AdapterPage.tsx`) +illustrates the seam with a backend selector — **Fleet server**, **MDK v1 miner +(direct)**, **MDK v2 miner (direct)** — each folding a different backend into the +same snapshot and rendering the same view. + +```mermaid +flowchart LR + UI["Single-miner view"] --> SEAM{{"Adapter seam"}} + SEAM --> FA["Fleet adapter"] + SEAM --> V1["MDK v1 adapter"] + SEAM --> V2["MDK v2 adapter"] + FA -->|"Connect RPC (device-level)"| FS["Fleet server → TimescaleDB"] + FA -.->|"component data:
proxy passthrough"| PX["minerproxy"] + V1 -->|"v1 REST (many endpoints)"| PX + V2 -->|"v2 consolidated envelope"| PX + PX --> M["Miner (live)"] + classDef seam fill:#e7f1ff,stroke:#0d6efd,color:#052c65; + class SEAM seam; +``` + +This is the same abstraction the +[backend-agnostic discovery doc](../plans/2026-07-29-protoos-backend-agnostic-abstraction-discovery.md) +described — a sound seam whose flexibility is the point. + +**Pros** + +- **Superset flexibility** — fleet-native *and* direct-to-device coexist behind + one view. Render fleet data for offline miners, and read (or proxy through to) + the device where reachability and fidelity matter. +- **Streamlines maintenance across backends** — one shared view, one snapshot + contract, and per-backend/per-version mapping isolated to small adapters + (objectives 2 and 3). Adding a firmware generation is a new adapter, not a new + view. +- **A path around the component-data question** — combining with proxy lets us + ship fleet-native for what the fleet serves and passthrough for ASIC/component + reads, so we don't have to build full component collection into the fleet up + front. +- Clean migration story: fleet adapter as the default, device passthrough where + needed, and shrink the device path per-domain as fleet capabilities land. + +**Cons** + +- **Most abstraction to carry** — a seam plus N adapters (plus a proxy path); + risk of over-engineering if we only ever use one backend. +- **Does not by itself consolidate the client footprint** — if we keep + direct/device adapters we still ship per-version client bundles, same as the + proxy strategy. Only a fleet-only configuration approaches a single app. +- **Two+ sourcing paths to keep behaviorally identical** — the value proposition + depends on fleet and device adapters producing the same snapshot; drift between + them is a real maintenance cost. +- Backend/sourcing selection is an internal concern that needs a clear product + rule (default backend, when passthrough kicks in) so it doesn't leak into the + UX. + +--- + +### How each strategy serves the objectives + +| Objective / property | S1 Fleet-native | S2 Proxy version-aware | S3 Adapter layer | +| --- | --- | --- | --- | +| **1. Rich single-miner UX** | 🟡 Device-level rich today; component/ASIC data needs building into fleet | ✅ Live at device fidelity; real ASIC grid where firmware exposes it | ✅ Device passthrough gives full fidelity; component collection optional | +| **2. Accommodate firmware/MDK versions** | ✅ Fleet normalizes across all | ✅ Version probe → per-version client | ✅ Version probe + per-backend adapter | +| **3. Simplify dev & maintenance** | ✅ Thinnest client, one data path | 🟡 Shared view helps, but N client bundles + minerproxy | 🟡 Shared view + isolated adapters; still N bundles if device path kept | +| Consolidates to a **single client app** | ✅ | 🔴 fleet + per-version bundles | 🔴 unless fleet-only | +| Works for offline / unreachable miners | ✅ | 🔴 | 🟡 (fleet-sourced reads only) | +| Data freshness | 🟡 ~10-min collector (5s broadcast) | ✅ ~15s live | Depends on sourcing | +| Reduces/removes minerproxy | ✅ | 🔴 | 🟡 (as device path shrinks) | +| Fleet backend work required | Medium–High (scope component collection) | None | Low–Medium (device-level fleet reads) | + +Legend: ✅ strong · 🟡 partial / with caveats · 🔴 weak. + +## Drawbacks + +The single-miner view is being redefined at the same time as its data source, so +whichever strategy we pick, component-level (ASIC/hashboard) and live-only data +are the parts most likely to feel different from today. Any strategy that sources +from the fleet trades some freshness (~10-min collector) and asks us to scope how +much component data to collect; any strategy that sources from the device trades +offline support and keeps per-firmware client bundles plus the minerproxy. No +single option is simultaneously live-fresh, single-client, and works offline — +the strategies sit at the three corners of that trade-off, which is exactly why +the adapter+proxy combination (S3) is attractive: it lets us choose per data +domain rather than globally. + +## Alternatives considered + +- **Keep ProtoOS-on-miner as-is.** Rejected by the migration direction — we want + a first-class fleet experience and the ability to render any miner in any state. +- **A single hardcoded-version client (no probe).** Rejected: fails objective 2 + the moment firmware diverges (v1 vs v2 are already materially different wire + shapes). +- **On-demand per-component passthrough RPC on the fleet server** (a server RPC + that reads `miner.GetDeviceMetrics` on request rather than persisting). Not a + separate strategy so much as one of the two ways (alongside persisting the + collector's arrays) to source component data for S1/S3 — noted here as the + concrete option to evaluate when scoping component collection. + +## Unresolved questions + +- **How much component-level (ASIC/hashboard) data do we need in the view, and at + what granularity/freshness?** This scopes the fleet work for S1/S3 — full + per-chip parity, an aggregated rollup, or device passthrough for the grid only. +- If we source component data from the fleet, do we **persist the component + arrays** (a schema + retention decision for high-cardinality per-ASIC + timeseries) or add an **on-demand passthrough RPC**? Different cost/freshness + profiles. +- For S3, **who chooses the sourcing** — is it purely internal (default fleet, + passthrough for specific reads) or ever surfaced? What's the default rule? +- Which **live operations** (LED locate, pool test, network write, firmware + upload) run fleet-mediated vs. need a live device path, and does that justify + keeping a thin device path regardless of the primary strategy? +- **Single-miner mode packaging** — a one-click local install of ProtoFleet + pointed at a single device is a prerequisite for the "served locally" half of + objective 1, and is non-trivial given the Docker environment. See the + [single-miner-mode discovery](../plans/2026-07-29-protofleet-single-miner-mode-discovery.md). + +## Phased rollout + +A pragmatic path that treats **S3 (adapter layer) as the frame** and lets us +start where the value is highest with the least backend risk: + +- **Phase 0 — Adopt the seam.** Land the shared snapshot contract, adapter seam, + and one `` as the single view, independent of backend. + (Objective 3 down payment.) +- **Phase 1 — Version-aware device path (S2).** Ship the `/api/version` probe + + v1/v2 clients through minerproxy to reach live parity, with a real ASIC grid + where firmware exposes it. Fastest route to a rich experience today, no backend + work. (Objectives 1 + 2.) +- **Phase 2 — Fleet adapter (S1) as default for reads the fleet already serves.** + Identity, KPIs, status, history at device-level granularity; enables + offline/any-firmware rendering. +- **Phase 3 — Bring component data into the fleet, as scoped.** Persist the + collector's component arrays and/or add an on-demand metrics RPC to source the + ASIC grid fleet-side at the granularity we decide we need. +- **Phase 4 — Shrink the device path** per-domain as fleet capabilities land, + keeping only a thin device path (if any) for operations that must run live. + +The end state: one view, one seam, fleet-native by default, with a shrinking +device path — serving all three objectives without a big-bang cutover. diff --git a/justfile b/justfile index 0b64fb0383..efda50b5a1 100644 --- a/justfile +++ b/justfile @@ -571,3 +571,15 @@ _asicrs-build-release: rm -rf "/tmp/asicrs-${arch}" done chmod +x deployment-files/server/asicrs-plugin-* + +# run two fake proto rigs (MDK v1 + v2) for the single-miner Lab [PROTOTYPE] +lab-fakes: + #!/usr/bin/env bash + cd server/fake-proto-rig + GOWORK=off go build -o /tmp/fakerig-lab . + echo "MDK v1 rig → http://localhost:18081 (SN PROTO-LAB-V1, pw admin1234)" + echo "MDK v2 rig → http://localhost:18082 (SN PROTO-LAB-V2, pw admin1234)" + trap 'kill 0' EXIT + MDK_VERSION=1 HTTP_PORT=18081 SERIAL_NUMBER=PROTO-LAB-V1 FAKE_RIG_PASSWORD=admin1234 FAKE_RIG_MINING=1 /tmp/fakerig-lab & + MDK_VERSION=2 HTTP_PORT=18082 SERIAL_NUMBER=PROTO-LAB-V2 FAKE_RIG_PASSWORD=admin1234 FAKE_RIG_MINING=1 /tmp/fakerig-lab & + wait diff --git a/server/fake-proto-rig/main.go b/server/fake-proto-rig/main.go index 0c3bc9a932..d05cfba1a0 100644 --- a/server/fake-proto-rig/main.go +++ b/server/fake-proto-rig/main.go @@ -46,6 +46,20 @@ func main() { // Apply error configuration from environment applyErrorConfig(state) + // PROTOTYPE: FAKE_RIG_MINING seeds a pool so the rig reports live hashrate + // (no pool → NoPools → zeroed telemetry), making the single-miner Lab demo + // show a mining device instead of an idle one. + if getEnvBool("FAKE_RIG_MINING", false) { + state.AddPool(&Pool{ + Idx: 0, + Priority: 0, + Url: "stratum+tcp://lab-pool.example:3333", + Username: "lab.worker", + Password: "x", + }) + log.Printf("Lab mining seed: added pool, rig will report Mining") + } + // Set IP address based on outbound interface state.IPAddress = getOutboundIP().String() @@ -66,12 +80,31 @@ func main() { } } +// withPrototypeCORS wraps the whole fake-rig mux with permissive CORS so a +// browser-based Strategy 3 adapter can call the rig directly (PROTOTYPE). This +// mirrors a real finding: direct browser→miner calls need CORS on the device; +// real miners lack it, which is exactly why production goes through minerproxy. +func withPrototypeCORS(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-MDK-Key") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) +} + func startHTTPServer(ctx context.Context, state *MinerState, port int) error { mux := http.NewServeMux() // Create REST API handler restHandler := NewRESTApiHandler(state) restHandler.RegisterRoutes(mux) + // PROTOTYPE: MDK v2 simulation + version probe (throwaway). + restHandler.RegisterV2Routes(mux) // Add health check endpoint mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { @@ -81,7 +114,7 @@ func startHTTPServer(ctx context.Context, state *MinerState, port int) error { server := &http.Server{ Addr: fmt.Sprintf(":%d", port), - Handler: mux, + Handler: withPrototypeCORS(mux), } // Start listening diff --git a/server/fake-proto-rig/mdk_v2.go b/server/fake-proto-rig/mdk_v2.go new file mode 100644 index 0000000000..0a077a0603 --- /dev/null +++ b/server/fake-proto-rig/mdk_v2.go @@ -0,0 +1,215 @@ +// Package main — MDK v2 simulation (PROTOTYPE, throwaway). +// +// The single-miner-view prototype needs a miner that speaks a *different* API +// than today's MDK v1 REST surface, to exercise version-routing (Strategy 2) +// and backend adapters (Strategy 3). Real MDK v2 lives in the miner-firmware +// repo; here we fake a plausibly-divergent shape: +// +// - a single consolidated GET /api/v2/miner (vs v1's many endpoints) +// - a wrapped envelope { apiVersion, data, meta } +// - camelCase fields, hashrate in GH/s (not TH/s), nested thermals +// - per-chip "chips" array with a state enum (vs v1 "asics") +// +// Enable with MDK_VERSION=2. A public GET /api/version probe lets a client pick +// the right client/adapter. These endpoints send permissive CORS headers so a +// browser-based adapter can call the miner directly (a real finding for +// Strategy 3: direct browser→miner calls need CORS on the device). +package main + +import ( + "net/http" + "os" + "strconv" + "time" +) + +const defaultFirmwareRev = "1.4.2" + +// getMDKVersion reports the simulated firmware generation ("1" or "2"). +func getMDKVersion() string { + if v := os.Getenv("MDK_VERSION"); v == "2" { + return "2" + } + return "1" +} + +func getFirmwareRev() string { + return getEnv("FIRMWARE_REV", defaultFirmwareRev) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-MDK-Key") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + next(w, r) + } +} + +// RegisterV2Routes wires the version probe (always) and the divergent v2 +// consolidated endpoint (only when MDK_VERSION=2). +func (h *RESTApiHandler) RegisterV2Routes(mux *http.ServeMux) { + mdk := getMDKVersion() + + mux.HandleFunc("/api/version", withCORS(func(w http.ResponseWriter, _ *http.Request) { + apiVersions := []string{"v1"} + if mdk == "2" { + apiVersions = []string{"v1", "v2"} + } + h.writeJSON(w, http.StatusOK, map[string]any{ + "mdkVersion": mdk, + "apiVersions": apiVersions, + "firmwareRev": getFirmwareRev(), + }) + })) + + if mdk == "2" { + mux.HandleFunc("/api/v2/miner", withCORS(h.handleV2Miner)) + } +} + +// v2 envelope types — deliberately different from the v1 JSON shapes. +type v2Envelope struct { + APIVersion string `json:"apiVersion"` + Data v2Data `json:"data"` + Meta v2Meta `json:"meta"` +} + +type v2Meta struct { + GeneratedAt string `json:"generatedAt"` + Schema string `json:"schema"` +} + +type v2Data struct { + Device v2Device `json:"device"` + State string `json:"state"` + Performance v2Performance `json:"performance"` + Boards []v2Board `json:"boards"` +} + +type v2Device struct { + DisplayName string `json:"displayName"` + HardwareModel string `json:"hardwareModel"` + FirmwareRev string `json:"firmwareRev"` + MDK string `json:"mdk"` + NetMAC string `json:"netMac"` + UnitSerial string `json:"unitSerial"` + LANIP string `json:"lanIp"` +} + +type v2Performance struct { + HashrateGHS float64 `json:"hashrateGhs"` + PowerWatts float64 `json:"powerWatts"` + Thermals v2Thermal `json:"thermals"` +} + +type v2Thermal struct { + PeakC float64 `json:"peakC"` + AvgC float64 `json:"avgC"` +} + +type v2Board struct { + Slot int `json:"slot"` + Serial string `json:"serial"` + HashrateGHS float64 `json:"hashrateGhs"` + Thermals v2Thermal `json:"thermals"` + Chips []v2Chip `json:"chips"` +} + +type v2Chip struct { + Pos int `json:"pos"` + TempC float64 `json:"tempC"` + GHS float64 `json:"ghs"` + State string `json:"state"` // ONLINE | HOT | FAULT | OFFLINE +} + +const chipsPerBoardV2 = 66 + +func (h *RESTApiHandler) handleV2Miner(w http.ResponseWriter, _ *http.Request) { + hashrateTHS, tempC, powerW, _ := h.state.GetMinerTelemetry() + boardCount := h.state.GetHashboardCount() + + boards := make([]v2Board, 0, boardCount) + for slot := 0; slot < boardCount; slot++ { + baseTemp := tempC + float64(slot)*2 + inError := h.state.IsHashboardInError(slot) + chips := make([]v2Chip, 0, chipsPerBoardV2) + var boardGHS float64 + var peak float64 + for pos := 0; pos < chipsPerBoardV2; pos++ { + wobble := float64((pos*7+slot*13)%11) - 5 + ct := baseTemp + wobble + state := "ONLINE" + switch { + case inError && pos%5 == 0: + state = "FAULT" + case ct >= baseTemp+4: + state = "HOT" + case (pos*3+slot)%37 == 0: + state = "OFFLINE" + } + ghs := 290.0 + wobble*4 + if state == "OFFLINE" || state == "FAULT" { + ghs = 0 + } + if ct > peak { + peak = ct + } + boardGHS += ghs + chips = append(chips, v2Chip{Pos: pos, TempC: round1(ct), GHS: round1(ghs), State: state}) + } + boards = append(boards, v2Board{ + Slot: slot, + Serial: "HB-" + strconv.Itoa(1000+slot), + HashrateGHS: round1(boardGHS), + Thermals: v2Thermal{PeakC: round1(peak), AvgC: round1(baseTemp)}, + Chips: chips, + }) + } + + env := v2Envelope{ + APIVersion: "2.0", + Data: v2Data{ + Device: v2Device{ + DisplayName: orDefault(h.state.Hostname, "proto-sim"), + HardwareModel: orDefault(h.state.Model, "Proto Alpha"), + FirmwareRev: getFirmwareRev(), + MDK: "2.0", + NetMAC: h.state.MacAddress, + UnitSerial: h.state.SerialNumber, + LANIP: h.state.IPAddress, + }, + State: v2State(string(h.state.GetMiningState())), + Performance: v2Performance{ + HashrateGHS: round1(hashrateTHS * 1000), + PowerWatts: round1(powerW), + Thermals: v2Thermal{PeakC: round1(tempC + 12), AvgC: round1(tempC)}, + }, + Boards: boards, + }, + Meta: v2Meta{GeneratedAt: time.Now().UTC().Format(time.RFC3339), Schema: "mdk-v2-consolidated"}, + } + h.writeJSON(w, http.StatusOK, env) +} + +func v2State(miningState string) string { + if miningState == string(MiningStateMining) || miningState == string(MiningStateDegraded) { + return "HASHING" + } + return "IDLE" +} + +func orDefault(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func round1(v float64) float64 { + return float64(int(v*10)) / 10 +}