Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
1652496
feat(rollout): scaffold rollout framework presentational layer + Stor…
Jul 31, 2026
a86382c
refactor(rollout): align components with existing proto-fleet patterns
Jul 31, 2026
a13c33c
feat(rollout): auto-retry model, step-led status, view-rollout modal,…
Jul 31, 2026
e49c7f7
fix(rollout): ViewRolloutModal frames as a real modal (title bar + cl…
Jul 31, 2026
243f737
feat(rollout): real config modal + top-bar CTAs + scroll for both modals
Jul 31, 2026
ffb6b33
refactor(rollout): use standard-width modals
Jul 31, 2026
3f549f9
feat(rollout): pin modal header in top bar; pill + banners open the m…
Aug 3, 2026
fc686e2
refactor(rollout): simplify active-card bar/key, reposition scope, ma…
Aug 3, 2026
9d13b22
fix(rollout): pad the status icon below the modal's sticky top bar
Aug 3, 2026
481ddaa
fix(rollout): match embedded card top padding to the modal side margins
Aug 3, 2026
8c74f47
fix(rollout): make active-card stat grid responsive to container width
Aug 3, 2026
de34ea4
feat(rollout): add page-context in-situ stories (firmware settings, e…
Aug 3, 2026
88d7d0c
feat(rollout): wrap in-situ page stories in the real navigation shell
Aug 3, 2026
f1c2593
refactor(rollout): align column state with the shipped DeviceStatus/M…
Aug 3, 2026
daf6d0e
feat(rollout): seed nav permissions + dummy page content in in-situ s…
Aug 3, 2026
3f37bec
feat(rollout): story for FirmwareUpdateModal with rollout controls (o…
Aug 3, 2026
53d70fb
fix(rollout): use shipped table components + shared List in in-situ s…
Aug 3, 2026
da5e13d
chore(rollout): remove redundant standalone stories covered by In Situ
Aug 3, 2026
f826e6b
chore(rollout): drop incomplete fleet-view In Situ stories
Aug 3, 2026
6209661
refactor(rollout): consolidate stories into In Situ + Components buckets
Aug 3, 2026
0ddc172
refactor(rollout): split In Situ into Config + In Progress buckets
Aug 3, 2026
6bf7c0d
refactor(rollout): 2-up controls; align active-rollout card with curt…
Aug 4, 2026
f3da486
feat(rollout): add Manage CTA and excluded-count legend annotation to…
Aug 4, 2026
4672aff
chore(rollout): drop parenthetical qualifiers from story names
Aug 4, 2026
4be746d
feat(rollout): add per-state + animated lifecycle stories for firmwar…
Aug 4, 2026
66a7922
refactor(rollout): render firmware and reboot lifecycle stories in situ
Aug 4, 2026
a0f5aa6
refactor(rollout): render firmware + reboot lifecycle stories on the …
Aug 4, 2026
2e712f8
feat(rollout): firmware release channels tab + manage modal
Aug 4, 2026
585ebed
refactor(rollout): consolidate redundant labels, tighten button sizin…
Aug 4, 2026
c192222
feat(rollout): use compact button size in Apply-to tables
Aug 4, 2026
e476385
feat(rollout): rework firmware payload input for scale
Aug 5, 2026
b91849d
feat(rollout): apply design-review updates and contextual terminology
Aug 7, 2026
fce2b8c
refactor(rollout): scope Order to the efficiency axis, paced runs only
Aug 7, 2026
a460fd2
feat(rollout): adopt curtailment 'behavior' vocab for pacing section …
Aug 7, 2026
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
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
import Button, { variants } from "@/shared/components/Button";
import Button, { sizes, variants } from "@/shared/components/Button";
import Row from "@/shared/components/Row";

interface TargetSelectButtonProps {
label: string;
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 (
<Row compact className="flex items-center justify-between gap-4">
<span className="min-w-0 truncate text-emphasis-300 text-text-primary">{label}</span>
<Button
ariaLabel={`${label} ${value}`}
text={value}
variant={variants.secondary}
size={size}
disabled={disabled}
onClick={onClick}
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { ReactElement } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";

import ActiveRolloutStatus from "@/protoFleet/features/rollout/ActiveRolloutStatus";
import { AnimatedFirmwareInSitu, FirmwareInSitu } from "@/protoFleet/features/rollout/activeRolloutStoryHelpers";
import {
completedFirmwareEvent,
completedWithFailuresFirmwareEvent,
inProgressFirmwareEvent,
pausedFirmwareEvent,
pilotGateFirmwareEvent,
scheduledFirmwareEvent,
} from "@/protoFleet/features/rollout/rollout.fixtures";

/**
* The active **firmware update** across its lifecycle states, plus a
* live-animated lifecycle — all rendered **in situ**, on the established
* firmware in-situ surface: the Firmware settings page (nav sidebar + settings
* subnav + "Firmware" header + Upload CTA + firmware files table), with the
* shipped `ActiveRolloutStatus` card inline above the files table, exactly the
* way the `In Situ/In Progress` "Firmware settings page" story already
* establishes. Each state reads where an operator actually meets it, not as a
* bare card on a blank canvas. `FirmwareInSitu` is the single shared surface so
* this can't drift from that story.
*/
const meta = {
title: "Proto Fleet/Rollout/In Situ/Firmware Lifecycle",
component: ActiveRolloutStatus,
parameters: {
layout: "fullscreen",
// The in-situ surface provides its own MemoryRouter (at /settings/firmware),
// so opt out of the global StoryRouter — react-router throws on nested routers.
withRouter: false,
},
} satisfies Meta<typeof ActiveRolloutStatus>;

export default meta;

type Story = StoryObj<typeof ActiveRolloutStatus>;

export const Scheduled: Story = {
render: () => <FirmwareInSitu event={scheduledFirmwareEvent} />,
};

export const InProgress: Story = {
name: "In progress",
render: () => <FirmwareInSitu event={inProgressFirmwareEvent} />,
};

export const Paused: Story = {
render: () => <FirmwareInSitu event={pausedFirmwareEvent} />,
};

export const PilotReview: Story = {
name: "Pilot review",
render: () => <FirmwareInSitu event={pilotGateFirmwareEvent} />,
};

export const Completed: Story = {
render: () => <FirmwareInSitu event={completedFirmwareEvent} />,
};

export const CompletedWithFailures: Story = {
name: "Completed with failures",
render: () => <FirmwareInSitu event={completedWithFailuresFirmwareEvent} />,
};

export const AnimatedFirmwareLifecycle: Story = {
name: "Animated firmware lifecycle",
render: function renderAnimatedFirmwareLifecycle(): ReactElement {
return <AnimatedFirmwareInSitu base={inProgressFirmwareEvent} />;
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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";

/**
* The active **reboot** across its lifecycle states, plus a live-animated
* lifecycle — all rendered **in situ**. Reboot has no settings page of its own:
* it is a Fleet bulk action (`FleetGroupActionsMenu`), so its in-situ home is
* the Fleet page (nav sidebar + "Fleet" header + Reboot/Update CTAs + rack
* list), with the shipped `ActiveRolloutStatus` card inline above the rack
* list — the same inline treatment the firmware story uses on the Firmware
* settings page. Reboot is a batched process with no pilot-approval gate, so it
* has no "scheduled for later" / "pilot review" states of its own; the covered
* states are the ones a reboot actually reaches.
*/
const meta = {
title: "Proto Fleet/Rollout/In Situ/Reboot Lifecycle",
component: ActiveRolloutStatus,
parameters: {
layout: "fullscreen",
// The in-situ surface provides its own MemoryRouter (at /fleet), so opt out
// of the global StoryRouter — react-router throws on nested routers.
withRouter: false,
},
} satisfies Meta<typeof ActiveRolloutStatus>;

export default meta;

type Story = StoryObj<typeof ActiveRolloutStatus>;

export const InProgress: Story = {
name: "In progress",
render: () => <RebootInSitu event={inProgressRebootEvent} />,
};

export const Paused: Story = {
render: () => <RebootInSitu event={pausedRebootEvent} />,
};

export const Completed: Story = {
render: () => <RebootInSitu event={completedRebootEvent} />,
};

export const CompletedWithFailures: Story = {
name: "Completed with failures",
render: () => <RebootInSitu event={completedWithFailuresRebootEvent} />,
};

export const AnimatedRebootLifecycle: Story = {
name: "Animated reboot lifecycle",
render: function renderAnimatedRebootLifecycle(): ReactElement {
return <AnimatedRebootInSitu base={inProgressRebootEvent} />;
},
};
95 changes: 95 additions & 0 deletions client/src/protoFleet/features/rollout/ActiveRolloutBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
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";

interface ActiveRolloutBannerProps {
event: RolloutEvent;
onView?: () => void;
}

interface ActiveRolloutBannerStackProps {
events: RolloutEvent[];
onView?: (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<RolloutProcessType, keyof typeof intents> = {
firmware: intents.warning,
curtailment: intents.warning,
reboot: intents.information,
};

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 <Download className={className} />;
case "reboot":
return <Reboot className={className} />;
case "curtailment":
return <LightningAlt className={className} />;
}
}

function bannerTitle(event: RolloutEvent): string {
return event.scopeLabel ? `${event.title}, ${event.scopeLabel}` : event.title;
}

function bannerSubtitle(event: RolloutEvent): string {
const done = rolloutPhaseCount(event.rollups, "done");
const failed = rolloutPhaseCount(event.rollups, "failed");
const inScope = Math.max(event.totalTargets - event.excludedTargets, 0);
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 an active rollout, built on the shared {@link Callout}
* — our standard inline banner — so a rollout reads exactly like every other
* page banner. Stacks via {@link ActiveRolloutBannerStack} when several
* processes run at once (the Activity "Active now" surface).
*/
export function ActiveRolloutBanner({ event, onView }: ActiveRolloutBannerProps): ReactElement {
return (
<Callout
intent={processIntent[event.processType]}
prefixIcon={<ProcessIcon processType={event.processType} />}
title={bannerTitle(event)}
subtitle={bannerSubtitle(event)}
buttonText={onView ? `View ${rolloutActionNoun(event.processType)}` : undefined}
buttonOnClick={onView}
testId="active-rollout-banner"
/>
);
}

export function ActiveRolloutBannerStack({ events, onView }: ActiveRolloutBannerStackProps): ReactElement {
return (
<div className="flex flex-col gap-3" data-testid="active-rollout-banner-stack">
{events.map((event, index) => (
<ActiveRolloutBanner
key={`${event.processType}-${event.title}-${index}`}
event={event}
onView={onView ? () => onView(event, index) : undefined}
/>
))}
</div>
);
}

export default ActiveRolloutBanner;
Loading