From cf7cdc1ce3efda070038a283873c4e0372f8cc4d Mon Sep 17 00:00:00 2001 From: keegreil Date: Sat, 12 Sep 2026 15:36:47 -0400 Subject: [PATCH] fix: make appliance onboarding self-explanatory --- boot.tests/PoolConfigValidatorTests.cs | 13 ++++ boot_portal/Models/DashboardModels.cs | 17 +++++ boot_portal/Models/PoolConfig.cs | 9 +++ .../Services/DashboardReadModelService.cs | 9 +++ boot_portal/Utils/PoolConfigValidator.cs | 8 +++ boot_portal/boot_portal_config.json | 3 + boot_portal/ui/AGENTS.md | 8 ++- boot_portal/ui/src/App.tsx | 57 ++++++++++++---- boot_portal/ui/src/api.ts | 16 ++++- .../src/components/MinerConnection.test.tsx | 28 ++++++++ .../ui/src/components/MinerConnection.tsx | 65 +++++++++++++++++++ boot_portal/ui/src/hooks/useDashboard.ts | 3 +- boot_portal/ui/src/hooks/useDiagram.ts | 3 +- boot_portal/ui/src/modules/index.tsx | 10 +++ boot_portal/ui/src/styles.css | 63 ++++++++++++++++++ boot_portal/ui/src/test/fixture.ts | 9 +++ boot_portal/ui/src/types.ts | 9 +++ docker/boot_portal_config.sample.json | 3 + 18 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 boot_portal/ui/src/components/MinerConnection.test.tsx create mode 100644 boot_portal/ui/src/components/MinerConnection.tsx diff --git a/boot.tests/PoolConfigValidatorTests.cs b/boot.tests/PoolConfigValidatorTests.cs index 42e69c2..90b0b54 100644 --- a/boot.tests/PoolConfigValidatorTests.cs +++ b/boot.tests/PoolConfigValidatorTests.cs @@ -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() { diff --git a/boot_portal/Models/DashboardModels.cs b/boot_portal/Models/DashboardModels.cs index 7d99901..0da31e7 100644 --- a/boot_portal/Models/DashboardModels.cs +++ b/boot_portal/Models/DashboardModels.cs @@ -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; @@ -132,6 +148,7 @@ public sealed class DashboardCapabilitiesDto public List Modules { get; set; } = [ "status", + "miner-connection", "snapshot", "reserve", "work-rate", diff --git a/boot_portal/Models/PoolConfig.cs b/boot_portal/Models/PoolConfig.cs index f7545d8..2776ec4 100644 --- a/boot_portal/Models/PoolConfig.cs +++ b/boot_portal/Models/PoolConfig.cs @@ -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; diff --git a/boot_portal/Services/DashboardReadModelService.cs b/boot_portal/Services/DashboardReadModelService.cs index ad1efd6..653bdda 100644 --- a/boot_portal/Services/DashboardReadModelService.cs +++ b/boot_portal/Services/DashboardReadModelService.cs @@ -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, diff --git a/boot_portal/Utils/PoolConfigValidator.cs b/boot_portal/Utils/PoolConfigValidator.cs index 18f1b53..16fc4a4 100644 --- a/boot_portal/Utils/PoolConfigValidator.cs +++ b/boot_portal/Utils/PoolConfigValidator.cs @@ -233,6 +233,14 @@ public static List 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"); diff --git a/boot_portal/boot_portal_config.json b/boot_portal/boot_portal_config.json index 318cc44..825924f 100644 --- a/boot_portal/boot_portal_config.json +++ b/boot_portal/boot_portal_config.json @@ -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, diff --git a/boot_portal/ui/AGENTS.md b/boot_portal/ui/AGENTS.md index 4ff1c61..f3bf23d 100644 --- a/boot_portal/ui/AGENTS.md +++ b/boot_portal/ui/AGENTS.md @@ -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 | @@ -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 diff --git a/boot_portal/ui/src/App.tsx b/boot_portal/ui/src/App.tsx index 01fd467..8ae80dc 100644 --- a/boot_portal/ui/src/App.tsx +++ b/boot_portal/ui/src/App.tsx @@ -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"; @@ -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(() => { @@ -82,6 +84,11 @@ function MapApp() { {live.stale ? "reconnecting" : "live"} Details + {summary.mining?.nativeSv2.enabled ? ( + + ) : null} - + {summary.capabilities.operatorApiAvailable ? ( + + ) : null} @@ -135,6 +144,24 @@ function MapApp() { unlock={unlock} /> ) : null} + {miningOpen ? ( +
setMiningOpen(false)}> +
event.stopPropagation()} + > +

Canonical miner transport

+

Connect a native SV2 miner

+ +
+ +
+
+
+ ) : null} ); } @@ -242,13 +269,15 @@ function DetailsApp() { > {theme === "dark" ? "○" : "●"} - + {summary.capabilities.operatorApiAvailable ? ( + + ) : null} diff --git a/boot_portal/ui/src/api.ts b/boot_portal/ui/src/api.ts index c71c063..f3a5725 100644 --- a/boot_portal/ui/src/api.ts +++ b/boot_portal/ui/src/api.ts @@ -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(path: string, adminKey?: string): Promise { const headers = new Headers({ Accept: "application/json" }); if (adminKey) { @@ -30,13 +38,15 @@ async function request(path: string, adminKey?: string): Promise { }); 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; } diff --git a/boot_portal/ui/src/components/MinerConnection.test.tsx b/boot_portal/ui/src/components/MinerConnection.test.tsx new file mode 100644 index 0000000..b320859 --- /dev/null +++ b/boot_portal/ui/src/components/MinerConnection.test.tsx @@ -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(); + + 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(); + expect(screen.getByText(/does not advertise/i)).toBeInTheDocument(); + }); +}); diff --git a/boot_portal/ui/src/components/MinerConnection.tsx b/boot_portal/ui/src/components/MinerConnection.tsx new file mode 100644 index 0000000..e706bf0 --- /dev/null +++ b/boot_portal/ui/src/components/MinerConnection.tsx @@ -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

This node does not advertise a miner-facing Stratum V2 service.

; + } + + const copy = async () => { + await navigator.clipboard.writeText(url); + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }; + + return ( +
+
+ Stratum V2 endpoint + {url} + +
+
+
Host
{connectionHost(sv2.publicHost)}
+
Port
{sv2.publicPort}
+
Protocol
Native Stratum V2 with Noise
+
Username
Your payout address, or a worker label
+
+

{sv2.usernameGuidance}

+

+ 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's LAN IP. +

+ {!summary.health.miningWorkSafe ? ( +

Mining work is currently paused: {summary.health.miningWorkSafetyReason}

+ ) : null} +
+ ); +} diff --git a/boot_portal/ui/src/hooks/useDashboard.ts b/boot_portal/ui/src/hooks/useDashboard.ts index bdce4a5..95b6fea 100644 --- a/boot_portal/ui/src/hooks/useDashboard.ts +++ b/boot_portal/ui/src/hooks/useDashboard.ts @@ -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, @@ -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, diff --git a/boot_portal/ui/src/hooks/useDiagram.ts b/boot_portal/ui/src/hooks/useDiagram.ts index 81b60b4..296cbff 100644 --- a/boot_portal/ui/src/hooks/useDiagram.ts +++ b/boot_portal/ui/src/hooks/useDiagram.ts @@ -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, @@ -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); diff --git a/boot_portal/ui/src/modules/index.tsx b/boot_portal/ui/src/modules/index.tsx index 512312e..7674800 100644 --- a/boot_portal/ui/src/modules/index.tsx +++ b/boot_portal/ui/src/modules/index.tsx @@ -1,6 +1,7 @@ import { FormEvent, useState } from "react"; import { dashboardApi } from "../api"; import { Card, EmptyState, HashValue, Metric, Progress, StatusDot } from "../components/Primitives"; +import { MinerConnectionPanel } from "../components/MinerConnection"; import { formatAge, formatDate, formatPercent, formatSats, formatUncertainty } from "../format"; import type { DashboardModuleContext } from "./context"; @@ -159,6 +160,14 @@ function PulseModule({ summary }: DashboardModuleContext) { ); } +function MinerConnectionModule({ summary }: DashboardModuleContext) { + return ( + + + + ); +} + function AddressModule(context: DashboardModuleContext) { const [address, setAddress] = useState(""); const submit = (event: FormEvent) => { @@ -435,6 +444,7 @@ function ConsoleModule(context: DashboardModuleContext) { export const dashboardModules: DashboardModule[] = [ { id: "status", scope: "public", className: "module-full", render: (context) => }, + { id: "miner-connection", scope: "public", className: "module-full", render: (context) => }, { id: "snapshot", scope: "public", className: "module-half", render: (context) => }, { id: "reserve", scope: "public", className: "module-half", render: (context) => }, { id: "work-rate", scope: "public", className: "module-wide", render: (context) => }, diff --git a/boot_portal/ui/src/styles.css b/boot_portal/ui/src/styles.css index 28e2775..c2d10cb 100644 --- a/boot_portal/ui/src/styles.css +++ b/boot_portal/ui/src/styles.css @@ -821,6 +821,69 @@ footer div { margin-top: 1rem; } +.connection-modal { + width: min(680px, 100%); +} + +.miner-connection { + display: grid; + gap: 1rem; + margin-top: 1rem; +} + +.connection-target { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 0.55rem 0.75rem; + align-items: center; + border: 1px solid var(--line); + padding: 1rem; + background: var(--panel-soft); +} + +.connection-target > span { + grid-column: 1 / -1; + color: var(--muted); + font-family: "IBM Plex Mono", monospace; + font-size: 0.66rem; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.connection-target code { + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.88rem; +} + +.connection-target button { + padding: 0.45rem 0.65rem; +} + +.connection-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.connection-fields div { + border-top: 1px solid var(--line); + padding-top: 0.65rem; +} + +.connection-fields dt { + color: var(--muted); + font-size: 0.7rem; +} + +.connection-fields dd { + margin: 0.25rem 0 0; + font-family: "IBM Plex Mono", monospace; + font-size: 0.8rem; + overflow-wrap: anywhere; +} + .loading-screen, .fatal-screen { display: grid; diff --git a/boot_portal/ui/src/test/fixture.ts b/boot_portal/ui/src/test/fixture.ts index 45a4d8a..4b60a3b 100644 --- a/boot_portal/ui/src/test/fixture.ts +++ b/boot_portal/ui/src/test/fixture.ts @@ -84,6 +84,15 @@ export const summaryFixture: DashboardSummary = { relayTtl: 1, interpretation: "Pulse proofs are liveness." }, + mining: { + nativeSv2: { + enabled: true, + publicHost: "node.gridpool.test", + publicPort: 34265, + scheme: "stratum2+noise", + usernameGuidance: "Use a payout address or worker label." + } + }, capabilities: { webUiEnabled: true, legacyUiEnabled: true, diff --git a/boot_portal/ui/src/types.ts b/boot_portal/ui/src/types.ts index c63f78b..b532489 100644 --- a/boot_portal/ui/src/types.ts +++ b/boot_portal/ui/src/types.ts @@ -66,6 +66,15 @@ export interface DashboardSummary { relayTtl: number; interpretation: string; }; + mining?: { + nativeSv2: { + enabled: boolean; + publicHost: string; + publicPort: number; + scheme: string; + usernameGuidance: string; + }; + }; capabilities: { webUiEnabled: boolean; legacyUiEnabled: boolean; diff --git a/docker/boot_portal_config.sample.json b/docker/boot_portal_config.sample.json index 8a55afc..a61e1aa 100644 --- a/docker/boot_portal_config.sample.json +++ b/docker/boot_portal_config.sample.json @@ -105,6 +105,9 @@ "datum_keepalive_interval_seconds": 30, "bitcoin_zmq_endpoint": "tcp://127.0.0.1:28332", "bitcoin_zmq_rawblock_endpoint": "tcp://127.0.0.1:28333", + "native_sv2_enabled": false, + "native_sv2_public_host": "", + "native_sv2_public_port": 34265, "stratum_v1_proxy_host": "", "stratum_v1_proxy_port": 0 }