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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,55 +1,92 @@
import { ReactNode, useEffect } from "react";
import { Link, useParams } from "react-router-dom";
import { motion } from "motion/react";
import { ReactNode, useCallback, useEffect, useState } from "react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { recallSingleMinerMetadata, type SingleMinerMetadata, type SingleMinerRouteState } from "./routeState";
import { singleMinerRoutePrefetch } from "@/protoFleet/routePrefetch";
import { scopedPath } from "@/protoFleet/routing/siteScope";
import { useFleetStore } from "@/protoFleet/store/useFleetStore";
// eslint-disable-next-line no-restricted-imports -- Fleet shell hosts the protoOS single-miner experience
import { MinerHostingProvider } from "@/protoOS/contexts/MinerHostingContext";
import { DismissCircleDark } from "@/shared/assets/icons";
import { Dismiss } from "@/shared/assets/icons";
import Button, { sizes, variants } from "@/shared/components/Button";
import useSlideUpAnimation from "@/shared/hooks/useSlideUpAnimation";
import { prefetchRoutes } from "@/shared/utils/prefetchRoutes";

const CloseButton = ({ id }: { id: string }) => {
const activeSite = useFleetStore((state) => state.ui.activeSite);
return (
<Link
className="flex flex-row items-center gap-1 pl-2 text-300 text-text-primary-70"
to={scopedPath("/fleet/miners", activeSite)}
>
<DismissCircleDark />
{id}
</Link>
);
};
const CloseButton = ({ label, onClose }: { label: string; onClose: () => void }) => (
<div className="flex min-w-0 items-center gap-3">
<Button
ariaLabel="Close miner view"
variant={variants.secondary}
size={sizes.base}
prefixIcon={<Dismiss />}
onClick={onClose}
testId="single-miner-close-button"
/>
<span className="truncate text-heading-100 text-text-primary">{label}</span>
</div>
);

/** Encode the route param as a single safe path segment. Strips C0 control
* characters and whitespace, then re-encodes so /, \, .., ?, # etc. are
* never interpreted as URL structure when used in baseUrl or minerRoot. */
// eslint-disable-next-line no-control-regex
const safePathSegment = (raw: string): string => encodeURIComponent(raw.replace(/[\x00-\x1f\x7f]/g, ""));

const routeMetadata = (state: unknown): SingleMinerMetadata | undefined =>
(state as SingleMinerRouteState | null)?.singleMinerMetadata;

const SingleMinerWrapper = ({ children }: { children: ReactNode }) => {
const { id: rawId } = useParams();
const location = useLocation();
const navigate = useNavigate();
const activeSite = useFleetStore((state) => state.ui.activeSite);
const slideUpAnimation = useSlideUpAnimation();
const [isClosing, setIsClosing] = useState(false);
const safeId = safePathSegment(rawId || "");
const displayId = rawId || "";
// location.state survives a direct render; the device-keyed cache survives the
// protoOS loader redirects (which drop navigation state).
const cachedMetadata = routeMetadata(location.state) ?? recallSingleMinerMetadata(displayId);

const metadata = {
minerName: cachedMetadata?.minerName ?? displayId,
ipAddress: cachedMetadata?.ipAddress,
macAddress: cachedMetadata?.macAddress,
firmwareVersion: cachedMetadata?.firmwareVersion,
};

const handleClose = useCallback(() => setIsClosing(true), []);

// Once the user is in /miners/:id/*, sibling protoOS chunks (KPI
// tabs, Logs, Diagnostics, per-miner Settings) are one click away;
// warm them at idle so tab switches have no Suspense flash.
// Once the user is in /miners/:id/*, sibling protoOS chunks (KPI tabs, Logs,
// Diagnostics, per-miner Settings) are one click away; warm them at idle so
// tab switches have no Suspense flash.
useEffect(() => {
return prefetchRoutes(singleMinerRoutePrefetch);
}, []);

// Here we are just setting the base url to <vite_server>/:id,
// which vite proxies to the actual miner api server.
// If we wanted to make this request to ProtoFleet backend we
// could pass <protofleet_host>/miners/:id instead
return (
<MinerHostingProvider
baseUrl={safeId}
baseUrl={`/api-proxy/miners/${safeId}`}
minerRoot={`/miners/${safeId}`}
closeButton={(<CloseButton id={displayId} />) as ReactNode}
closeButton={<CloseButton label={metadata.minerName} onClose={handleClose} />}
mode="fleet"
metadata={metadata}
>
Comment on lines 68 to 74
{children}
{/* Mirror the full-screen modal: slide/fade in on open, then finish the
exit animation before routing back to the miners list on close. Mounted
on the parent route, so this plays once per visit (not per tab). */}
<motion.div
initial={slideUpAnimation.initial}
animate={isClosing ? slideUpAnimation.exit : slideUpAnimation.animate}
transition={slideUpAnimation.transition}
onAnimationComplete={() => {
if (isClosing) {
navigate(scopedPath("/fleet/miners", activeSite));
}
}}
>
{children}
</motion.div>
</MinerHostingProvider>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb";

export type SingleMinerMetadata = {
minerName?: string;
ipAddress?: string;
macAddress?: string;
firmwareVersion?: string;
};

export type SingleMinerRouteState = {
singleMinerMetadata?: SingleMinerMetadata;
};

const nonEmpty = (value: string | undefined): string | undefined => {
const normalized = value?.trim();
return normalized ? normalized : undefined;
};

export const buildSingleMinerMetadata = (miner: MinerStateSnapshot): SingleMinerMetadata => ({
// Match the miner-list name column (MinerName): the device name, falling back
// to its identifier — not the model.
minerName: nonEmpty(miner.name) ?? nonEmpty(miner.deviceIdentifier),
ipAddress: nonEmpty(miner.ipAddress),
macAddress: nonEmpty(miner.macAddress),
firmwareVersion: nonEmpty(miner.firmwareVersion),
});

export const buildSingleMinerRouteState = (miner: MinerStateSnapshot): SingleMinerRouteState => ({
singleMinerMetadata: buildSingleMinerMetadata(miner),
});

// The protoOS index routes redirect via loaders (loader: () => redirect(...)),
// which run before render and drop navigation state — so metadata can't ride on
// location.state into SingleMinerWrapper. The opener stamps it here keyed by
// device id (it already holds the list snapshot); the wrapper reads it back.
const metadataByDevice = new Map<string, SingleMinerMetadata>();

export const rememberSingleMinerMetadata = (miner: MinerStateSnapshot): void => {
metadataByDevice.set(miner.deviceIdentifier, buildSingleMinerMetadata(miner));
};

export const recallSingleMinerMetadata = (deviceIdentifier: string): SingleMinerMetadata | undefined =>
metadataByDevice.get(deviceIdentifier);

export const canOpenEmbeddedMinerView = (miner: MinerStateSnapshot): boolean => miner.embeddedWebViewAvailable;
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useCallback } from "react";
import { useNavigate } from "react-router-dom";
import { buildSingleMinerRouteState, canOpenEmbeddedMinerView, rememberSingleMinerMetadata } from "./routeState";
import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb";

/**
* Opens a miner the same way from every entry point (row click, actions menu):
* the embedded single-miner view when the miner can be proxied, otherwise the
* miner's own web UI in a new tab. Centralized so the callers can't drift —
* the embed gate must match what the server proxy can actually serve.
*/
export const useOpenMinerView = () => {
const navigate = useNavigate();

return useCallback(
(miner: MinerStateSnapshot) => {
if (canOpenEmbeddedMinerView(miner)) {
rememberSingleMinerMetadata(miner);
navigate(`/miners/${encodeURIComponent(miner.deviceIdentifier)}`, {
state: buildSingleMinerRouteState(miner),
});
return;
}
if (miner.url) {
window.open(miner.url, "_blank", "noopener,noreferrer");
}
},
[navigate],
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest";
import { deviceActions, settingsActions } from "./constants";
import SingleMinerActionsMenu from "./SingleMinerActionsMenu";

const mockWindowOpen = vi.fn();
vi.stubGlobal("open", mockWindowOpen);
import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb";

const {
mockAuthenticateFleetModal,
mockBulkActionConfirmDialog,
mockNavigate,
mockOpenMinerView,
mockCompleteBatchOperation,
mockRemoveDevicesFromBatch,
mockStartBatchOperation,
mockWithCapabilityCheck,
mockPushToast,
mockRemoveToast,
Expand All @@ -28,10 +31,20 @@ const {
const mockUpdateSingleWorkerName = vi.fn();
const mockStreamCommandBatchUpdates = vi.fn();
const mockRefreshMiners = vi.fn();
const mockNavigate = vi.fn();
const mockOpenMinerView = vi.fn();
const mockStartBatchOperation = vi.fn();
const mockCompleteBatchOperation = vi.fn();
const mockRemoveDevicesFromBatch = vi.fn();

return {
mockAuthenticateFleetModal: vi.fn(() => null),
mockBulkActionConfirmDialog: vi.fn(() => null),
mockNavigate,
mockOpenMinerView,
mockCompleteBatchOperation,
mockRemoveDevicesFromBatch,
mockStartBatchOperation,
mockWithCapabilityCheck,
mockPushToast: vi.fn(() => 1),
mockRemoveToast: vi.fn(),
Expand Down Expand Up @@ -124,6 +137,14 @@ vi.mock("@/protoFleet/api/useRefreshMiners", () => ({
}),
}));

vi.mock("@/protoFleet/features/fleetManagement/hooks/useBatchOperations", () => ({
useBatchActions: () => ({
startBatchOperation: mockStartBatchOperation,
completeBatchOperation: mockCompleteBatchOperation,
removeDevicesFromBatch: mockRemoveDevicesFromBatch,
}),
}));

vi.mock("@/protoFleet/store/hooks/useFleet", () => ({
useMinerDeviceStatus: vi.fn(() => undefined),
}));
Expand Down Expand Up @@ -200,6 +221,14 @@ vi.mock("@/shared/features/toaster", () => ({
},
}));

vi.mock("@/shared/hooks/useNavigate", () => ({
useNavigate: () => mockNavigate,
}));

vi.mock("@/protoFleet/components/SingleMinerWrapper/useOpenMinerView", () => ({
useOpenMinerView: () => mockOpenMinerView,
}));

describe("SingleMinerActionsMenu", () => {
beforeEach(() => {
vi.clearAllMocks();
Expand Down Expand Up @@ -274,12 +303,12 @@ describe("SingleMinerActionsMenu", () => {
expect(screen.getByTestId("update-worker-names-popover-button")).toBeInTheDocument();
});

it("does not render 'View miner' menu item when minerUrl is not provided", () => {
it("renders 'View miner' menu item without requiring minerUrl", () => {
render(<SingleMinerActionsMenu deviceIdentifier="test-device-123" />);

fireEvent.click(screen.getByTestId("single-miner-actions-menu-button"));

expect(screen.queryByText("View miner")).not.toBeInTheDocument();
expect(screen.getByText("View miner")).toBeInTheDocument();
});

it("renders 'View miner' menu item when minerUrl is provided", () => {
Expand All @@ -291,14 +320,25 @@ describe("SingleMinerActionsMenu", () => {
expect(screen.getByTestId("viewMiner-popover-button")).toBeInTheDocument();
});

it("opens miner URL in new tab when 'View miner' is clicked", () => {
const minerUrl = "http://192.168.1.42";
render(<SingleMinerActionsMenu deviceIdentifier="my-device-abc" minerUrl={minerUrl} />);
it("opens the row's miner via the shared opener when 'View miner' is clicked", () => {
const miner = {
deviceIdentifier: "my-device-abc",
url: "http://192.168.1.42",
embeddedWebViewAvailable: true,
} as MinerStateSnapshot;

render(
<SingleMinerActionsMenu
deviceIdentifier="my-device-abc"
minerUrl="http://192.168.1.42"
miners={{ "my-device-abc": miner }}
/>,
);

fireEvent.click(screen.getByTestId("single-miner-actions-menu-button"));
fireEvent.click(screen.getByTestId("viewMiner-popover-button"));

expect(mockWindowOpen).toHaveBeenCalledWith(minerUrl, "_blank", "noopener,noreferrer");
expect(mockOpenMinerView).toHaveBeenCalledWith(miner);
});

it("refreshes a row without calling the full miner refetch callback", async () => {
Expand Down Expand Up @@ -698,16 +738,16 @@ describe("SingleMinerActionsMenu", () => {
return render(<SingleMinerActionsMenu deviceIdentifier="test-device" {...props} />);
}

it("shows only Unpair when needsAuthentication is true and no minerUrl", () => {
it("shows Unpair and View miner when needsAuthentication is true", () => {
renderWithActions({ needsAuthentication: true });

fireEvent.click(screen.getByTestId("single-miner-actions-menu-button"));

expect(screen.getByText("Unpair")).toBeInTheDocument();
expect(screen.getByText("View miner")).toBeInTheDocument();
expect(screen.queryByText("Reboot")).not.toBeInTheDocument();
expect(screen.queryByText("Blink LEDs")).not.toBeInTheDocument();
expect(screen.queryByText("Edit pool")).not.toBeInTheDocument();
expect(screen.queryByText("View miner")).not.toBeInTheDocument();
expect(screen.queryByTestId("refreshStatus-popover-button")).not.toBeInTheDocument();
});

Expand Down
Loading