FAB-380: container lifecycle ops UI (instance + cluster), safe mode, resend invite#1429
FAB-380: container lifecycle ops UI (instance + cluster), safe mode, resend invite#1429DavidCockerill wants to merge 7 commits into
Conversation
…te handling Studio UI for the FAB-380 container operations: - Per-instance container ops in the instance ⋯ menu (Start / Start in safe mode / Restart / Restart in safe mode / Stop) under a "Container" section, gated by manage permission + instance status. New CM apiClient mutation + toast/invalidate hook. - Safe-mode badge on running instances (hidden when stopped — a UI start clears it). - Stopped/transitioning instances: stop polling get_status (ops API unreachable), show a muted availability dot and a "—" placeholder instead of a perpetual spinner. - STOPPED status badge -> red. - Cluster card stays reachable when not running: fully-stopped opens the instances page, partial opens the cluster overview. - Cluster overview StatusPill: STOPPED -> red, PARTIAL -> yellow. - Add safeMode to the Instance type (returned by the CM, not yet in the generated schema). The container calls are hand-typed past the generated SDK (the /HDBInstance/{id}/container/ {action} paths and the safeMode field aren't in the CM OpenAPI yet). Follow-up: regenerate the SDK once the CM spec exposes them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add cluster-level Start/Stop/Restart to the ClusterCard ⋯ menu, status-gated (RUNNING → Restart/Restart-safe/Stop, STOPPED → Start/Start-safe, PARTIAL → Start/Stop/Restart). Safe-mode ops force parallel; Stop shows a confirm dialog; non-safe Restart opens a rolling/parallel picker. Wired via useClusterContainerOps + clusterContainerOperation (hand-typed past the generated SDK, same as terminate). Also show a "Safe mode" pill next to the status pill on the cluster overview when every instance is in safe mode, and override Cluster.instances to the patched Instance type so safeMode is available on cluster.instances. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a "Resend invite" action to the org users table, shown only for PENDING
users. Reuses the existing invite mutation (POST /UserInvite with { email,
roleId }, targeting the user's existing role); the backend treats a repeat
invite for a pending user as a resend. Renamed tableDefinition.ts -> .tsx to
host the JSX action cell.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces container lifecycle operations (start, stop, restart, and safe mode) for both individual instances and entire clusters, alongside a new "Resend invite" button for pending organization users. The review feedback focuses on improving code robustness and UI consistency, recommending the removal of local error handlers to prevent double-toasting, guarding query invalidations against undefined parameters, adding size classes to icons, utilizing appropriate button variants, and enhancing accessibility with focus-visible ring styles on custom buttons.
| inviteUser( | ||
| { email: user.email, roleId }, | ||
| { | ||
| onSuccess: () => toast.success(`Invitation resent to ${user.email}.`), | ||
| onError: () => toast.error(`Failed to resend invitation to ${user.email}.`), | ||
| }, | ||
| ); |
There was a problem hiding this comment.
To prevent duplicate error notifications (double-toasting), do not add local onError handlers to individual mutations if a global error handler (such as a global MutationCache.onError in React Query) is already configured to handle and display error messages.
inviteUser(
{ email: user.email, roleId },
{
onSuccess: () => toast.success(`Invitation resent to ${user.email}.`),
}
);
References
- Do not add local
onErrorhandlers to individual mutations if a global error handler (such as a globalMutationCache.onErrorin React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).
| } catch { | ||
| toast.dismiss(toastId); | ||
| toast.error('Error', { | ||
| description: `Failed to ${action} cluster ${cluster.name}.`, | ||
| action: { label: 'Dismiss', onClick: () => toast.dismiss() }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
To prevent duplicate error notifications (double-toasting), avoid adding local error toasts in the caught error block of mutations if a global error handler is already configured. Simply dismiss the loading toast and let the global handler display the error message.
} catch {
toast.dismiss(toastId);
}References
- Do not add local
onErrorhandlers to individual mutations if a global error handler (such as a globalMutationCache.onErrorin React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).
| } catch { | ||
| toast.dismiss(toastId); | ||
| toast.error('Error', { | ||
| description: `Failed to ${action} ${target}.`, | ||
| action: { label: 'Dismiss', onClick: () => toast.dismiss() }, | ||
| }); | ||
| } |
There was a problem hiding this comment.
To prevent duplicate error notifications (double-toasting), avoid adding local error toasts in the caught error block of mutations if a global error handler is already configured. Simply dismiss the loading toast and let the global handler display the error message.
} catch {
toast.dismiss(toastId);
}References
- Do not add local
onErrorhandlers to individual mutations if a global error handler (such as a globalMutationCache.onErrorin React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).
| void queryClient.invalidateQueries({ queryKey: [clusterId] }); | ||
| void queryClient.invalidateQueries({ queryKey: [instance.id] }); |
There was a problem hiding this comment.
Since clusterId is retrieved from useParams({ strict: false }) and is optional, guard the query invalidation to prevent invalidating [undefined], which could lead to unexpected behavior or invalidating more queries than intended.
if (clusterId) {
void queryClient.invalidateQueries({ queryKey: [clusterId] });
}
void queryClient.invalidateQueries({ queryKey: [instance.id] });| <Badge variant="warning" title="Running in safe mode — user apps/components are not loaded"> | ||
| <LifeBuoyIcon /> | ||
| Safe mode | ||
| </Badge> |
There was a problem hiding this comment.
Add a size class to LifeBuoyIcon inside the Badge to ensure visual consistency with the SafeModePill in ClusterHome.tsx (which uses size-3) and prevent it from rendering at its default large size.
| <Badge variant="warning" title="Running in safe mode — user apps/components are not loaded"> | |
| <LifeBuoyIcon /> | |
| Safe mode | |
| </Badge> | |
| <Badge variant="warning" title="Running in safe mode — user apps/components are not loaded"> | |
| <LifeBuoyIcon className="size-3" /> | |
| Safe mode | |
| </Badge> |
| </DialogHeader> | ||
| <DialogFooter> | ||
| <div className="flex justify-end gap-3"> | ||
| <Button type="button" onClick={() => setStopOpen(false)}>Cancel</Button> |
There was a problem hiding this comment.
The "Cancel" button should use variant="defaultOutline" or variant="ghost" to prevent visual competition with the primary/destructive action ("Stop cluster").
| <Button type="button" onClick={() => setStopOpen(false)}>Cancel</Button> | |
| <Button type="button" variant="defaultOutline" onClick={() => setStopOpen(false)}>Cancel</Button> |
References
- Do not replace the custom button variant 'defaultOutline' with the standard shadcn/ui 'outline' variant, as 'defaultOutline' is a valid custom variant in this repository (defined in 'src/components/ui/buttonVariants.tsx') that provides a primary border.
| </button> | ||
| </div> | ||
| <DialogFooter> | ||
| <Button type="button" onClick={() => setRestartOpen(false)}>Cancel</Button> |
There was a problem hiding this comment.
The "Cancel" button should use variant="defaultOutline" or variant="ghost" to prevent visual competition with the primary actions.
| <Button type="button" onClick={() => setRestartOpen(false)}>Cancel</Button> | |
| <Button type="button" variant="defaultOutline" onClick={() => setRestartOpen(false)}>Cancel</Button> |
References
- Do not replace the custom button variant 'defaultOutline' with the standard shadcn/ui 'outline' variant, as 'defaultOutline' is a valid custom variant in this repository (defined in 'src/components/ui/buttonVariants.tsx') that provides a primary border.
| <button | ||
| type="button" | ||
| disabled={isPending} | ||
| onClick={() => onConfirmRestart('rolling')} | ||
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 disabled:opacity-60" | ||
| > |
There was a problem hiding this comment.
Add focus-visible ring styles to the custom <button> element to ensure keyboard navigation is visible and accessible.
| <button | |
| type="button" | |
| disabled={isPending} | |
| onClick={() => onConfirmRestart('rolling')} | |
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 disabled:opacity-60" | |
| > | |
| <button | |
| type="button" | |
| disabled={isPending} | |
| onClick={() => onConfirmRestart('rolling')} | |
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60" | |
| > |
| <button | ||
| type="button" | ||
| disabled={isPending} | ||
| onClick={() => onConfirmRestart('parallel')} | ||
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 disabled:opacity-60" | ||
| > |
There was a problem hiding this comment.
Add focus-visible ring styles to the custom <button> element to ensure keyboard navigation is visible and accessible.
| <button | |
| type="button" | |
| disabled={isPending} | |
| onClick={() => onConfirmRestart('parallel')} | |
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 disabled:opacity-60" | |
| > | |
| <button | |
| type="button" | |
| disabled={isPending} | |
| onClick={() => onConfirmRestart('parallel')} | |
| className="rounded-lg border border-border p-4 text-left transition-colors hover:border-primary hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60" | |
| > |
|
@DavidCockerill Can you get it to rebase, and then take a look at the new nav paradigm for clusters? Surfacing things in the ... menu for clusters is great, but you can also surface them on the cluster dashboard, before you navigate into the cluster. That can aid discoverability of these features. Take a pass at the instance level features too to see if any of those can be brought out of the ... on instances, and perhaps surfaced around that instances table for better discoverability. |
|
FWIW, I tend to rebase instead of merging changes in. That will better represent what will happen when we move to bring this PR into the main branch. Plus, it gives you a clearer view of your changes being additive to the changes that have landed already. |
…afe-mode UX - Cluster overview: a "Cluster actions" dropdown (AWS "Instance state" style) with all container ops plus Terminate; ops invalid for the current status are shown but disabled so the feature set is discoverable. - Safe mode: an explain-and-confirm dialog (what it does + when to use it) that both the overview dropdown and the clusters-list card menu route through, so safe mode always explains itself instead of firing silently. - Clusters-list card: a temporary status badge for container-op states — Stopping/Starting/Restarting (amber + spinner) and Stopped/Partial — that clears on its own as the list polls; RUNNING stays clean. - Fix: plain Start/Restart now send safeMode:false so they return a cluster to normal mode. Previously they omitted safeMode and the CM preserves the current mode, so restarting an in-safe-mode cluster stayed in safe mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rId, a11y polish - Remove local error toasts in ResendInviteButton, useClusterContainerOps, and useInstanceContainerOps; the global MutationCache.onError already surfaces failures with the server's message (avoids double-toasting). - Guard the [clusterId] query invalidation (useParams strict:false can be undefined) so we don't invalidate [undefined]. - Cancel buttons -> defaultOutline (house style) across the container-op and safe-mode dialogs so they don't compete with the primary/destructive action. - Add focus-visible ring to the raw restart-strategy <button>s for keyboard a11y. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces cluster-wide and instance-level container lifecycle operations (stop, start, restart, and safe mode) along with confirmation dialogs, status badges, and polling suppression for stopped or transitioning instances. It also adds a "Resend invite" button for pending organization users. The review feedback suggests removing a local onError handler in ClusterStateMenu to prevent duplicate error notifications, and adding size and margin classes to several icons inside dropdown items and badges to ensure proper layout and alignment.
| onError: () => { | ||
| toast.error('Error', { | ||
| description: `Failed to terminate cluster: ${cluster.name}.`, | ||
| action: { label: 'Dismiss', onClick: () => toast.dismiss() }, | ||
| }); | ||
| setTerminateOpen(false); | ||
| }, |
There was a problem hiding this comment.
Do not add local onError handlers that display error messages if a global error handler (such as a global MutationCache.onError in React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting). Instead, just handle the local state cleanup (like closing the modal).
onError: () => {
setTerminateOpen(false);
},
References
- Do not add local
onErrorhandlers to individual mutations if a global error handler (such as a globalMutationCache.onErrorin React Query) is already configured to handle and display error messages, to prevent duplicate error notifications (double-toasting).
| <DropdownMenuItem | ||
| disabled={opsDisabled || !(isStopped || isPartial)} | ||
| onClick={() => void runClusterOp('start', { safeMode: false, strategy: 'parallel' })} | ||
| > | ||
| <PlayIcon /> Start | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem disabled={opsDisabled || !isStopped} onClick={() => setSafeModeAction('start')}> | ||
| <LifeBuoyIcon /> Start in safe mode | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem | ||
| disabled={opsDisabled || !(isRunning || isPartial)} | ||
| onClick={() => setRestartOpen(true)} | ||
| > | ||
| <RotateCwIcon /> Restart | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem disabled={opsDisabled || !isRunning} onClick={() => setSafeModeAction('restart')}> | ||
| <LifeBuoyIcon /> Restart in safe mode | ||
| </DropdownMenuItem> | ||
| <DropdownMenuItem | ||
| variant="destructive" | ||
| disabled={opsDisabled || !(isRunning || isPartial)} | ||
| onClick={() => setStopOpen(true)} | ||
| > | ||
| <SquareIcon /> Stop | ||
| </DropdownMenuItem> | ||
| <DropdownMenuSeparator /> | ||
| <DropdownMenuItem variant="destructive" disabled={!remove} onClick={() => setTerminateOpen(true)}> | ||
| <TrashIcon /> Terminate | ||
| </DropdownMenuItem> |
There was a problem hiding this comment.
Icons rendered directly inside DropdownMenuItem (PlayIcon, LifeBuoyIcon, RotateCwIcon, SquareIcon, TrashIcon) lack size and margin classes, which will cause them to render at their default large size (24x24) and sit right next to the text without any spacing. Consider adding className="mr-2 size-4" to each of these icons to keep them compact and properly spaced.
<DropdownMenuItem
disabled={opsDisabled || !(isStopped || isPartial)}
onClick={() => void runClusterOp('start', { safeMode: false, strategy: 'parallel' })}
>
<PlayIcon className="mr-2 size-4" /> Start
</DropdownMenuItem>
<DropdownMenuItem disabled={opsDisabled || !isStopped} onClick={() => setSafeModeAction('start')}>
<LifeBuoyIcon className="mr-2 size-4" /> Start in safe mode
</DropdownMenuItem>
<DropdownMenuItem
disabled={opsDisabled || !(isRunning || isPartial)}
onClick={() => setRestartOpen(true)}
>
<RotateCwIcon className="mr-2 size-4" /> Restart
</DropdownMenuItem>
<DropdownMenuItem disabled={opsDisabled || !isRunning} onClick={() => setSafeModeAction('restart')}>
<LifeBuoyIcon className="mr-2 size-4" /> Restart in safe mode
</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
disabled={opsDisabled || !(isRunning || isPartial)}
onClick={() => setStopOpen(true)}
>
<SquareIcon className="mr-2 size-4" /> Stop
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={!remove} onClick={() => setTerminateOpen(true)}>
<TrashIcon className="mr-2 size-4" /> Terminate
</DropdownMenuItem>
| {cell.row.original.safeMode && !isStoppedOrTransitioning(cell.row.original.status) | ||
| ? ( | ||
| <Badge variant="warning" title="Running in safe mode — user apps/components are not loaded"> | ||
| <LifeBuoyIcon /> |
There was a problem hiding this comment.
The LifeBuoyIcon inside the Badge lacks a size class, which may cause it to render at its default large size (24x24) and distort the badge layout. Consider adding a size class like size-3.5 to keep it compact and consistent with other status indicators.
| <LifeBuoyIcon /> | |
| <LifeBuoyIcon className="size-3.5" /> |
| {isActive && view && <ClusterCardAction cluster={cluster} hasCardLink={!!cardHref} />} | ||
| {showContainerOpBadge && cluster.status && ( | ||
| <Badge variant={isClusterStopped ? 'destructive' : 'warning'}> | ||
| {isClusterTransitioning && <Loader2 className="animate-spin" />} |
There was a problem hiding this comment.
The Loader2 icon inside the Badge lacks a size class, which may cause it to render at its default large size (24x24) and distort the badge layout. Consider adding a size class like size-3.5 to keep it compact and consistent.
| {isClusterTransitioning && <Loader2 className="animate-spin" />} | |
| {isClusterTransitioning && <Loader2 className="size-3.5 animate-spin" />} |
Gemini re-review: the terminate mutation's local onError toast duplicates the global MutationCache.onError toast. Keep only the modal close. (The 3 icon-size comments are no-ops: DropdownMenuItem already applies [&_svg]:size-4 + gap-2, and Badge applies [&>svg]:size-3.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Studio UI for the FAB-380 container lifecycle operations (stop / start / restart, with safe mode), plus a small resend-invite addition for pending org users. This is the front-end for the central-manager / host-manager work in HarperFast/central-manager#404 and HarperFast/host-manager#126.
Container ops are a distinct class from the existing proxied Harper "restart" — they're handled host-side via the CM, are async (the endpoint returns a transitional status and the resting state lands as the poll catches up), and support
safeMode(boot Harper core without loading user apps/components).What's included
Per-instance container ops (
useInstanceMenuItems)Containersection in the instance ⋯ menu — Start / Start in safe mode (when STOPPED), Restart / Restart in safe mode / Stop (when RUNNING). Hidden mid-transition.useInstanceContainerOpshook +instanceContainerOperationclient (POST /HDBInstance/{id}/container/{action}).Cluster-wide container ops (
ClusterCard)Containersection in the cluster ⋯ menu, gated by cluster state (RUNNING → Restart/Restart-safe/Stop, STOPPED → Start/Start-safe, PARTIAL → Start/Stop/Restart).strategy: parallel(the CM rejects safeMode + rolling). Stop shows a confirm dialog; non-safe Restart opens a rolling/parallel picker.useClusterContainerOpshook +clusterContainerOperationclient (POST /Cluster/{id}/container/{action}), andClusterContainerOpModals.Cluster overview — discoverability (
ClusterStateMenu)SafeModeConfirmDialog): "safe mode" is jargon and a recovery action, so rather than a hover-only tooltip we explain what it does at the moment of use. Shared by the start-in-safe-mode and restart-in-safe-mode entries on both the overview and the card.Stopping/Starting/Restartingbadge (with spinner), and aStoppedlabel when the cluster is stopped — so users can see what's happening without opening the cluster. Clears on its own as the 10s poll catches the resting state.safeMode: falseso a cluster that's in safe mode returns to normal mode. (Previously the UI omittedsafeMode, and the CM preserves the current mode when it's omitted — so a plain restart of a safe-mode cluster stayed in safe mode.)Safe mode indicators
Stopped-state handling
isStoppedOrTransitioninghelper; stopped/transitioning instances stopget_statuspolling (the ops API is unreachable), show a muted dot instead of a stale "Available"/endless spinner, and a "—" in the sign-in cell.STOPPEDbadge is nowdestructive(red); cluster status pill colors STOPPED red / PARTIAL yellow. Stopped cards open the instances page; partial cards open the overview.Resend invite
PENDINGusers. Reuses the existing invite mutation (POST /UserInvitewith{ email, roleId }); the CM treats a repeat invite for a pending user as a resend.Screenshots
Cluster overview — Running + Safe mode pill
Instances — Safe mode badges
Per-instance ⋯ menu — Container section
Cluster ⋯ menu — Container section
Restart strategy picker (non-safe cluster restart)
Resend invite (pending users only)
Backend dependencies / notes
/container/{action}paths andsafeMode/strategybody aren't in the CM OpenAPI yet (same pattern asterminateCluster). Follow-up: regenerate the SDK once the CM OpenAPI exposes them and drop the casts.Instance.safeModeandCluster.instances(now the patchedInstance[]) are added inapi.patch.d.tsfor the same reason.409 "User already has a pending invite"; the UI is correct and will work once that build is out.Out of scope / follow-ups
MutationCache.onError(which shows the server's message) with no local error toast, so there's no double-toasting. The same pre-existing local-onErrordouble-toast pattern lives elsewhere in the app (e.g.ClusterCardterminate, org settings/roles/users,ClusterForm) — intentionally left for a separate repo-wide cleanup rather than widened here.Testing
tsc -p tsconfig.app.json,oxlint,dprintall pass; full unit suite green (pre-commit, 1239 tests).gemini-code-assistreview passes addressed — genuine findings fixed (double-toasts,[clusterId]invalidation guard, Cancel-button variants, focus-visible a11y ring); icon-size comments verified as no-ops (DropdownMenuItem/Badgealready size their svg children).🤖 Generated with Claude Code