diff --git a/client/src/protoFleet/components/TargetSelectButton/TargetSelectButton.tsx b/client/src/protoFleet/components/TargetSelectButton/TargetSelectButton.tsx index 0feecffaa4..6cd7148b29 100644 --- a/client/src/protoFleet/components/TargetSelectButton/TargetSelectButton.tsx +++ b/client/src/protoFleet/components/TargetSelectButton/TargetSelectButton.tsx @@ -1,4 +1,4 @@ -import Button, { variants } from "@/shared/components/Button"; +import Button, { sizes, variants } from "@/shared/components/Button"; import Row from "@/shared/components/Row"; interface TargetSelectButtonProps { @@ -6,9 +6,15 @@ interface TargetSelectButtonProps { value: string; disabled?: boolean; onClick: () => void; + /** + * Button size for the value control. Defaults to `base` to preserve the + * curtailment/schedule modals' existing sizing; the firmware rollout Apply-to + * tables opt into `compact`. + */ + size?: keyof typeof sizes; } -function TargetSelectButton({ label, value, disabled = false, onClick }: TargetSelectButtonProps) { +function TargetSelectButton({ label, value, disabled = false, onClick, size = sizes.base }: TargetSelectButtonProps) { return ( {label} @@ -16,6 +22,7 @@ function TargetSelectButton({ label, value, disabled = false, onClick }: TargetS ariaLabel={`${label} ${value}`} text={value} variant={variants.secondary} + size={size} disabled={disabled} onClick={onClick} /> diff --git a/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.stories.tsx b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.stories.tsx new file mode 100644 index 0000000000..67b8bdf463 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.stories.tsx @@ -0,0 +1,92 @@ +import type { ComponentProps } from "react"; +import type { Meta, StoryObj } from "@storybook/react"; + +import FleetContextualSuggestion from "./FleetContextualSuggestion"; +import { Asic, Building, Fleet, Racks } from "@/shared/assets/icons"; + +const meta = { + title: "Proto Fleet/Fleet Management/FleetContextualSuggestion", + component: FleetContextualSuggestion, + parameters: { + layout: "padded", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; +type FleetContextualSuggestionProps = ComponentProps; + +const noop = () => undefined; + +const rackCreationArgs: FleetContextualSuggestionProps = { + icon: , + title: "24 unassigned miners from 10.90.12.40-10.90.12.63 look like a rack.", + detail: "Seen in nearby IPs. Mostly Proto Rig.", + action: { + label: "Review", + onClick: noop, + }, + onDismiss: noop, +}; + +const minerPairingArgs: FleetContextualSuggestionProps = { + icon: , + title: "12 detected miners in 10.90.13.80-10.90.13.91 are ready to pair.", + detail: "Found during the latest network poll. All are reporting default credentials.", + action: { + label: "Review", + onClick: noop, + }, + onDismiss: noop, +}; + +const containerCreationArgs: FleetContextualSuggestionProps = { + icon: , + title: "96 miners across 10.90.20.0/24 look like a container.", + detail: "Detected as four adjacent rack-sized cohorts with matching firmware and model.", + action: { + label: "Review", + onClick: noop, + }, + onDismiss: noop, +}; + +const buildingGroupingArgs: FleetContextualSuggestionProps = { + icon: , + title: "4 rack-shaped cohorts in 10.90.0.0/20 look like Building B.", + detail: "Detected from configured IP ranges and contiguous rack groupings.", + action: { + label: "Review", + onClick: noop, + }, + onDismiss: noop, +}; + +const examples = [rackCreationArgs, minerPairingArgs, containerCreationArgs, buildingGroupingArgs]; + +export const AllExamples: Story = { + args: rackCreationArgs, + render: () => ( +
+ {examples.map((example) => ( + + ))} +
+ ), +}; + +export const RackCreation: Story = { + args: rackCreationArgs, +}; + +export const MinerPairing: Story = { + args: minerPairingArgs, +}; + +export const ContainerCreation: Story = { + args: containerCreationArgs, +}; + +export const BuildingGrouping: Story = { + args: buildingGroupingArgs, +}; diff --git a/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.test.tsx b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.test.tsx new file mode 100644 index 0000000000..3283df9919 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, test, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import FleetContextualSuggestion from "./FleetContextualSuggestion"; + +describe("FleetContextualSuggestion", () => { + test("renders contextual copy and dispatches action", async () => { + const user = userEvent.setup(); + const onReview = vi.fn(); + const onDismiss = vi.fn(); + + render( + , + ); + + expect(screen.getByText("24 unassigned miners from 10.90.12.40-10.90.12.63 look like a rack.")).toBeVisible(); + expect(screen.getByText("Seen in nearby IPs. Mostly Proto Rig.")).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Review" })); + await user.click(screen.getByRole("button", { name: "Dismiss suggestion" })); + + expect(onReview).toHaveBeenCalledTimes(1); + expect(onDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.tsx b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.tsx new file mode 100644 index 0000000000..e38c7f56f5 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/components/FleetContextualSuggestion.tsx @@ -0,0 +1,81 @@ +import { type ReactNode } from "react"; +import clsx from "clsx"; + +import { DismissTiny, Info } from "@/shared/assets/icons"; +import Button, { type ButtonVariant, sizes, variants } from "@/shared/components/Button"; + +export type FleetContextualSuggestionAction = { + label: string; + onClick: () => void; + variant?: ButtonVariant; + disabled?: boolean; + testId?: string; +}; + +type FleetContextualSuggestionProps = { + className?: string; + title: string; + detail?: string; + icon?: ReactNode; + action: FleetContextualSuggestionAction; + onDismiss?: () => void; + testId?: string; +}; + +const renderAction = (action: FleetContextualSuggestionAction) => ( + +); + +const FleetContextualSuggestion = ({ + className, + title, + detail, + icon = , + action, + onDismiss, + testId = "fleet-contextual-suggestion", +}: FleetContextualSuggestionProps) => ( +
+
+
+ {icon} +
+
+
{title}
+ {detail ?
{detail}
: null} +
+
+
+ {renderAction(action)} + {onDismiss ? ( +
+
+); + +export default FleetContextualSuggestion; diff --git a/client/src/protoFleet/features/fleetManagement/components/RowActionsMenu/RowActionsMenu.tsx b/client/src/protoFleet/features/fleetManagement/components/RowActionsMenu/RowActionsMenu.tsx index 00ba5630e1..0536f34759 100644 --- a/client/src/protoFleet/features/fleetManagement/components/RowActionsMenu/RowActionsMenu.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/RowActionsMenu/RowActionsMenu.tsx @@ -1,4 +1,5 @@ import { Fragment, type ReactNode, useCallback, useEffect, useState } from "react"; +import clsx from "clsx"; import { Ellipsis } from "@/shared/assets/icons"; import { iconSizes } from "@/shared/assets/icons/constants"; @@ -19,6 +20,7 @@ export interface RowAction { showGroupDivider?: boolean; hidden?: boolean; disabled?: boolean; + danger?: boolean; testId?: string; } @@ -104,7 +106,7 @@ const RowActionsMenuInner = ({ setPopoverRenderMode("portal-fixed"); }, [setPopoverRenderMode]); - // Disabled hard-closes; re-enable doesn't resurrect — operator must reopen. + // Disabled hard-closes; re-enable doesn't resurrect, operator must reopen. const open = isOpen && !disabled; const setMenuOpen = useCallback( @@ -146,6 +148,7 @@ const RowActionsMenuInner = ({ ({ disabled: action.disabled, + danger: action.danger, icon: action.icon, label: action.label, onClick: action.onClick, @@ -178,7 +181,7 @@ const RowActionsMenuInner = ({
{ diff --git a/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx b/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx index 2be4287c45..8198ab673c 100644 --- a/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx +++ b/client/src/protoFleet/features/fleetManagement/pages/RacksPage.tsx @@ -1,13 +1,16 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import clsx from "clsx"; +import { create } from "@bufbuild/protobuf"; import { useBuildings } from "@/protoFleet/api/buildings"; import { type BuildingWithCounts } from "@/protoFleet/api/generated/buildings/v1/buildings_pb"; import { type DeviceSet } from "@/protoFleet/api/generated/device_set/v1/device_set_pb"; +import { MinerListFilterSchema, PairingStatus } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; import { type SiteWithCounts } from "@/protoFleet/api/generated/sites/v1/sites_pb"; import { useSites } from "@/protoFleet/api/sites"; import { useDeviceSets } from "@/protoFleet/api/useDeviceSets"; +import useFleet from "@/protoFleet/api/useFleet"; import type { DeviceSetListItem } from "@/protoFleet/components/DeviceSetList"; import type { DeviceSetColumn } from "@/protoFleet/components/DeviceSetList"; import { DEFAULT_PAGE_SIZE, DeviceSetList, issueOptions, useIssueFilter } from "@/protoFleet/components/DeviceSetList"; @@ -20,12 +23,14 @@ import NoFilterResultsEmptyState from "@/protoFleet/components/NoFilterResultsEm import NullState from "@/protoFleet/components/NullState"; import { intersectSiteFilters, + isMatchNoneSiteFilter, siteFilterFromActive, useActiveSite, } from "@/protoFleet/components/PageHeader/SitePicker"; import ParentPickerModal from "@/protoFleet/components/ParentPickerModal"; import { PAGE_SCROLL_CHROME_WIDTH } from "@/protoFleet/constants/layout"; import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; +import FleetContextualSuggestion from "@/protoFleet/features/fleetManagement/components/FleetContextualSuggestion"; import { useFleetCreateFlow } from "@/protoFleet/features/fleetManagement/components/FleetCreateFlow/context"; import FleetGroupActionsMenu from "@/protoFleet/features/fleetManagement/components/FleetGroupActionsMenu"; import FleetGroupListActionBar from "@/protoFleet/features/fleetManagement/components/FleetGroupActionsMenu/FleetGroupListActionBar"; @@ -44,6 +49,7 @@ import { UNASSIGNED_URL_VALUE, } from "@/protoFleet/features/fleetManagement/utils/filterUrlParams"; import { mapRackToCardProps } from "@/protoFleet/features/fleetManagement/utils/rackCardMapper"; +import { buildRackCreationSuggestion } from "@/protoFleet/features/fleetManagement/utils/rackSuggestion"; import { TELEMETRY_FILTER_BOUNDS, TELEMETRY_FILTER_KEYS, @@ -67,6 +73,7 @@ import SegmentedControl from "@/shared/components/SegmentedControl"; import { pushToast, STATUSES } from "@/shared/features/toaster"; import useMeasure from "@/shared/hooks/useMeasure"; import { useNavigate } from "@/shared/hooks/useNavigate"; +import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage"; import type { NumericRangeValue } from "@/shared/utils/filterValidation"; const RACK_COLUMNS_FLEET: DeviceSetColumn[] = [ @@ -114,12 +121,16 @@ const TELEMETRY_FILTER_CHIPS: FilterChipsBarNumericFilter[] = TELEMETRY_FILTER_K title: TELEMETRY_FILTER_BOUNDS[key].label, bounds: TELEMETRY_FILTER_BOUNDS[key], })); +const RACK_SUGGESTION_DISMISSED_KEY = "fleet:rackCreationSuggestionsDismissed"; +const RACK_SUGGESTION_PAGE_SIZE = 144; const RacksPage = () => { const navigate = useNavigate(); const { listRacks, listRackZones, deleteGroup } = useDeviceSets(); const { listAllBuildings, assignRacksToBuilding } = useBuildings(); const canEditRack = useHasPermission("rack:manage"); + const canReadMiners = useHasPermission("miner:read"); + const canReadFleet = useHasPermission("fleet:read"); const canReadSiteCatalog = useHasPermission("site:read"); // Both "Add to building" and "Add to site" reparent actions are gated // by site:manage (server enforces the same). One flag, two actions. @@ -771,6 +782,84 @@ const RacksPage = () => { selectedIssues.length > 0 || telemetryRanges.length > 0; + const rackSuggestionFilter = useMemo( + () => + create(MinerListFilterSchema, { + includeNoRack: true, + siteIds: effectiveSiteFilter.siteIds, + includeUnassigned: effectiveSiteFilter.includeUnassigned, + }), + [effectiveSiteFilter.includeUnassigned, effectiveSiteFilter.siteIds], + ); + const rackSuggestionEnabled = + !!createFlow && + canEditRack && + canReadMiners && + canReadFleet && + !hasActiveFilters && + !isMatchNoneSiteFilter(effectiveSiteFilter); + const { + minerIds: unrackedMinerIds, + miners: unrackedMiners, + hasInitialLoadCompleted: unrackedMinersLoaded, + refreshCurrentPage: refreshUnrackedMiners, + } = useFleet({ + enabled: rackSuggestionEnabled, + pageSize: RACK_SUGGESTION_PAGE_SIZE, + filter: rackSuggestionFilter, + pairingStatuses: [PairingStatus.PAIRED, PairingStatus.DEFAULT_PASSWORD], + }); + const rackSuggestion = useMemo(() => { + if (!unrackedMinersLoaded) return undefined; + return buildRackCreationSuggestion(unrackedMinerIds.flatMap((id) => unrackedMiners[id] ?? [])); + }, [unrackedMinerIds, unrackedMiners, unrackedMinersLoaded]); + const [dismissedRackSuggestions, setDismissedRackSuggestions] = useReactiveLocalStorage>( + RACK_SUGGESTION_DISMISSED_KEY, + {}, + ); + const rackSuggestionDismissed = rackSuggestion + ? dismissedRackSuggestions?.[rackSuggestion.dismissalKey] === true + : false; + const showRackSuggestion = rackSuggestion !== undefined && !rackSuggestionDismissed; + const handleDismissRackSuggestion = useCallback(() => { + if (!rackSuggestion) return; + setDismissedRackSuggestions((prev) => ({ ...(prev ?? {}), [rackSuggestion.dismissalKey]: true })); + }, [rackSuggestion, setDismissedRackSuggestions]); + const handleReviewSuggestedRack = useCallback(() => { + if (!rackSuggestion || !createFlow) return; + createFlow.launchCreateRack({ minerIds: rackSuggestion.minerIds }); + }, [createFlow, rackSuggestion]); + const rackSuggestionPanel = showRackSuggestion ? ( + } + title={`${rackSuggestion.count} unassigned miners from ${rackSuggestion.ipRangeLabel} look like a rack.`} + detail={ + rackSuggestion.modelSummary ? `Seen in nearby IPs. ${rackSuggestion.modelSummary}` : "Seen in nearby IPs." + } + action={{ + label: "Review", + onClick: handleReviewSuggestedRack, + testId: "rack-suggestion-review", + }} + onDismiss={handleDismissRackSuggestion} + testId="rack-creation-suggestion" + /> + ) : null; + + useEffect(() => { + if (!rackSuggestionEnabled || !unrackedMinersLoaded) return; + const intervalId = setInterval(() => { + refreshUnrackedMiners(); + }, POLL_INTERVAL_MS); + return () => clearInterval(intervalId); + }, [rackSuggestionEnabled, refreshUnrackedMiners, unrackedMinersLoaded]); + + useEffect(() => { + if (entitiesChangedAt > 0 && rackSuggestionEnabled) { + refreshUnrackedMiners(); + } + }, [entitiesChangedAt, rackSuggestionEnabled, refreshUnrackedMiners]); + // Unfiltered rack count for the "X of Y racks" line. `totalCount` from the // list hook is the filtered total; this fetches the path-scope total (no // zone/building/issue/`?site=`/telemetry filters) so the count line can show @@ -1117,6 +1206,11 @@ const RacksPage = () => { if (!hasRacks) { return ( <> + {rackSuggestionPanel ? ( +
+ {rackSuggestionPanel} +
+ ) : null} } @@ -1258,6 +1352,11 @@ const RacksPage = () => { {error ? ( } title={error} /> ) : null} + {rackSuggestionPanel ? ( +
+ {rackSuggestionPanel} +
+ ) : null} {racksViewMode === "list" ? ( // No horizontal padding or overflow wrapper here: that inset the table // (white gaps beside the row rules) and added a second scroll diff --git a/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.test.ts b/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.test.ts new file mode 100644 index 0000000000..a076037e4d --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; +import { create } from "@bufbuild/protobuf"; + +import { buildRackCreationSuggestion, RACK_SUGGESTION_MAX_MINERS, RACK_SUGGESTION_MIN_MINERS } from "./rackSuggestion"; +import { + type MinerStateSnapshot, + MinerStateSnapshotSchema, +} from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +const miner = (id: number, ipAddress: string, model = "Proto Rig"): MinerStateSnapshot => + create(MinerStateSnapshotSchema, { + deviceIdentifier: `miner-${id}`, + ipAddress, + model, + }); + +describe("buildRackCreationSuggestion", () => { + test("groups nearby unassigned miners by IPv4 range", () => { + const miners = Array.from({ length: RACK_SUGGESTION_MIN_MINERS }, (_, i) => miner(i + 1, `10.90.12.${40 + i}`)); + + expect(buildRackCreationSuggestion(miners)).toEqual({ + count: RACK_SUGGESTION_MIN_MINERS, + minerIds: miners.map((m) => m.deviceIdentifier), + ipRangeLabel: "10.90.12.40-10.90.12.47", + ipRangeFilter: "10.90.12.40-10.90.12.47", + modelSummary: "All Proto Rig.", + dismissalKey: `rack:10.90.12.40:10.90.12.47:${RACK_SUGGESTION_MIN_MINERS}`, + }); + }); + + test("chooses the largest plausible cohort", () => { + const small = Array.from({ length: RACK_SUGGESTION_MIN_MINERS }, (_, i) => miner(i + 1, `10.90.12.${10 + i}`)); + const large = Array.from({ length: RACK_SUGGESTION_MIN_MINERS + 2 }, (_, i) => + miner(100 + i, `10.90.13.${30 + i}`, i === 0 ? "Antminer S21" : "Proto Rig"), + ); + + const suggestion = buildRackCreationSuggestion([...small, ...large]); + + expect(suggestion?.count).toBe(RACK_SUGGESTION_MIN_MINERS + 2); + expect(suggestion?.ipRangeLabel).toBe("10.90.13.30-10.90.13.39"); + expect(suggestion?.modelSummary).toBe("Mostly Proto Rig."); + }); + + test("does not suggest tiny, oversized, or non-ip cohorts", () => { + const tiny = Array.from({ length: RACK_SUGGESTION_MIN_MINERS - 1 }, (_, i) => miner(i + 1, `10.90.12.${40 + i}`)); + const oversized = Array.from({ length: RACK_SUGGESTION_MAX_MINERS + 1 }, (_, i) => + miner(i + 1, `10.90.12.${i + 1}`), + ); + + expect(buildRackCreationSuggestion(tiny)).toBeUndefined(); + expect(buildRackCreationSuggestion(oversized)).toBeUndefined(); + expect(buildRackCreationSuggestion([miner(1, "miner.local")])).toBeUndefined(); + }); +}); diff --git a/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.ts b/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.ts new file mode 100644 index 0000000000..51d4afacb6 --- /dev/null +++ b/client/src/protoFleet/features/fleetManagement/utils/rackSuggestion.ts @@ -0,0 +1,118 @@ +import type { MinerStateSnapshot } from "@/protoFleet/api/generated/fleetmanagement/v1/fleetmanagement_pb"; + +export const RACK_SUGGESTION_MIN_MINERS = 8; +export const RACK_SUGGESTION_MAX_MINERS = 144; + +type IPv4Parts = [number, number, number, number]; + +type CandidateMiner = { + miner: MinerStateSnapshot; + ipNumber: number; + prefix: string; +}; + +export type RackCreationSuggestion = { + count: number; + minerIds: string[]; + ipRangeLabel: string; + ipRangeFilter: string; + modelSummary?: string; + dismissalKey: string; +}; + +const parseIPv4 = (value: string): IPv4Parts | undefined => { + const parts = value.split("."); + if (parts.length !== 4) return undefined; + const nums = parts.map((part) => { + if (!/^\d{1,3}$/.test(part)) return Number.NaN; + return Number(part); + }); + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return undefined; + return nums as IPv4Parts; +}; + +const ipv4ToNumber = ([a, b, c, d]: IPv4Parts): number => ((a << 24) >>> 0) + (b << 16) + (c << 8) + d; + +const numberToIPv4 = (value: number): string => + [value >>> 24, (value >>> 16) & 255, (value >>> 8) & 255, value & 255].join("."); + +const buildCandidate = (miner: MinerStateSnapshot): CandidateMiner | undefined => { + const parts = parseIPv4(miner.ipAddress.trim()); + if (!parts || !miner.deviceIdentifier) return undefined; + return { + miner, + ipNumber: ipv4ToNumber(parts), + prefix: `${parts[0]}.${parts[1]}.${parts[2]}`, + }; +}; + +const modelSummaryFor = (miners: MinerStateSnapshot[]): string | undefined => { + const counts = new Map(); + for (const miner of miners) { + const label = miner.model.trim() || miner.manufacturer.trim() || miner.driverName.trim(); + if (!label) continue; + counts.set(label, (counts.get(label) ?? 0) + 1); + } + if (counts.size === 0) return undefined; + + const [topLabel, topCount] = Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0]; + if (topCount === miners.length) return `All ${topLabel}.`; + if (topCount >= Math.ceil(miners.length * 0.6)) return `Mostly ${topLabel}.`; + return `${counts.size} models.`; +}; + +const toSuggestion = (group: CandidateMiner[]): RackCreationSuggestion | undefined => { + if (group.length < RACK_SUGGESTION_MIN_MINERS || group.length > RACK_SUGGESTION_MAX_MINERS) return undefined; + + const sorted = [...group].sort( + (a, b) => a.ipNumber - b.ipNumber || a.miner.deviceIdentifier.localeCompare(b.miner.deviceIdentifier), + ); + const first = sorted[0]; + const last = sorted[sorted.length - 1]; + const firstIP = numberToIPv4(first.ipNumber); + const lastIP = numberToIPv4(last.ipNumber); + const ipRangeFilter = firstIP === lastIP ? firstIP : `${firstIP}-${lastIP}`; + + return { + count: sorted.length, + minerIds: sorted.map((candidate) => candidate.miner.deviceIdentifier), + ipRangeLabel: ipRangeFilter, + ipRangeFilter, + modelSummary: modelSummaryFor(sorted.map((candidate) => candidate.miner)), + dismissalKey: `rack:${firstIP}:${lastIP}:${sorted.length}`, + }; +}; + +export const buildRackCreationSuggestion = ( + miners: MinerStateSnapshot[], + maxIPGap: number = 4, +): RackCreationSuggestion | undefined => { + const byPrefix = new Map(); + for (const miner of miners) { + const candidate = buildCandidate(miner); + if (!candidate) continue; + const bucket = byPrefix.get(candidate.prefix) ?? []; + bucket.push(candidate); + byPrefix.set(candidate.prefix, bucket); + } + + const groups: CandidateMiner[][] = []; + for (const bucket of byPrefix.values()) { + const sorted = [...bucket].sort((a, b) => a.ipNumber - b.ipNumber); + let current: CandidateMiner[] = []; + for (const candidate of sorted) { + const previous = current[current.length - 1]; + if (previous && candidate.ipNumber - previous.ipNumber > maxIPGap) { + groups.push(current); + current = []; + } + current.push(candidate); + } + if (current.length > 0) groups.push(current); + } + + return groups + .map(toSuggestion) + .filter((suggestion): suggestion is RackCreationSuggestion => suggestion !== undefined) + .sort((a, b) => b.count - a.count || a.ipRangeLabel.localeCompare(b.ipRangeLabel))[0]; +}; diff --git a/client/src/protoFleet/features/rollout/ActiveFirmwareRollout.stories.tsx b/client/src/protoFleet/features/rollout/ActiveFirmwareRollout.stories.tsx new file mode 100644 index 0000000000..37cc544d75 --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveFirmwareRollout.stories.tsx @@ -0,0 +1,407 @@ +import { type ReactElement, type ReactNode, useState } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import { FileDropZone, FileSelectedStatus } from "@/protoFleet/components/FirmwareUpload"; +import FullScreenTwoPaneModal, { + type FullScreenTwoPaneModalProps, +} from "@/protoFleet/components/FullScreenTwoPaneModal"; +import TargetSelectButton, { targetSelectPlaceholderLabel } from "@/protoFleet/components/TargetSelectButton"; +import { ActiveRolloutBanner } from "@/protoFleet/features/rollout/ActiveRolloutBanner"; +import ActiveRolloutStatus from "@/protoFleet/features/rollout/ActiveRolloutStatus"; +import { + AnimatedFirmwareInSitu, + FirmwareInSitu, + FirmwareReleaseChannelsTab, + FirmwareSettingsSurface, +} from "@/protoFleet/features/rollout/activeRolloutStoryHelpers"; +import { + completedFirmwareEvent, + completedWithFailuresFirmwareEvent, + inProgressFirmwareEvent, + pausedFirmwareEvent, + pilotGateFirmwareEvent, + scheduledFirmwareEvent, +} from "@/protoFleet/features/rollout/rollout.fixtures"; +import RolloutControls from "@/protoFleet/features/rollout/RolloutControls"; +import { rolloutPlanReadout } from "@/protoFleet/features/rollout/rolloutDisplayUtils"; +import type { RolloutPlanConfig } from "@/protoFleet/features/rollout/rolloutTypes"; +import { sizes, variants } from "@/shared/components/Button"; +import { DatePickerField } from "@/shared/components/DatePicker"; +import Input from "@/shared/components/Input"; +import SegmentedControl from "@/shared/components/SegmentedControl"; +import Select from "@/shared/components/Select"; + +/** + * Firmware rollout lifecycle states rendered on the Firmware settings page. + * These stories show the rollout card in its expected page context. + */ +const meta = { + title: "Proto Fleet/Rollout/In Situ/Firmware Lifecycle", + component: ActiveRolloutStatus, + parameters: { + layout: "fullscreen", + // The page shell provides its own MemoryRouter at /settings/firmware. + withRouter: false, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +const noop = (): void => undefined; + +function SectionTitle({ children }: { children: string }): ReactElement { + return
{children}
; +} + +function Section({ title, children }: { title: string; children: ReactNode }): ReactElement { + return ( +
+ {title} + {children} +
+ ); +} + +const scheduledFirmwareConfig: RolloutPlanConfig = { + processType: scheduledFirmwareEvent.processType, + strategy: scheduledFirmwareEvent.strategy, + order: scheduledFirmwareEvent.order, + maxConcurrentOffline: 50, + batchSize: scheduledFirmwareEvent.batchSize, + batchIntervalSec: scheduledFirmwareEvent.batchIntervalSec, + scheduleType: "scheduleForLater", + scheduledStartAt: scheduledFirmwareEvent.scheduledStartAt, +}; + +type PayloadMethod = "existing" | "upload"; + +const payloadMethodSegments = [ + { key: "existing", title: "Choose existing" }, + { key: "upload", title: "Upload new" }, +]; + +const scheduledTimeOptions = [ + { value: "14:00", label: "2:00 PM" }, + { value: "18:00", label: "6:00 PM" }, + { value: "22:00", label: "10:00 PM" }, +]; + +const firmwareFileOptions = [ + { + value: "f1", + label: "antminer-s21-5.1.0.tar.gz", + description: "Antminer S21 (5.1.0)", + }, + { + value: "f2", + label: "antminer-s21-5.0.2.tar.gz", + description: "Antminer S21 (5.0.2)", + }, + { + value: "f3", + label: "whatsminer-m60-3.4.1.tar.gz", + description: "Whatsminer M60 (3.4.1)", + }, +]; + +const scheduledFirmwareScopeTargets = [ + { label: "Sites", value: targetSelectPlaceholderLabel }, + { label: "Buildings", value: scheduledFirmwareEvent.scopeLabel }, + { label: "Racks", value: targetSelectPlaceholderLabel }, + { label: "Groups", value: targetSelectPlaceholderLabel }, + { label: "Miners", value: targetSelectPlaceholderLabel }, +]; + +function formatScheduledStart(config: RolloutPlanConfig, startDate: Date | undefined, startTime: string): string { + if (config.scheduleType === "startNow") { + return "Starts after save"; + } + + if (!startDate) { + return "Not scheduled"; + } + + const date = startDate.toLocaleDateString("en-US", { + weekday: "long", + month: "short", + day: "numeric", + }); + const time = scheduledTimeOptions.find((option) => option.value === startTime)?.label ?? startTime; + return `${date} at ${time}`; +} + +function PreviewRow({ label, value }: { label: string; value: string }): ReactElement { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function ScheduledFirmwarePreview({ + config, + inScopeCount, + payloadSummary, + startDate, + startTime, +}: { + config: RolloutPlanConfig; + inScopeCount: number; + payloadSummary: string; + startDate: Date | undefined; + startTime: string; +}): ReactElement { + const planReadout = rolloutPlanReadout({ inScopeCount, config }) ?? "Plan incomplete"; + + return ( +
+
+ {scheduledFirmwareEvent.title} is scheduled for {inScopeCount.toLocaleString()} miners in{" "} + {scheduledFirmwareEvent.scopeLabel}. +
+ +
+ + + + + +
+
+ ); +} + +function ManageScheduledFirmwareRolloutModal({ + onCancelScheduled, + onDismiss, + onSave, +}: { + onCancelScheduled: () => void; + onDismiss: () => void; + onSave: () => void; +}): ReactElement { + const [method, setMethod] = useState("existing"); + const [fileId, setFileId] = useState("f1"); + const [uploadedFile, setUploadedFile] = useState<{ name: string; size: number } | null>(null); + const [firmwareVersion, setFirmwareVersion] = useState("5.1.0"); + const [config, setConfig] = useState(scheduledFirmwareConfig); + const [startDate, setStartDate] = useState(new Date("2026-08-14T14:00:00")); + const [startTime, setStartTime] = useState("14:00"); + const inScopeCount = scheduledFirmwareEvent.totalTargets - scheduledFirmwareEvent.excludedTargets; + const isScheduled = config.scheduleType === "scheduleForLater"; + const selectedFile = firmwareFileOptions.find((option) => option.value === fileId); + const payloadSummary = + method === "existing" + ? selectedFile + ? `${selectedFile.label}, ${selectedFile.description}` + : "No firmware file selected" + : uploadedFile + ? `${uploadedFile.name}, ${firmwareVersion}` + : `New firmware ${firmwareVersion}`; + const previewPane = ( + + ); + const buttons: NonNullable = [ + { + text: "Cancel scheduled update", + variant: variants.secondaryDanger, + onClick: onCancelScheduled, + }, + { + text: "Save changes", + variant: variants.primary, + onClick: onSave, + }, + ]; + + const selectMethod = (next: PayloadMethod): void => { + setMethod(next); + if (next === "existing") { + setUploadedFile(null); + } else { + setFileId(""); + } + }; + + return ( + {previewPane}
} + primaryPane={ +
+
+ selectMethod(key as PayloadMethod)} + /> + {method === "existing" ? ( + + + + + {uploadedFile ? ( + setUploadedFile(null)} + /> + ) : ( + setUploadedFile({ name: file.name, size: file.size })} + /> + )} + + )} +
+ +
+
+ {scheduledFirmwareScopeTargets.map((target) => ( + + ))} +
+
+ + + +
+ + + ) : null} +
Times shown in America/Denver (MDT)
+
+
+ } + secondaryPane={previewPane} + secondaryPaneClassName="!hidden !bg-transparent laptop:!flex laptop:!pl-0 laptop:!rounded-[24px]" + /> + ); +} + +function ScheduledFirmwareStory(): ReactElement { + const [configOpen, setConfigOpen] = useState(false); + const [showScheduledBanner, setShowScheduledBanner] = useState(true); + + return ( + <> + } + rolloutBanner={ + showScheduledBanner ? ( + setConfigOpen(true)} /> + ) : null + } + /> + {configOpen ? ( + setConfigOpen(false)} + onSave={() => setConfigOpen(false)} + onCancelScheduled={() => { + setConfigOpen(false); + setShowScheduledBanner(false); + }} + /> + ) : null} + + ); +} + +export const Scheduled: Story = { + render: () => , +}; + +export const InProgress: Story = { + name: "In progress", + render: () => , +}; + +export const Paused: Story = { + render: () => , +}; + +export const PilotReview: Story = { + name: "Pilot review", + render: () => , +}; + +export const Completed: Story = { + render: () => , +}; + +export const CompletedWithFailures: Story = { + name: "Completed with failures", + render: () => , +}; + +export const AnimatedFirmwareLifecycle: Story = { + name: "Animated firmware lifecycle", + render: function renderAnimatedFirmwareLifecycle(): ReactElement { + return ; + }, +}; diff --git a/client/src/protoFleet/features/rollout/ActiveRebootRollout.stories.tsx b/client/src/protoFleet/features/rollout/ActiveRebootRollout.stories.tsx new file mode 100644 index 0000000000..f22e3e304d --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveRebootRollout.stories.tsx @@ -0,0 +1,54 @@ +import type { ReactElement } from "react"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +import ActiveRolloutStatus from "@/protoFleet/features/rollout/ActiveRolloutStatus"; +import { AnimatedRebootInSitu, RebootInSitu } from "@/protoFleet/features/rollout/activeRolloutStoryHelpers"; +import { + completedRebootEvent, + completedWithFailuresRebootEvent, + inProgressRebootEvent, + pausedRebootEvent, +} from "@/protoFleet/features/rollout/rollout.fixtures"; + +/** + * Reboot rollout lifecycle states rendered on the Fleet page. Reboot is a bulk + * action, so these stories use the Fleet page as the in-product home. + */ +const meta = { + title: "Proto Fleet/Rollout/In Situ/Reboot Lifecycle", + component: ActiveRolloutStatus, + parameters: { + layout: "fullscreen", + // The page shell provides its own MemoryRouter at /fleet. + withRouter: false, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const InProgress: Story = { + name: "In progress", + render: () => , +}; + +export const Paused: Story = { + render: () => , +}; + +export const Completed: Story = { + render: () => , +}; + +export const CompletedWithFailures: Story = { + name: "Completed with failures", + render: () => , +}; + +export const AnimatedRebootLifecycle: Story = { + name: "Animated reboot lifecycle", + render: function renderAnimatedRebootLifecycle(): ReactElement { + return ; + }, +}; diff --git a/client/src/protoFleet/features/rollout/ActiveRolloutBanner.tsx b/client/src/protoFleet/features/rollout/ActiveRolloutBanner.tsx new file mode 100644 index 0000000000..3dfb05c203 --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveRolloutBanner.tsx @@ -0,0 +1,120 @@ +import type { ReactElement } from "react"; + +import { phaseLabel, rolloutActionNoun, rolloutPhaseCount } from "./rolloutDisplayUtils"; +import type { RolloutEvent, RolloutProcessType } from "./rolloutTypes"; +import { Download, LightningAlt, Reboot } from "@/shared/assets/icons"; +import Callout, { intents } from "@/shared/components/Callout"; +import { formatTimestamp, isoToEpochSeconds } from "@/shared/utils/formatTimestamp"; + +interface ActiveRolloutBannerProps { + event: RolloutEvent; + onView?: () => void; + onManage?: () => void; +} + +interface ActiveRolloutBannerStackProps { + events: RolloutEvent[]; + onView?: (event: RolloutEvent, index: number) => void; + onManage?: (event: RolloutEvent, index: number) => void; +} + +/** Intent per process, firmware/curtailment carry uptime impact (warning), + * reboot is informational. Drives the shared Callout's color + icon tint. */ +const processIntent: Record = { + firmware: intents.warning, + curtailment: intents.warning, + reboot: intents.information, +}; + +function bannerIntent(event: RolloutEvent): keyof typeof intents { + return event.state === "scheduled" ? intents.information : processIntent[event.processType]; +} + +function ProcessIcon({ processType }: { processType: RolloutProcessType }): ReactElement { + // Force neutral/black icons regardless of the Callout's intent tint, the + // intent color still drives the header/accent, but the process glyph stays + // black for a calmer, more legible banner. + const className = "text-text-primary"; + switch (processType) { + case "firmware": + return ; + case "reboot": + return ; + case "curtailment": + return ; + } +} + +function bannerTitle(event: RolloutEvent): string { + return event.scopeLabel ? `${event.title}, ${event.scopeLabel}` : event.title; +} + +function bannerSubtitle(event: RolloutEvent): string { + const inScope = Math.max(event.totalTargets - event.excludedTargets, 0); + + if (event.state === "scheduled") { + const scheduledAt = event.scheduledStartAt ? formatTimestamp(isoToEpochSeconds(event.scheduledStartAt)) : undefined; + const parts = [ + scheduledAt ? `Scheduled for ${scheduledAt}` : "Scheduled", + `${inScope.toLocaleString()} miners queued`, + event.excludedTargets > 0 ? `${event.excludedTargets.toLocaleString()} excluded` : null, + ]; + return parts.filter(Boolean).join(", "); + } + + const done = rolloutPhaseCount(event.rollups, "done"); + const failed = rolloutPhaseCount(event.rollups, "failed"); + const doneVerb = phaseLabel(event.processType, "done").toLowerCase(); + + const parts = [`${done.toLocaleString()} of ${inScope.toLocaleString()} miners ${doneVerb}`]; + if (failed > 0) { + parts.push(`${failed.toLocaleString()} failed`); + } + if (event.currentBatch && event.totalBatches) { + parts.push(`Batch ${event.currentBatch} of ${event.totalBatches}`); + } + return parts.join(", "); +} + +/** + * Inline progress banner for active and scheduled rollouts. + */ +export function ActiveRolloutBanner({ event, onView, onManage }: ActiveRolloutBannerProps): ReactElement { + const showManageAction = event.state === "scheduled" && onManage !== undefined; + const showViewAction = event.state !== "scheduled" && onView !== undefined; + const buttonText = showManageAction + ? `Manage scheduled ${rolloutActionNoun(event.processType)}` + : showViewAction + ? `View ${rolloutActionNoun(event.processType)}` + : undefined; + const buttonOnClick = showManageAction ? onManage : showViewAction ? onView : undefined; + + return ( + } + title={bannerTitle(event)} + subtitle={bannerSubtitle(event)} + buttonText={buttonText} + buttonOnClick={buttonOnClick} + testId="active-rollout-banner" + /> + ); +} + +export function ActiveRolloutBannerStack({ events, onView, onManage }: ActiveRolloutBannerStackProps): ReactElement { + return ( +
+ {events.map((event, index) => ( + onView(event, index) : undefined} + onManage={onManage ? () => onManage(event, index) : undefined} + /> + ))} +
+ ); +} + +export default ActiveRolloutBanner; diff --git a/client/src/protoFleet/features/rollout/ActiveRolloutStatus.tsx b/client/src/protoFleet/features/rollout/ActiveRolloutStatus.tsx new file mode 100644 index 0000000000..9963dd3458 --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveRolloutStatus.tsx @@ -0,0 +1,388 @@ +import { type ReactElement, type ReactNode, useEffect, useState } from "react"; +import clsx from "clsx"; + +import { + formatRolloutMetric, + orderLabels, + pacingSummary, + phaseLabel, + rolloutCompletionPercent, + rolloutLifecycleActions, + rolloutMetricDelta, + type RolloutMetricDelta, + type RolloutMetricDeltaIntent, + rolloutPhaseCount, + rolloutProgressSegments, + rolloutStageLabel, +} from "./rolloutDisplayUtils"; +import type { RolloutEvent } from "./rolloutTypes"; +import { formatCurtailmentElapsedDuration as formatElapsed } from "@/protoFleet/features/energy/curtailmentDisplayUtils"; +import RowActionsMenu, { type RowAction } from "@/protoFleet/features/fleetManagement/components/RowActionsMenu"; +import { useTemperatureUnit } from "@/protoFleet/store"; +import { Alert, Success } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import CompositionBar, { type Segment } from "@/shared/components/CompositionBar"; +import Header from "@/shared/components/Header"; +import ProgressCircular from "@/shared/components/ProgressCircular"; +import Row from "@/shared/components/Row"; + +/** + * Rollout progress colors follow the active curtailment card: done is primary, + * remaining is accent, and failures are critical. + */ +const rolloutProgressColorMap: Record = { + OK: "bg-core-primary-fill", + WARNING: "bg-core-accent-fill", + CRITICAL: "bg-intent-critical-fill", + NA: "bg-core-primary-10", +}; + +interface ActiveRolloutStatusProps { + event: RolloutEvent; + className?: string; + /** Drop card chrome when the host already provides an elevated surface. */ + embedded?: boolean; + /** Suppress lifecycle actions when the host renders them elsewhere. */ + hideActions?: boolean; + /** Lifecycle actions. Missing handlers hide their controls. */ + onManage?: () => void; + onPause?: () => void; + onResume?: () => void; + onCancelRemaining?: () => void; + onContinueFromPilot?: () => void; + onRetryFailed?: () => void; + onViewMiners?: () => void; +} + +interface StatBlockProps { + label: string; + value: string; + detail?: string; +} + +// Same lockup as ActiveCurtailmentStatus' StatBlock, so rollout detail reads +// consistently with curtailment detail. +function StatBlock({ label, value, detail }: StatBlockProps): ReactElement { + return ( +
+
{label}
+
+ {value} +
+ {detail ? ( +
+ {detail} +
+ ) : null} +
+ ); +} + +/** + * A single stat as a standard label/value table row. This follows the `SummaryRow` pattern + * shared with `ActivityDetailModal`: label pinned left, value right-aligned, a + * hairline divider between rows. Used in the modal (`embedded`) presentation, + * where the four stats read better stacked as detail rows than as a stat grid. + * `detail` (percent / elapsed) sits under the value, still right-aligned. + */ +function StatRow({ label, value, detail, divider }: StatBlockProps & { divider: boolean }): ReactElement { + return ( + +
+ {label} + + + {value} + + {detail ? ( + + {detail} + + ) : null} + +
+
+ ); +} + +// Deltas show movement only. The UI does not judge whether the change is good +// or bad for the operator. +const deltaTextColor: Record = { + positive: "text-intent-success-fill", + negative: "text-intent-critical-fill", +}; + +/** + * Signed metric delta rendered beside the current value. + */ +function DeltaChip({ delta }: { delta: RolloutMetricDelta }): ReactElement { + return {delta.deltaText}; +} + +/** + * Baseline-vs-current telemetry for pilot review. + */ +function PerformanceStrip({ event }: { event: RolloutEvent }): ReactElement | null { + const temperatureUnit = useTemperatureUnit(); + if (!event.performance || event.performance.metrics.length === 0) { + return null; + } + return ( +
+ {event.performance.metrics.map((metric) => { + const value = formatRolloutMetric(metric, temperatureUnit); + return ( +
+
{metric.label}
+
+ + {value} + + +
+
+ ); + })} +
+ ); +} + +function statusHeadline(event: RolloutEvent): string { + switch (event.state) { + case "scheduled": + return "Scheduled"; + case "inProgress": + return "In progress"; + case "pausedAtPilotGate": + return "Paused for pilot review"; + case "paused": + return "Paused"; + case "completed": + return "Completed"; + case "completedWithFailures": + return "Completed with failures"; + } +} + +function statusIcon(event: RolloutEvent): ReactNode { + if (event.state === "completedWithFailures") { + return ; + } + if (event.state === "completed") { + return ; + } + if (event.state === "paused" || event.state === "pausedAtPilotGate") { + return ; + } + return ; +} + +/** + * Progress-against-plan detail card for active rollout work. + */ +function ActiveRolloutStatus({ + event, + className, + embedded = false, + hideActions = false, + onManage, + onPause, + onResume, + onCancelRemaining, + onContinueFromPilot, + onRetryFailed, + onViewMiners, +}: ActiveRolloutStatusProps): ReactElement { + const isRunning = event.state === "inProgress"; + const isTerminal = event.state === "completed" || event.state === "completedWithFailures"; + const inScope = Math.max(event.totalTargets - event.excludedTargets, 0); + const done = rolloutPhaseCount(event.rollups, "done"); + const percent = rolloutCompletionPercent(event); + const segments = rolloutProgressSegments(event); + const doneVerb = phaseLabel(event.processType, "done").toLowerCase(); + + // Live-ticking elapsed timer while running, matching the curtailment card. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!isRunning || !event.startedAt) { + return; + } + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [isRunning, event.startedAt]); + const elapsedSeconds = event.startedAt + ? Math.max(Math.floor((now - new Date(event.startedAt).getTime()) / 1000), 0) + : 0; + + const etaValue = + event.estimatedSecondsRemaining && event.estimatedSecondsRemaining > 0 + ? `~${formatElapsed(event.estimatedSecondsRemaining)}` + : isTerminal + ? "—" + : "Calculating…"; + + const statItems: StatBlockProps[] = [ + { label: "Scope", value: event.scopeLabel || "—" }, + { label: "Strategy", value: pacingSummary(event) }, + // Order only applies to a paced run. Under "all at once" there's no first/last. + ...(event.strategy === "allAtOnce" ? [] : [{ label: "Order", value: orderLabels[event.order] }]), + { label: "Est. time remaining", value: etaValue }, + ]; + + // Progress summary + elapsed live in the progress section, rather than the stat grid. + const progressSummary = `${done.toLocaleString()} of ${inScope.toLocaleString()} miners ${doneVerb} (${percent}%)`; + + const actions = hideActions + ? [] + : rolloutLifecycleActions(event, { + onManage, + onPause, + onResume, + onCancelRemaining, + onContinueFromPilot, + onRetryFailed, + }); + const visibleActions = actions.filter((action) => action.key !== "cancel"); + const overflowLifecycleActions = actions.filter((action) => action.key === "cancel"); + const overflowMenuActions: RowAction[] = []; + if (!hideActions && onViewMiners) { + overflowMenuActions.push({ + label: "View miners", + onClick: onViewMiners, + showGroupDivider: overflowLifecycleActions.length > 0, + testId: "active-rollout-view-miners-action", + }); + } + overflowLifecycleActions.forEach((action) => { + if (!action.onClick) { + return; + } + overflowMenuActions.push({ + label: action.text, + onClick: action.onClick, + danger: action.variant === "danger", + testId: `active-rollout-${action.key}-action`, + }); + }); + const hasTopActions = visibleActions.length > 0 || overflowMenuActions.length > 0; + const buttonVariant = { + primary: variants.primary, + secondary: variants.secondary, + danger: variants.danger, + } as const; + + return ( +
+ {embedded ? null : ( +
+
+
+ )} +
+ {hasTopActions ? ( +
+ {overflowMenuActions.length > 0 ? ( + + ) : null} + {visibleActions.map((action) => ( +
+ ) : null} + +
+
+ {statusIcon(event)} +
+
+
{statusHeadline(event)}
+
{rolloutStageLabel(event)}
+
+
+ + {/* Stat lockups: in the modal (embedded) they read as standard + label/value table rows; in the standalone card they use the same + multi-column stat grid as ActiveCurtailmentStatus (grid-cols-5, + gap-x-12). */} + {embedded ? ( +
+ {statItems.map((item, index) => ( + + ))} +
+ ) : ( +
+ {statItems.map((item) => ( + + ))} +
+ )} + + {/* Baseline telemetry for pilot review. */} + + + {/* Progress section: summary, elapsed time, bar, then legend. */} +
+
+
{progressSummary}
+ {event.startedAt ? ( +
{`${formatElapsed(elapsedSeconds)} elapsed`}
+ ) : null} +
+ +
+ {segments.map((segment) => ( + + + {`${segment.name} (${(segment.count ?? 0).toLocaleString()})`} + + ))} + {/* Excluded targets sit outside the bar and appear as a separate legend item. */} + {event.excludedTargets > 0 ? ( + + {`${event.excludedTargets.toLocaleString()} excluded`} + + ) : null} +
+
+
+
+ ); +} + +export default ActiveRolloutStatus; diff --git a/client/src/protoFleet/features/rollout/ReleaseChannelModal.tsx b/client/src/protoFleet/features/rollout/ReleaseChannelModal.tsx new file mode 100644 index 0000000000..943f50dd50 --- /dev/null +++ b/client/src/protoFleet/features/rollout/ReleaseChannelModal.tsx @@ -0,0 +1,239 @@ +import type { ReactElement, ReactNode } from "react"; + +import type { + ReleaseChannelDraft, + ReleaseChannelFile, + ReleaseChannelPreview, + ReleaseChannelScope, +} from "./releaseChannelTypes"; +import RolloutControls from "./RolloutControls"; +import FullScreenTwoPaneModal, { + type FullScreenTwoPaneModalProps, +} from "@/protoFleet/components/FullScreenTwoPaneModal"; +import TargetSelectButton, { getTargetButtonLabel } from "@/protoFleet/components/TargetSelectButton"; +import { Ellipsis } from "@/shared/assets/icons"; +import Button, { sizes, variants } from "@/shared/components/Button"; +import Input from "@/shared/components/Input"; +import List from "@/shared/components/List"; +import type { ColConfig, ColTitles } from "@/shared/components/List/types"; +import Textarea from "@/shared/components/Textarea"; + +interface ReleaseChannelModalProps { + open: boolean; + /** Create shows "Create release channel"; manage shows "Manage release channel". */ + mode: "create" | "manage"; + draft: ReleaseChannelDraft; + onDraftChange: (next: ReleaseChannelDraft) => void; + preview: ReleaseChannelPreview; + onAddFile: () => void; + onFileActions: (file: ReleaseChannelFile) => void; + /** Per-scope-level selection entry points (Sites / Buildings / …). */ + onSelectScope: (level: keyof ReleaseChannelScope) => void; + onDismiss: () => void; + onSave: () => void; +} + +function SectionTitle({ children }: { children: string }): ReactElement { + return
{children}
; +} + +function Section({ + title, + action, + children, +}: { + title: string; + action?: ReactNode; + children: ReactNode; +}): ReactElement { + return ( +
+
+ {title} + {action} +
+ {children} +
+ ); +} + +// ---- Firmware file table (inside the modal) -------------------------------- + +type FirmwareFileColumn = "model" | "file" | "uploaded" | "actions"; + +const firmwareFileColumns: FirmwareFileColumn[] = ["model", "file", "uploaded", "actions"]; + +const firmwareFileColTitles: ColTitles = { + model: "Model", + file: "File", + uploaded: "Uploaded", + actions: "", +}; + +// ---- Scope rows (Apply to) ------------------------------------------------- + +const scopeLevels: Array<{ level: keyof ReleaseChannelScope; label: string; singular: string }> = [ + { level: "sites", label: "Sites", singular: "site" }, + { level: "buildings", label: "Buildings", singular: "building" }, + { level: "racks", label: "Racks", singular: "rack" }, + { level: "groups", label: "Groups", singular: "group" }, + { level: "miners", label: "Miners", singular: "miner" }, +]; + +// ---- Coverage preview pane ------------------------------------------------- + +function CoveragePreview({ preview }: { preview: ReleaseChannelPreview }): ReactElement { + const scopeSummary = [ + `${preview.siteCount} ${preview.siteCount === 1 ? "site" : "sites"}`, + `${preview.buildingCount} ${preview.buildingCount === 1 ? "building" : "buildings"}`, + `${preview.rackCount} ${preview.rackCount === 1 ? "rack" : "racks"}`, + ]; + + return ( +
+
+
+ Deploys firmware to {preview.minerCount.toLocaleString()} miners ({preview.modelCount}{" "} + {preview.modelCount === 1 ? "model" : "models"}) across {scopeSummary.join(", ")}. +
+ +
+
Previous updates
+
+ {preview.previousRollouts.map((rollout) => ( +
+ {rollout} +
+ ))} +
+
+
+
+ ); +} + +/** Create/manage surface for a firmware release channel. */ +function ReleaseChannelModal({ + open, + mode, + draft, + onDraftChange, + preview, + onAddFile, + onFileActions, + onSelectScope, + onDismiss, + onSave, +}: ReleaseChannelModalProps): ReactElement { + const title = mode === "create" ? "Create release channel" : "Manage release channel"; + const closeAriaLabel = mode === "create" ? "Close release channel creator" : "Close release channel editor"; + + const firmwareFileColConfig: ColConfig = { + model: { + component: (file) => {file.model}, + width: "w-40", + }, + file: { component: (file) => file.file, width: "w-64" }, + uploaded: { component: (file) => file.uploaded, width: "w-48" }, + actions: { + component: (file) => ( +
+ +
+ ), + width: "w-16", + }, + }; + + const previewPane = ; + + const buttons: NonNullable = [ + { + text: "Save", + variant: variants.primary, + onClick: onSave, + }, + ]; + + return ( + {previewPane}} + primaryPane={ +
+
+
+ onDraftChange({ ...draft, name: value })} + /> +