Skip to content
Merged
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
13 changes: 13 additions & 0 deletions boot.tests/PoolConfigValidatorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,19 @@ public void DashboardDefaultsToEnabledWithLegacyFallback()
Assert.IsTrue(config.EnableLegacyUi);
}

[TestMethod]
public void EnabledNativeSv2RequiresAValidPublicPort()
{
var config = new PoolConfig
{
NativeSv2Enabled = true,
NativeSv2PublicPort = 0
};

Assert.IsTrue(PoolConfigValidator.Validate(config).Any(error =>
error.Contains("native_sv2_public_port", StringComparison.OrdinalIgnoreCase)));
}

[TestMethod]
public void BitcoinNotificationConfigurationRejectsUnsafeOrAmbiguousRpcSettings()
{
Expand Down
17 changes: 17 additions & 0 deletions boot_portal/Models/DashboardModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,25 @@ public sealed class DashboardSummaryDto
public DashboardSnapshotDto Snapshot { get; set; } = new();
public DashboardWorkRateEstimateDto WorkRate { get; set; } = new();
public DashboardPulseDto Pulse { get; set; } = new();
public DashboardMiningDto Mining { get; set; } = new();
public DashboardCapabilitiesDto Capabilities { get; set; } = new();
}

public sealed class DashboardMiningDto
{
public DashboardNativeSv2Dto NativeSv2 { get; set; } = new();
}

public sealed class DashboardNativeSv2Dto
{
public bool Enabled { get; set; }
public string PublicHost { get; set; } = string.Empty;
public int PublicPort { get; set; } = 34265;
public string Scheme { get; set; } = "stratum2+noise";
public string UsernameGuidance { get; set; } =
"Use a Bitcoin payout address for per-miner slot-0 attribution, or a worker label to use the node payout address.";
}

public sealed class DashboardNodeDto
{
public string NodeId { get; set; } = string.Empty;
Expand Down Expand Up @@ -132,6 +148,7 @@ public sealed class DashboardCapabilitiesDto
public List<string> Modules { get; set; } =
[
"status",
"miner-connection",
"snapshot",
"reserve",
"work-rate",
Expand Down
9 changes: 9 additions & 0 deletions boot_portal/Models/PoolConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,15 @@ public class PoolConfig
[JsonPropertyName("local_sv2_api_url")]
public string LocalSv2ApiUrl { get; set; } = string.Empty;

[JsonPropertyName("native_sv2_enabled")]
public bool NativeSv2Enabled { get; set; }

[JsonPropertyName("native_sv2_public_host")]
public string NativeSv2PublicHost { get; set; } = string.Empty;

[JsonPropertyName("native_sv2_public_port")]
public int NativeSv2PublicPort { get; set; } = 34265;

[JsonPropertyName("admin_rate_limit_per_minute")]
public int AdminRateLimitPerMinute { get; set; } = 12;

Expand Down
9 changes: 9 additions & 0 deletions boot_portal/Services/DashboardReadModelService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ public DashboardSummaryDto BuildSummary(string? windowKey)
TargetIntervalSeconds = status.PulseTargetIntervalSeconds,
RelayTtl = status.PulseRelayTtl
},
Mining = new DashboardMiningDto
{
NativeSv2 = new DashboardNativeSv2Dto
{
Enabled = _poolConfig.NativeSv2Enabled,
PublicHost = _poolConfig.NativeSv2PublicHost,
PublicPort = _poolConfig.NativeSv2PublicPort
}
},
Capabilities = new DashboardCapabilitiesDto
{
WebUiEnabled = _poolConfig.EnableWebUi,
Expand Down
8 changes: 8 additions & 0 deletions boot_portal/Utils/PoolConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,14 @@ public static List<string> Validate(PoolConfig config)
ValidatePositive(errors, config.StaleDatumDisconnectCooldownSeconds, "stale_datum_disconnect_cooldown_seconds");
ValidatePositive(errors, config.StaleDatumRefreshIntervalSeconds, "stale_datum_refresh_interval_seconds");
ValidateNonNegativePort(errors, config.StratumV1ProxyPort, "stratum_v1_proxy_port");
if (config.NativeSv2Enabled)
{
ValidateRequiredPort(errors, config.NativeSv2PublicPort, "native_sv2_public_port");
}
else
{
ValidateNonNegativePort(errors, config.NativeSv2PublicPort, "native_sv2_public_port");
}
if (string.IsNullOrWhiteSpace(config.StratumV1ProxyHost) && config.StratumV1ProxyPort > 0)
{
errors.Add("stratum_v1_proxy_host is required when stratum_v1_proxy_port is configured");
Expand Down
3 changes: 3 additions & 0 deletions boot_portal/boot_portal_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@
"max_accepted_share_telemetry_entries": 20000,
"testing_round_reset_mode": "none",
"testing_round_reset_low_nibble_threshold": 0,
"native_sv2_enabled": false,
"native_sv2_public_host": "",
"native_sv2_public_port": 34265,
"stratum_v1_proxy_host": "",
"stratum_v1_proxy_port": 0,
"grid_labs_support_fee_enabled": true,
Expand Down
8 changes: 7 additions & 1 deletion boot_portal/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Production API:

| Route | Visibility | Purpose |
|---|---|---|
| `/api/dashboard/v1/summary` | Public, redacted | Node truth, snapshot, work rate, pulse and capabilities |
| `/api/dashboard/v1/summary` | Public, redacted | Node truth, snapshot, work rate, pulse, miner connections and capabilities |
| `/api/dashboard/v1/history` | Public, redacted | Aggregated 6h, 24h or 7d history |
| `/api/dashboard/v1/address/{address}` | Public | Locked and provisional positions for one address |
| `/api/dashboard/v1/operator` | Admin key | Local adapters, peers and detailed diagnostics |
Expand All @@ -65,6 +65,12 @@ which topics changed; clients refetch the typed HTTP projection. Operator keys
stay in React memory only and must not enter URLs, browser storage, logs, or
exports.

Miner-facing endpoints are explicit non-secret summary data. Packaged nodes set
`native_sv2_enabled`, `native_sv2_public_host`, and `native_sv2_public_port`;
when the host is blank the dashboard suggests the browser hostname and explains
that a reachable LAN hostname or IP may be substituted. Never infer that the
operator API exists merely because the UI is private behind an appliance proxy.

The production map owns one SignalR connection per tab. Invalidation refreshes
are coalesced and single-flight: journal reads run at most once per second,
diagram snapshots at most once per two seconds, and summary/history projections
Expand Down
57 changes: 43 additions & 14 deletions boot_portal/ui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FormEvent, startTransition, useEffect, useState } from "react";
import { dashboardApi } from "./api";
import { StatusDot } from "./components/Primitives";
import { MinerConnectionPanel } from "./components/MinerConnection";
import { SystemMap } from "./components/SystemMap";
import { formatAge } from "./format";
import { useDashboard } from "./hooks/useDashboard";
Expand All @@ -25,6 +26,7 @@ function MapApp() {
const [adminKey, setAdminKey] = useState("");
const [adminDraft, setAdminDraft] = useState("");
const [unlockOpen, setUnlockOpen] = useState(false);
const [miningOpen, setMiningOpen] = useState(false);
const live = useDiagram(adminKey);

useEffect(() => {
Expand Down Expand Up @@ -82,6 +84,11 @@ function MapApp() {
{live.stale ? "reconnecting" : "live"}
</span>
<a className="details-link" href="/details">Details</a>
{summary.mining?.nativeSv2.enabled ? (
<button type="button" className="operator-button" onClick={() => setMiningOpen(true)}>
Connect miner
</button>
) : null}
<button
type="button"
className="icon-button"
Expand All @@ -90,13 +97,15 @@ function MapApp() {
>
{theme === "dark" ? "○" : "●"}
</button>
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
onClick={() => adminKey ? setAdminKey("") : setUnlockOpen(true)}
>
{adminKey ? "Lock operator" : "Operator"}
</button>
{summary.capabilities.operatorApiAvailable ? (
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
onClick={() => adminKey ? setAdminKey("") : setUnlockOpen(true)}
>
{adminKey ? "Lock operator" : "Operator"}
</button>
) : null}
</div>
</header>

Expand Down Expand Up @@ -135,6 +144,24 @@ function MapApp() {
unlock={unlock}
/>
) : null}
{miningOpen ? (
<div className="modal-backdrop" role="presentation" onMouseDown={() => setMiningOpen(false)}>
<section
className="modal connection-modal"
role="dialog"
aria-modal="true"
aria-labelledby="miner-connection-title"
onMouseDown={(event) => event.stopPropagation()}
>
<p className="eyebrow">Canonical miner transport</p>
<h2 id="miner-connection-title">Connect a native SV2 miner</h2>
<MinerConnectionPanel summary={summary} />
<div className="modal-actions">
<button type="button" onClick={() => setMiningOpen(false)}>Close</button>
</div>
</section>
</div>
) : null}
</div>
);
}
Expand Down Expand Up @@ -242,13 +269,15 @@ function DetailsApp() {
>
{theme === "dark" ? "○" : "●"}
</button>
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
onClick={() => adminKey ? setAdminKey("") : setUnlockOpen(true)}
>
{adminKey ? "Lock operator" : "Operator"}
</button>
{summary.capabilities.operatorApiAvailable ? (
<button
type="button"
className={adminKey ? "operator-button operator-unlocked" : "operator-button"}
onClick={() => adminKey ? setAdminKey("") : setUnlockOpen(true)}
>
{adminKey ? "Lock operator" : "Operator"}
</button>
) : null}
</div>
</header>

Expand Down
16 changes: 13 additions & 3 deletions boot_portal/ui/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ import type {

export class ApiError extends Error {
readonly status: number;
readonly code: string;

constructor(status: number, message: string) {
constructor(status: number, message: string, code = "") {
super(message);
this.status = status;
this.code = code;
}
}

export function redirectToSetupIfRequired(error: unknown): boolean {
if (!(error instanceof ApiError) || error.code !== "setup_required") return false;
if (window.location.pathname !== "/setup") window.location.replace("/setup");
return true;
}

async function request<T>(path: string, adminKey?: string): Promise<T> {
const headers = new Headers({ Accept: "application/json" });
if (adminKey) {
Expand All @@ -30,13 +38,15 @@ async function request<T>(path: string, adminKey?: string): Promise<T> {
});
if (!response.ok) {
let reason = `${response.status} ${response.statusText}`;
let code = "";
try {
const payload = (await response.json()) as { reason?: string; message?: string };
const payload = (await response.json()) as { status?: string; reason?: string; message?: string };
code = payload.status ?? "";
reason = payload.reason ?? payload.message ?? reason;
} catch {
// Keep the HTTP status when the response is not JSON.
}
throw new ApiError(response.status, reason);
throw new ApiError(response.status, reason, code);
}
return (await response.json()) as T;
}
Expand Down
28 changes: 28 additions & 0 deletions boot_portal/ui/src/components/MinerConnection.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { MinerConnectionPanel, nativeSv2Url } from "./MinerConnection";
import { summaryFixture } from "../test/fixture";

describe("MinerConnectionPanel", () => {
it("renders an explicit native SV2 target", () => {
render(<MinerConnectionPanel summary={summaryFixture} />);

expect(screen.getByText("stratum2+noise://node.gridpool.test:34265")).toBeInTheDocument();
expect(screen.getByText("Your payout address, or a worker label")).toBeInTheDocument();
});

it("falls back to the dashboard host when no public host is configured", () => {
const localSummary = structuredClone(summaryFixture);
localSummary.mining!.nativeSv2.publicHost = "";

expect(nativeSv2Url(localSummary)).toBe("stratum2+noise://umbrel.local:34265");
});

it("handles a cached response from a runtime without connection metadata", () => {
const legacySummary = structuredClone(summaryFixture);
delete legacySummary.mining;

render(<MinerConnectionPanel summary={legacySummary} />);
expect(screen.getByText(/does not advertise/i)).toBeInTheDocument();
});
});
65 changes: 65 additions & 0 deletions boot_portal/ui/src/components/MinerConnection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useState } from "react";
import type { DashboardSummary } from "../types";

const unavailableSv2 = {
enabled: false,
publicHost: "",
publicPort: 34265,
scheme: "stratum2+noise",
usernameGuidance: "Use a Bitcoin payout address or worker label."
};

function connectionHost(configuredHost: string): string {
if (configuredHost.trim()) return configuredHost.trim();
const browserHost = window.location.hostname;
return !browserHost || browserHost === "localhost" || browserHost === "127.0.0.1"
? "umbrel.local"
: browserHost;
}

export function nativeSv2Url(summary: DashboardSummary): string {
const sv2 = summary.mining?.nativeSv2 ?? unavailableSv2;
return `${sv2.scheme}://${connectionHost(sv2.publicHost)}:${sv2.publicPort}`;
}

export function MinerConnectionPanel({ summary }: { summary: DashboardSummary }) {
const [copied, setCopied] = useState(false);
const sv2 = summary.mining?.nativeSv2 ?? unavailableSv2;
const url = nativeSv2Url(summary);

if (!sv2.enabled) {
return <p className="explain">This node does not advertise a miner-facing Stratum V2 service.</p>;
}

const copy = async () => {
await navigator.clipboard.writeText(url);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
};

return (
<div className="miner-connection">
<div className="connection-target">
<span>Stratum V2 endpoint</span>
<code>{url}</code>
<button type="button" className="button-ghost" onClick={() => void copy()}>
{copied ? "Copied" : "Copy"}
</button>
</div>
<dl className="connection-fields">
<div><dt>Host</dt><dd>{connectionHost(sv2.publicHost)}</dd></div>
<div><dt>Port</dt><dd>{sv2.publicPort}</dd></div>
<div><dt>Protocol</dt><dd>Native Stratum V2 with Noise</dd></div>
<div><dt>Username</dt><dd>Your payout address, or a worker label</dd></div>
</dl>
<p className="explain">{sv2.usernameGuidance}</p>
<p className="explain">
The miner must be on a network that can reach this node. If the suggested
hostname does not resolve from the miner, use the Umbrel device&apos;s LAN IP.
</p>
{!summary.health.miningWorkSafe ? (
<p className="notice notice-bad">Mining work is currently paused: {summary.health.miningWorkSafetyReason}</p>
) : null}
</div>
);
}
3 changes: 2 additions & 1 deletion boot_portal/ui/src/hooks/useDashboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr";
import { useEffect, useEffectEvent, useState } from "react";
import { dashboardApi } from "../api";
import { dashboardApi, redirectToSetupIfRequired } from "../api";
import type {
DashboardChanged,
DashboardState,
Expand Down Expand Up @@ -39,6 +39,7 @@ export function useDashboard(windowKey: WindowKey, adminKey: string) {
lastUpdatedUtc: new Date().toISOString()
}));
} catch (error) {
if (redirectToSetupIfRequired(error)) return;
setState((current) => ({
...current,
loading: false,
Expand Down
3 changes: 2 additions & 1 deletion boot_portal/ui/src/hooks/useDiagram.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { HubConnectionBuilder, HubConnectionState } from "@microsoft/signalr";
import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react";
import { dashboardApi } from "../api";
import { dashboardApi, redirectToSetupIfRequired } from "../api";
import type {
DashboardChanged,
DashboardDiagram,
Expand Down Expand Up @@ -39,6 +39,7 @@ export function useDiagram(adminKey: string) {
);

const reportFailure = useEffectEvent((reason: unknown, fallback: string) => {
if (redirectToSetupIfRequired(reason)) return;
setLoading(false);
setStale(diagram !== null);
setError(reason instanceof Error ? reason.message : fallback);
Expand Down
Loading
Loading