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/BulkActions/BulkActionsPopover.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx index 5aecdf329f..c3588ccbff 100644 --- a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsPopover.tsx @@ -46,7 +46,7 @@ const ActionItem = ({ action, onAction }: ActionItemProps
onAction(action)} @@ -54,7 +54,10 @@ const ActionItem = ({ action, onAction }: ActionItemProps - {action.title} + {action.title} + {isDisabled && action.disabledReason ? ( + {action.disabledReason} + ) : null}
{action.showGroupDivider ? : null} diff --git a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx index 1bccefba63..3d23b99896 100644 --- a/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/BulkActions/BulkActionsWidget.test.tsx @@ -3,6 +3,7 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, test, vi } from "vitest"; import BulkActionsWidget from "./BulkActionsWidget"; import { type BulkAction } from "./types"; +import { BulkActionsPopover } from "."; import { deviceActions } from "@/protoFleet/features/fleetManagement/components/MinerActionsMenu/constants"; import Button, { variants } from "@/shared/components/Button"; import { PopoverProvider } from "@/shared/components/Popover"; @@ -57,4 +58,31 @@ describe("BulkActionsWidget", () => { expect(screen.getByText("Reboot miners?")).toBeInTheDocument(); expect(screen.getByTestId("reboot-confirm-button")).toBeInTheDocument(); }); + + test("shows disabled action reasons in the popover", () => { + const actions: BulkAction[] = [ + { + action: deviceActions.firmwareUpdate, + title: "Firmware update", + icon: null, + actionHandler: vi.fn(), + requiresConfirmation: false, + disabled: true, + disabledReason: "Firmware is already updating on selected miners.", + }, + ]; + + render( + + + actions={actions} + beforeEach={vi.fn()} + testId="actions-popover" + /> + , + ); + + expect(screen.getByTestId(`${deviceActions.firmwareUpdate}-popover-button`)).toBeDisabled(); + expect(screen.getByText("Firmware is already updating on selected miners.")).toBeInTheDocument(); + }); }); diff --git a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx index 5767752893..72305b7807 100644 --- a/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx +++ b/client/src/protoFleet/features/fleetManagement/components/MinerActionsMenu/FirmwareUpdateModal/FirmwareUpdateModal.tsx @@ -134,7 +134,7 @@ const FirmwareUpdateModal = ({ open, target, onConfirm, onDismiss }: FirmwareUpd const showUploadFields = !missingTarget && serverConfig && (!hasExistingFiles || showUploadZone); const uploadMetadataLocked = uploadState !== "idle"; - const buttons = isReady ? [{ text: "Continue", variant: variants.primary, onClick: handleConfirm }] : undefined; + const buttons = [{ text: "Continue", variant: variants.primary, onClick: handleConfirm, disabled: !isReady }]; return ( 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/rollout/ActiveFirmwareRollout.stories.tsx b/client/src/protoFleet/features/rollout/ActiveFirmwareRollout.stories.tsx new file mode 100644 index 0000000000..8d391b288e --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveFirmwareRollout.stories.tsx @@ -0,0 +1,467 @@ +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 { RolloutEvent, 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 }, +]; + +const animatedAllAtOnceFirmwareEvent: RolloutEvent = { + processType: "firmware", + state: "inProgress", + title: "Firmware update to 5.1.0", + scopeLabel: "Building B", + strategy: "allAtOnce", + order: "leastEfficientFirst", + totalTargets: 240, + excludedTargets: 18, + startedAt: new Date(Date.now() - 60_000).toISOString(), + estimatedSecondsRemaining: 90, + performance: inProgressFirmwareEvent.performance, + rollups: [ + { phase: "inProgress", count: 222 }, + { phase: "excluded", count: 18 }, + ], +}; + +const animatedBatchesReviewFirmwareEvent: RolloutEvent = { + ...inProgressFirmwareEvent, + currentBatch: 1, + reviewAfterEachBatch: true, + rollups: [ + { phase: "inProgress", count: 20 }, + { phase: "queued", count: 202 }, + { phase: "excluded", count: 18 }, + ], +}; + +const animatedPilotReviewFirmwareEvent: RolloutEvent = { + ...inProgressFirmwareEvent, + strategy: "pilotThenContinue", + pilotSize: 10, + batchSize: 25, + batchIntervalSec: 90, + currentBatch: 1, + totalBatches: 10, + reviewAfterEachBatch: true, + estimatedSecondsRemaining: 810, + rollups: [ + { phase: "inProgress", count: 10 }, + { phase: "queued", count: 212 }, + { phase: "excluded", count: 18 }, + ], +}; + +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 AnimatedAllAtOnce: Story = { + name: "Animated all at once", + render: function renderAnimatedAllAtOnce(): ReactElement { + return ; + }, +}; + +export const AnimatedBatchesWithReview: Story = { + name: "Animated batches with review", + render: function renderAnimatedBatchesWithReview(): ReactElement { + return ; + }, +}; + +export const AnimatedPilotWithReview: Story = { + name: "Animated pilot with review", + render: function renderAnimatedPilotWithReview(): 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..6c58f44cc3 --- /dev/null +++ b/client/src/protoFleet/features/rollout/ActiveRolloutStatus.tsx @@ -0,0 +1,470 @@ +import { type ReactElement, type ReactNode, useEffect, useId, useState } from "react"; +import clsx from "clsx"; + +import { + formatRolloutMetric, + orderLabels, + pacingSummary, + phaseLabel, + rolloutCompletionPercent, + rolloutErrorImpactCount, + rolloutLifecycleActions, + type RolloutMetricDelta, + 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; + /** Start with the lower detail section expanded. */ + defaultDetailsOpen?: boolean; + /** Lifecycle actions. Missing handlers hide their controls. */ + onManage?: () => void; + onPause?: () => void; + onResume?: () => void; + onCancelRemaining?: () => void; + onContinueFromPilot?: () => void; + onRetryFailed?: () => void; + onViewMiners?: () => void; + onViewErrors?: () => 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 keep the signed value, but color by outcome for the metric. A +// temperature increase is bad even though the sign is positive. +const deltaTextColor: Record = { + positive: "text-intent-success-fill", + negative: "text-intent-critical-fill", + neutral: "text-text-primary-50", +}; + +/** + * Signed metric delta rendered beside the current value. + */ +function DeltaChip({ delta }: { delta: RolloutMetricDelta }): ReactElement { + return {delta.deltaText}; +} + +function errorCountLabel(count: number): string { + return `${count.toLocaleString()} ${count === 1 ? "error" : "errors"}`; +} + +/** + * Baseline-vs-current telemetry for pilot review. + */ +function PerformanceStrip({ + event, + embedded = false, + onViewErrors, +}: { + event: RolloutEvent; + embedded?: boolean; + onViewErrors?: () => void; +}): ReactElement | null { + const temperatureUnit = useTemperatureUnit(); + const hasErrorSummary = event.performance?.errors !== undefined; + const errorCount = rolloutErrorImpactCount(event.performance?.errors); + if (!event.performance || (event.performance.metrics.length === 0 && !hasErrorSummary)) { + return null; + } + + return ( +
+
+ {event.performance.metrics.map((metric) => { + const value = formatRolloutMetric(metric, temperatureUnit); + return ( +
+
{metric.label}
+
+ + {value} + + +
+
+ ); + })} + {hasErrorSummary ? ( +
+
Errors
+ {errorCount > 0 && onViewErrors ? ( + + ) : ( +
{errorCount.toLocaleString()}
+ )} +
+ ) : null} +
+
+ Compares the 30-minute pre-update baseline with post-update telemetry after miners stabilize. +
+
+ ); +} + +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 ; +} + +function ProgressLegend({ event, segments }: { event: RolloutEvent; segments: Segment[] }): ReactElement { + return ( +
+ {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} +
+ ); +} + +/** + * Progress-against-plan detail card for active rollout work. + */ +function ActiveRolloutStatus({ + event, + className, + embedded = false, + hideActions = false, + defaultDetailsOpen = false, + onManage, + onPause, + onResume, + onCancelRemaining, + onContinueFromPilot, + onRetryFailed, + onViewMiners, + onViewErrors, +}: ActiveRolloutStatusProps): ReactElement { + const detailsId = useId(); + const [detailsOpen, setDetailsOpen] = useState(defaultDetailsOpen); + 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 + ? "N/A" + : "Calculating"; + + const statItems: StatBlockProps[] = [ + { label: "Scope", value: event.scopeLabel || "N/A" }, + { label: "Method", 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 errorCount = rolloutErrorImpactCount(event.performance?.errors); + const hasCollapsedErrorLink = !detailsOpen && errorCount > 0; + + 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)}
+
+
+ + {/* Progress stays visible in the collapsed card; rollout setup and telemetry + sit behind the disclosure below. */} +
+
+
{progressSummary}
+ {event.startedAt ? ( +
{`${formatElapsed(elapsedSeconds)} elapsed`}
+ ) : null} +
+ + +
+ +
+ + {hasCollapsedErrorLink && onViewErrors ? ( + + ) : hasCollapsedErrorLink ? ( + {errorCountLabel(errorCount)} + ) : null} +
+ + {detailsOpen ? ( +
+ {/* 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. */} + +
+ ) : 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..ab8df62e94 --- /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 / etc.). */ + 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 })} + /> +