Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions client/src/protoFleet/prototypes/README.md
Original file line number Diff line number Diff line change
@@ -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
`<SingleMinerView>`, 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 `<SingleMinerView>`, the mini `<MinersList>` (the dumbed-down
miners tab), the `<MinerViewFrame>` 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 `<SingleMinerView>`:

- **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 `<MinersList>` (one MDK v1 rig, one MDK v2); clicking a
row proxies to that miner and renders its view.
- **S3** offers an **Adapter context** dropdown (`<Select>`): _Fleet server_
(→ a discovered-miners list), _MDK v1 miner (direct)_, or _MDK v2 miner
(direct)_.

Once a miner is open, `<MinerViewFrame>` keeps the view a clean canvas (status +
KPIs + ASIC grid + one control): a left action steps back to the picker, and a
right-aligned **Details** trigger tucks the identity + data-path chrome
(`<SingleMinerDetails>`) into a modal. So the only thing that reads differently
between strategies is how the miner is picked and how its data is sourced — the
view itself is identical.

## Running it

Start the two fake rigs (MDK v1 on :18081, MDK v2 on :18082, both mining):

```
just lab-fakes
```

Then open `/lab` in the client. Strategy 2 (both rows) and Strategy 3's direct
MDK contexts use the fake rigs directly (they send permissive CORS). Strategy 1
and Strategy 3's Fleet context need fleet-api up and an authenticated session; S1
takes any fleet miner's IP, and S3's Fleet context lists discovered miners to
click.

## Deleting the prototype

Everything is isolated so it can be removed cleanly:

- `client/src/protoFleet/prototypes/` (this dir)
- the `/lab` route block in `client/src/protoFleet/router.tsx`
- `server/fake-proto-rig/mdk_v2.go` (the whole file), plus the prototype-tagged
additions in `main.go` (`withPrototypeCORS`, `FAKE_RIG_MINING`,
`RegisterV2Routes`) and the `v2State` constant fix
- the `lab-fakes` recipe in the `justfile`

No fleet-server code was added (the `prototype/v1` RPC was designed but not
built), so there is nothing to remove under `proto/` or `server/internal/`.
277 changes: 277 additions & 0 deletions client/src/protoFleet/prototypes/adapter/AdapterPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* Strategy 3 — Abstraction layer / adapters.
*
* One generic <SingleMinerView>, swappable backend adapters behind a single
* `SingleMinerAdapter` seam. Pick an adapter context from the dropdown:
*
* - Fleet server → the same distilled miners list as Strategy 2, but each row
* resolves through FleetAdapter (identity + KPIs from ListMinerStateSnapshots
* over /api-proxy; ASIC grid synthesized). This is a single-miner UI rendered
* *inside ProtoFleet off the fleet backend*.
* - MDK v1 miner (direct) → the same client served straight off a v1 fake rig,
* resolved through MdkV1Adapter.
* - MDK v2 miner (direct) → served straight off a v2 fake rig, through
* MdkV2Adapter.
*
* Every context folds its very different backend into the identical snapshot and
* renders the same view — that's the whole thesis: same view code, different
* adapters.
*/
import { useCallback, useEffect, useRef, useState } from "react";

import { FleetAdapter } from "../fleetNative/fleetAdapter";
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, MinerStatus, SingleMinerSnapshot } from "../shared/types";
import { MdkV1Adapter } from "./mdkV1Adapter";
import { MdkV2Adapter } from "./mdkV2Adapter";
import { fleetManagementClient } from "@/protoFleet/api/clients";
import Button, { sizes as buttonSizes, variants as buttonVariants } from "@/shared/components/Button";
import Select from "@/shared/components/Select";

type Context = "fleet" | "mdkv1" | "mdkv2";

const CONTEXTS = [
{
value: "fleet",
label: "Fleet server (Connect RPC)",
description: "Rendered inside ProtoFleet off the fleet backend",
},
{ value: "mdkv1", label: "MDK v1 miner (direct)", description: "Served straight off a v1 fake rig → MDK v1 adapter" },
{ value: "mdkv2", label: "MDK v2 miner (direct)", description: "Served straight off a v2 fake rig → MDK v2 adapter" },
];

const V1_URL = "http://localhost:18081";
const V2_URL = "http://localhost:18082";
const DEFAULT_PASSWORD = "admin1234";

// Single-miner view is only supported on first-party rigs (our proto rigs and
// the lab fake rigs). Filter out known third-party vendors the fleet may have
// discovered — we don't render the single-miner view for those.
const THIRD_PARTY_VENDORS = [
"antminer",
"bitmain",
"whatsminer",
"microbt",
"avalon",
"canaan",
"innosilicon",
"goldshell",
"iceriver",
"bitaxe",
];

function isFirstParty(model?: string, driver?: string, name?: string): boolean {
const hay = `${model ?? ""} ${driver ?? ""} ${name ?? ""}`.toLowerCase();
return !THIRD_PARTY_VENDORS.some((v) => hay.includes(v));
}

export default function AdapterPage() {
const [context, setContext] = useState<Context>("fleet");
const [fleetMiners, setFleetMiners] = useState<MinerListItem[] | null>(null);
const [snapshot, setSnapshot] = useState<SingleMinerSnapshot | null>(null);
const [connection, setConnection] = useState<SingleMinerAdapter | null>(null);
const [busy, setBusy] = useState(true); // fleet list loads on mount
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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;
setBusy(true);
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) {
setBusy(false);
setBusyId(null);
}
}
}, []);

// Pure fetch → items, so the mount effect can populate the list without a
// synchronous setState in the effect body. Single-miner view is only
// supported on first-party (proto / lab) rigs, so third-party miners the
// fleet has discovered are filtered out of the pick list.
const fetchFleetMiners = useCallback(async (signal?: AbortSignal): Promise<MinerListItem[]> => {
const res = await fleetManagementClient.listMinerStateSnapshots({ pageSize: 50 }, { signal });
return res.miners
.filter((m) => isFirstParty(m.model, m.driverName, m.name))
.map((m) => {
const hashrateThs = m.hashrate.length ? m.hashrate[0].value : null;
const status: MinerStatus = (hashrateThs ?? 0) > 0 ? "mining" : "offline";
return {
id: m.ipAddress || m.deviceIdentifier,
name: m.name || m.deviceIdentifier,
ipAddress: m.ipAddress || undefined,
firmware: m.firmwareVersion || undefined,
status,
};
});
}, []);

const loadFleetList = useCallback(async () => {
setBusy(true);
setError(null);
try {
setFleetMiners(await fetchFleetMiners());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
}, [fetchFleetMiners]);

// A direct MDK context has a single fixed target, so selecting it is the
// whole action — connect and render immediately, no intermediate click.
const connectDirect = useCallback(
(ctx: Context) => {
const adapter = ctx === "mdkv2" ? new MdkV2Adapter(V2_URL) : new MdkV1Adapter(V1_URL, DEFAULT_PASSWORD);
load(adapter, null, trace.makeTracer("direct"));
},
[load, trace],
);

const switchContext = useCallback(
(next: Context) => {
abortRef.current?.abort();
// Switching prototype version is a fresh connection — clear the trace here.
// (Retry, which calls connectDirect directly, appends instead.)
trace.reset();
setContext(next);
setSnapshot(null);
setConnection(null);
setError(null);
if (next === "fleet") loadFleetList();
else connectDirect(next);
},
[loadFleetList, connectDirect, trace],
);

const openFleetMiner = useCallback(
(item: MinerListItem) => {
if (!item.ipAddress) {
setError(`${item.name} has no IP to resolve.`);
return;
}
trace.reset();
load(new FleetAdapter(item.ipAddress), item.id, trace.makeTracer("fleet"));
},
[load, trace],
);

// A control action is a POST; append it (and the refetch) to the running trace
// so the pane shows how writes are handled. No reset — the trace only clears
// on a new connection or prototype switch. Only direct MDK v1 exposes controls.
const runControl = useCallback(
async (action: MinerControlAction) => {
if (!connection?.control) return;
const tracer = trace.makeTracer("direct");
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);
}, []);

// Populate the fleet list on first mount (default context is fleet). State is
// only set from async callbacks so the effect body stays synchronous-free.
useEffect(() => {
let active = true;
fetchFleetMiners()
.then((items) => active && setFleetMiners(items))
.catch((e) => active && setError(e instanceof Error ? e.message : String(e)))
.finally(() => active && setBusy(false));
return () => {
active = false;
};
}, [fetchFleetMiners]);

useEffect(() => () => abortRef.current?.abort(), []);

return (
<div className="flex flex-col gap-4">
<Select
id="adapter-context"
label="Adapter context"
options={CONTEXTS}
value={context}
onChange={(v) => switchContext(v as Context)}
/>

{snapshot ? (
<MinerViewFrame
title={`Single-miner view · ${connection?.source ?? ""}`}
// Fleet has a list to step back to; direct contexts are driven by the
// dropdown, so there's nothing to go "back" to.
leftAction={context === "fleet" ? { label: "Back", onClick: back } : undefined}
details={<SingleMinerDetails snapshot={snapshot} />}
>
<SingleMinerView snapshot={snapshot} actions={connection?.control ? { onControl: runControl } : {}} />
</MinerViewFrame>
) : context === "fleet" ? (
<MinersList
title="Fleet miners (first-party rigs)"
items={fleetMiners ?? []}
onSelect={openFleetMiner}
busyId={busyId}
emptyMessage={
busy
? "Loading fleet miners…"
: "No first-party rigs discovered — single-miner view isn't supported on third-party miners."
}
/>
) : (
<div className="flex flex-col items-center gap-4 rounded-xl border border-dashed border-border-5 bg-surface-elevated-base p-10 text-center">
<div className="flex flex-col gap-1">
<span className="text-heading-200 text-text-primary">
{context === "mdkv2" ? "MDK v2 miner (direct)" : "MDK v1 miner (direct)"}
</span>
<span className="text-200 text-text-primary-50">
{busy
? `Connecting to the ${context === "mdkv2" ? "v2" : "v1"} fake rig…`
: `Couldn't reach the ${context === "mdkv2" ? "v2" : "v1"} fake rig. Is it running?`}
</span>
</div>
{busy ? null : (
<Button
text="Retry"
onClick={() => connectDirect(context)}
size={buttonSizes.base}
variant={buttonVariants.secondary}
/>
)}
</div>
)}

{error ? (
<div className="rounded-lg border border-intent-critical-10 bg-intent-critical-10 p-3 text-200 text-text-critical">
{error}
<div className="mt-1 text-heading-100 text-text-primary-50">
Direct MDK contexts need the fake rigs (<code>just lab-fakes</code>). Fleet needs fleet-api up and an
authenticated session.
</div>
</div>
) : null}
</div>
);
}
Loading
Loading