From 97fc0253a54b02bed618ba3f5ae31932f43e7be8 Mon Sep 17 00:00:00 2001 From: David C Date: Tue, 7 Jul 2026 16:32:07 -0400 Subject: [PATCH 1/6] feat(cluster-ops): instance container-op UI + safe mode + stopped-state handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/ui/utils/badgeStatus.tsx | 18 ++++- src/features/cluster/ClusterHome.tsx | 13 ++-- src/features/cluster/InstanceLogInCell.tsx | 7 ++ src/features/cluster/InstanceStatusCell.tsx | 22 +++++- src/features/cluster/Instances.tsx | 11 ++- src/features/cluster/useInstanceMenuItems.tsx | 71 ++++++++++++++++++- .../clusters/components/ClusterCard.tsx | 11 ++- src/hooks/useInstanceContainerOps.ts | 62 ++++++++++++++++ src/integrations/api/api.patch.d.ts | 3 + .../api/instance/containerOperation.ts | 48 +++++++++++++ 10 files changed, 255 insertions(+), 11 deletions(-) create mode 100644 src/hooks/useInstanceContainerOps.ts create mode 100644 src/integrations/api/instance/containerOperation.ts diff --git a/src/components/ui/utils/badgeStatus.tsx b/src/components/ui/utils/badgeStatus.tsx index f64ce7e2f..2dc8b87b4 100644 --- a/src/components/ui/utils/badgeStatus.tsx +++ b/src/components/ui/utils/badgeStatus.tsx @@ -27,7 +27,7 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus } switch (value) { case 'STOPPED': - return 'secondary'; + return 'destructive'; case 'TERMINATING': case 'TERMINATED': case 'FAILED': @@ -39,6 +39,22 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus } } +/** + * The instance is stopped or mid container-lifecycle transition, so its ops API is unreachable. + * Callers use this to suppress per-instance status polling (get_status) until it's back up. + */ +export function isStoppedOrTransitioning(value: string | undefined): boolean { + switch (value) { + case 'STOPPED': + case 'STOPPING': + case 'STARTING': + case 'RESTARTING': + return true; + default: + return false; + } +} + export function isRunning(value: string | undefined): value is 'RUNNING' | 'UPDATED' { switch (value) { case 'RUNNING': diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx index 2358e26f6..2ccde7321 100644 --- a/src/features/cluster/ClusterHome.tsx +++ b/src/features/cluster/ClusterHome.tsx @@ -384,12 +384,15 @@ function Spinner() { function StatusPill({ status }: { status?: string }) { const active = status && activeClusterStatuses.includes(status); + const colorClass = active + ? 'text-green bg-green/10' + : status === 'STOPPED' + ? 'text-destructive bg-destructive/10' + : status === 'PARTIAL' + ? 'text-yellow bg-yellow/10' + : 'text-muted-foreground bg-muted'; return ( - + {status ? status.charAt(0) + status.slice(1).toLowerCase() : 'Unknown'} diff --git a/src/features/cluster/InstanceLogInCell.tsx b/src/features/cluster/InstanceLogInCell.tsx index 0634d99fd..6f48984af 100644 --- a/src/features/cluster/InstanceLogInCell.tsx +++ b/src/features/cluster/InstanceLogInCell.tsx @@ -1,4 +1,5 @@ import { Button } from '@/components/ui/button'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { defaultInstanceRoute } from '@/config/constants'; import { useInstanceClient } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; @@ -24,6 +25,12 @@ export function InstanceLogInCell( await signOutOfInstance({ instance, instanceClient }); }, [instance, instanceClient]); + // A stopped / mid-transition instance isn't reachable — you can't connect or sign in — so show a + // neutral placeholder instead of the perpetual spinner (which is meant for instances coming up). + if (isStoppedOrTransitioning(instance.status)) { + return ; + } + if ( instanceAuthIsLoading || !instance.status || !['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE'].includes(instance.status) diff --git a/src/features/cluster/InstanceStatusCell.tsx b/src/features/cluster/InstanceStatusCell.tsx index 6e515b1f4..6afb3ffa9 100644 --- a/src/features/cluster/InstanceStatusCell.tsx +++ b/src/features/cluster/InstanceStatusCell.tsx @@ -1,6 +1,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { useInstanceClientIdParams } from '@/config/useInstanceClient'; import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions'; import { Instance } from '@/integrations/api/api.patch'; @@ -31,8 +32,12 @@ export function InstanceStatusCell( return () => clearTimeout(timer); }, [index]); + // Don't poll get_status while the instance is stopped / mid container-transition — its ops API + // is unreachable, so the request just errors on a 10s loop. + const stopped = isStoppedOrTransitioning(instance.status); + const statusPollEnabled = ready && canManage && !stopped; const { data: statusResponse, isLoading, isFetching } = useQuery( - getStatusQueryOptions(instanceParams, ready && canManage), + getStatusQueryOptions(instanceParams, statusPollEnabled), ); const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown'; @@ -43,6 +48,21 @@ export function InstanceStatusCell( return null; } + // Stopped / transitioning: availability + rotation are N/A. Show a muted dot rather than a stale + // green "Available" (cached from before it stopped) or an endless spinner. + if (stopped) { + return ( +
+ + + + + Not running + +
+ ); + } + return (
diff --git a/src/features/cluster/Instances.tsx b/src/features/cluster/Instances.tsx index d92ac999a..4730e33a5 100644 --- a/src/features/cluster/Instances.tsx +++ b/src/features/cluster/Instances.tsx @@ -3,7 +3,7 @@ import { SubNavMenu } from '@/components/SubNavMenu'; import { TextLoadingSkeleton } from '@/components/TextLoadingSkeleton'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; -import { renderBadgeStatusVariant } from '@/components/ui/utils/badgeStatus'; +import { isStoppedOrTransitioning, renderBadgeStatusVariant } from '@/components/ui/utils/badgeStatus'; import { deletedClusterStatuses } from '@/config/clusterStatuses'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { calculateInstanceFQDN } from '@/features/clusters/upsert/lib/calculateInstanceFQDN'; @@ -15,6 +15,7 @@ import { capitalizeWords } from '@/lib/string/capitalizeWords'; import { useQuery } from '@tanstack/react-query'; import { useParams } from '@tanstack/react-router'; import { ColumnDef } from '@tanstack/react-table'; +import { LifeBuoyIcon } from 'lucide-react'; import { useMemo } from 'react'; import { EmptyCluster } from './EmptyCluster'; import { InstanceActionsMenu } from './InstanceActionsMenu'; @@ -73,6 +74,14 @@ export function Instances() {
{status ? {capitalizeWords(status)} : null} + {cell.row.original.safeMode && !isStoppedOrTransitioning(cell.row.original.status) + ? ( + + + Safe mode + + ) + : null}
); }, diff --git a/src/features/cluster/useInstanceMenuItems.tsx b/src/features/cluster/useInstanceMenuItems.tsx index 9d2fea5a9..bab5a707a 100644 --- a/src/features/cluster/useInstanceMenuItems.tsx +++ b/src/features/cluster/useInstanceMenuItems.tsx @@ -1,4 +1,5 @@ import type { EntityMenuItem } from '@/components/ui/entityMenu'; +import { isStoppedOrTransitioning } from '@/components/ui/utils/badgeStatus'; import { defaultInstanceRoute } from '@/config/constants'; import { useInstanceClient, useInstanceClientIdParams } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; @@ -6,6 +7,7 @@ import { signOutOfInstance } from '@/features/cluster/signOutOfInstance'; import { calculateInstanceFQDN } from '@/features/clusters/upsert/lib/calculateInstanceFQDN'; import { useInstanceAuth } from '@/hooks/useAuth'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; +import { useInstanceContainerOps } from '@/hooks/useInstanceContainerOps'; import { useOrganizationClusterInstancePermissions } from '@/hooks/usePermissions'; import { Instance } from '@/integrations/api/api.patch'; import { getStatusQueryOptions, getSystemStatusById } from '@/integrations/api/instance/status/getStatus'; @@ -13,7 +15,18 @@ import { useSetStatus } from '@/integrations/api/instance/status/setStatus'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { useQuery } from '@tanstack/react-query'; -import { ClipboardIcon, LogInIcon, LogOutIcon, ServerIcon, ShieldCheckIcon, ShieldXIcon } from 'lucide-react'; +import { + ClipboardIcon, + LifeBuoyIcon, + LogInIcon, + LogOutIcon, + PlayIcon, + RotateCwIcon, + ServerIcon, + ShieldCheckIcon, + ShieldXIcon, + SquareIcon, +} from 'lucide-react'; import { useCallback, useMemo } from 'react'; const READY_STATUSES = ['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE']; @@ -39,11 +52,14 @@ export function useInstanceMenuItems( const isFabricConnect = authStore.checkForFabricConnect(instance.id); const statusParams = useInstanceClientIdParams({ operationsUrl, instanceId: instance.id, forceFabricConnect: true }); - const { data: statusResponse } = useQuery(getStatusQueryOptions(statusParams, enabled && canManage)); + const { data: statusResponse } = useQuery( + getStatusQueryOptions(statusParams, enabled && canManage && !isStoppedOrTransitioning(instance.status)), + ); const systemStatus = getSystemStatusById(statusResponse, 'availability') || 'Unknown'; const isAvailable = systemStatus === 'Available'; const isUnavailable = systemStatus === 'Unavailable'; const { mutate: setStatus, isPending: isSettingStatus } = useSetStatus(); + const { run: runContainerOp, isPending: isContainerOpPending } = useInstanceContainerOps(instance); const fqdn = instance.instanceFqdn; const apiUrl = calculateInstanceFQDN({ @@ -63,6 +79,13 @@ export function useInstanceMenuItems( const hasCopy = !!fqdn; const hasRotation = canManage && isReady && (isAvailable || isUnavailable); + // Container lifecycle ops (stop/start/restart) — distinct from the proxied Harper "restart". + // Only offered from a settled RUNNING/STOPPED state; hidden mid-transition (the instances poll + // reveals the resting state and the actions reappear). + const isRunning = instance.status === 'RUNNING'; + const isStopped = instance.status === 'STOPPED'; + const hasContainerOps = canManage && (isRunning || isStopped); + const actions: EntityMenuItem[] = [ hasAuth && isDirectlyLoggedIn && { key: 'direct-connect', @@ -110,6 +133,50 @@ export function useInstanceMenuItems( icon: , label: 'Bring back into rotation', }, + + (hasAuth || hasCopy || hasRotation) && hasContainerOps && { type: 'separator' as const, key: 'container-sep' }, + hasContainerOps && { + type: 'label' as const, + key: 'container-label', + className: 'text-gray-600 text-xs', + label: 'Container', + }, + hasContainerOps && isStopped && { + key: 'container-start', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('start', { safeMode: false }), + icon: , + label: 'Start', + }, + hasContainerOps && isStopped && { + key: 'container-start-safe', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('start', { safeMode: true, label: 'Starting in safe mode' }), + icon: , + label: 'Start in safe mode', + }, + hasContainerOps && isRunning && { + key: 'container-restart', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('restart', { safeMode: false }), + icon: , + label: 'Restart', + }, + hasContainerOps && isRunning && { + key: 'container-restart-safe', + disabled: isContainerOpPending, + onClick: () => void runContainerOp('restart', { safeMode: true, label: 'Restarting in safe mode' }), + icon: , + label: 'Restart in safe mode', + }, + hasContainerOps && isRunning && { + key: 'container-stop', + variant: 'destructive' as const, + disabled: isContainerOpPending, + onClick: () => void runContainerOp('stop'), + icon: , + label: 'Stop', + }, ].filter(excludeFalsy); if (!actions.length) { diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index abf587562..c96dce636 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -137,7 +137,16 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { // The whole card opens the cluster home (overview) for the normal "Open" case — including // self-hosted clusters, which get their own overview. A managed cluster with no FQDN yet opens its // instances; resetPassword (→ Finish Setup / Pending) keeps its explicit CTA in ClusterCardAction. - const cardHref = !isActive || !view || isTerminated + // Stopped/partial clusters aren't "active" but must still be reachable: a fully-stopped cluster + // opens its instances page (where you start them back up); a partial cluster (some instances still + // running) opens the cluster overview like a normal cluster. + const cardHref = !view || isTerminated + ? undefined + : cluster.status === 'STOPPED' + ? `/${cluster.organizationId}/${cluster.id}/instances` + : cluster.status === 'PARTIAL' + ? `/${cluster.organizationId}/${cluster.id}` + : !isActive ? undefined : isSelfManaged ? `/${cluster.organizationId}/${cluster.id}` diff --git a/src/hooks/useInstanceContainerOps.ts b/src/hooks/useInstanceContainerOps.ts new file mode 100644 index 000000000..57f86e31d --- /dev/null +++ b/src/hooks/useInstanceContainerOps.ts @@ -0,0 +1,62 @@ +import { Instance } from '@/integrations/api/api.patch'; +import { ContainerAction, useInstanceContainerOperation } from '@/integrations/api/instance/containerOperation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useParams } from '@tanstack/react-router'; +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +const GERUND: Record = { + stop: 'Stopping', + start: 'Starting', + restart: 'Restarting', +}; + +export interface RunContainerOpOptions { + /** start/restart only; omit for stop. Explicit false exits safe mode. */ + safeMode?: boolean; + /** Override the display label, e.g. "Restarting in safe mode". */ + label?: string; +} + +/** + * Fires an instance container op with the standard loading → success/error toast and cache + * invalidation, mirroring {@link useRestartInstanceClick}. The op is async backend-side, so + * "accepted" means the instance entered its transitional state; the instances-page poll reflects + * the resting state shortly after. + */ +export function useInstanceContainerOps(instance: Instance) { + const { clusterId }: { clusterId?: string } = useParams({ strict: false }); + const { mutateAsync, isPending } = useInstanceContainerOperation(); + const queryClient = useQueryClient(); + + const run = useCallback( + async (action: ContainerAction, opts?: RunContainerOpOptions) => { + const gerund = opts?.label ?? GERUND[action]; + const target = instance.name ?? instance.id; + const toastId = toast.loading(gerund, { + description: `${gerund} ${target}. Status will update shortly.`, + duration: 30_000, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + try { + await mutateAsync({ instanceId: instance.id, action, safeMode: opts?.safeMode }); + void queryClient.invalidateQueries({ queryKey: [clusterId] }); + void queryClient.invalidateQueries({ queryKey: [instance.id] }); + toast.dismiss(toastId); + toast.success('Request accepted', { + description: `${target} is ${gerund.toLowerCase()}. The status will update automatically.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } catch { + toast.dismiss(toastId); + toast.error('Error', { + description: `Failed to ${action} ${target}.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } + }, + [clusterId, instance.id, instance.name, mutateAsync, queryClient], + ); + + return { run, isPending }; +} diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts index 72b639517..bfa35d74d 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -123,6 +123,9 @@ export type LocalRoleAttributePermissionAction = keyof Omit { + const body = safeMode === undefined ? {} : { safeMode }; + const { data } = await apiClient.post( + `/HDBInstance/${instanceId}/container/${action}` as '/HDBInstance/{id}', + body, + ); + return data as InstanceContainerOperationResponse; +} + +export function useInstanceContainerOperation() { + return useMutation({ mutationFn: instanceContainerOperation }); +} From 2ad30a2a04d31faad7acf2790b1afe726582ff23 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 8 Jul 2026 11:45:13 -0400 Subject: [PATCH 2/6] feat(cluster-ops): cluster-wide container ops + safe-mode pill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/features/cluster/ClusterHome.tsx | 36 ++++++- .../clusters/components/ClusterCard.tsx | 77 +++++++++++++++ .../components/ClusterContainerOpModals.tsx | 98 +++++++++++++++++++ src/hooks/useClusterContainerOps.ts | 59 +++++++++++ src/integrations/api/api.patch.d.ts | 4 +- .../api/cluster/containerOperation.ts | 48 +++++++++ 6 files changed, 320 insertions(+), 2 deletions(-) create mode 100644 src/features/clusters/components/ClusterContainerOpModals.tsx create mode 100644 src/hooks/useClusterContainerOps.ts create mode 100644 src/integrations/api/cluster/containerOperation.ts diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx index 2ccde7321..7703eca0c 100644 --- a/src/features/cluster/ClusterHome.tsx +++ b/src/features/cluster/ClusterHome.tsx @@ -17,7 +17,18 @@ import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluste import { getOperationsUrlForInstance } from '@/lib/urls/getOperationsUrlForInstance'; import { useQuery } from '@tanstack/react-query'; import { Link, Navigate, useNavigate, useParams, useRouter } from '@tanstack/react-router'; -import { ArrowRight, CircleCheck, Copy, ExternalLink, KeyRound, Loader2, Rocket, Server, Zap } from 'lucide-react'; +import { + ArrowRight, + CircleCheck, + Copy, + ExternalLink, + KeyRound, + LifeBuoy, + Loader2, + Rocket, + Server, + Zap, +} from 'lucide-react'; import { ComponentType, ReactNode, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -117,6 +128,14 @@ export function ClusterHome() { const connected = !!user; const lastMode = authStore.getLastConnectMode(cluster.id); + // The cluster is "fully in safe mode" only when it's up and every instance reports safe mode — a + // stopped/transitioning instance has no safeMode flag, so a partial cluster won't qualify. + const clusterInstances = cluster.instances ?? []; + const allInSafeMode = !!cluster.status + && activeClusterStatuses.includes(cluster.status) + && clusterInstances.length > 0 + && clusterInstances.every((instance) => instance.safeMode); + return (
@@ -127,6 +146,7 @@ export function ClusterHome() {

{cluster.name}

+ {allInSafeMode && }
{cluster.instances?.length ?? 0} instances @@ -399,6 +419,20 @@ function StatusPill({ status }: { status?: string }) { ); } +// Shown beside the StatusPill when every instance is in safe mode (mirrors the per-instance +// "Safe mode" badge on the Instances page, styled as a rounded-full pill to match StatusPill). +function SafeModePill() { + return ( + + + Safe mode + + ); +} + function ConnectOption( { icon: Icon, title, description, pill, children }: { icon: ComponentType<{ className?: string }>; diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index c96dce636..d979b7c1a 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -10,13 +10,16 @@ import { useInstanceClient } from '@/config/useInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery'; import { ClusterCardAction } from '@/features/clusters/components/ClusterCardAction'; +import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals'; import { ClusterProgress } from '@/features/clusters/components/ClusterProgress'; import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster'; import { useInstanceAuth } from '@/hooks/useAuth'; +import { useClusterContainerOps } from '@/hooks/useClusterContainerOps'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; import { onInstanceLogoutSubmit } from '@/integrations/api/instance/auth/onInstanceLogoutSubmit'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; @@ -32,9 +35,13 @@ import { GitGraphIcon, GlobeIcon, KeyIcon, + LifeBuoyIcon, + PlayIcon, RocketIcon, + RotateCwIcon, ScaleIcon, ServerIcon, + SquareIcon, TrashIcon, } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; @@ -53,6 +60,16 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { const [signingOut, setSigningOut] = useState(false); const [isTerminateClusterModalOpen, setIsTerminateClusterModalOpen] = useState(false); + const [stopConfirmOpen, setStopConfirmOpen] = useState(false); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const { run: runClusterOp, isPending: isClusterOpPending } = useClusterContainerOps(cluster); + + // Container-op availability by cluster state (see the per-instance menu for the same idea): + // RUNNING → Restart, Restart in safe mode, Stop STOPPED → Start, Start in safe mode + // PARTIAL → Start, Stop, Restart (some up, some down) + const isClusterRunning = cluster.status === 'RUNNING'; + const isClusterStopped = cluster.status === 'STOPPED'; + const isClusterPartial = cluster.status === 'PARTIAL'; const isActive = useMemo( () => !!(cluster.status && activeClusterStatuses.includes(cluster.status)), @@ -214,6 +231,49 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { label: 'Deployments', }, + update && (isClusterRunning || isClusterStopped || isClusterPartial) + && { type: 'separator' as const, key: 'container-separator' }, + update && (isClusterRunning || isClusterStopped || isClusterPartial) + && { type: 'label' as const, key: 'container-label', className: 'text-gray-600 text-xs', label: 'Container' }, + update && (isClusterStopped || isClusterPartial) && { + key: 'container-start', + disabled: isClusterOpPending, + onClick: () => void runClusterOp('start', { strategy: 'parallel' }), + icon: , + label: 'Start', + }, + update && isClusterStopped && { + key: 'container-start-safe', + disabled: isClusterOpPending, + onClick: () => + void runClusterOp('start', { safeMode: true, strategy: 'parallel', label: 'Starting in safe mode' }), + icon: , + label: 'Start in safe mode', + }, + update && (isClusterRunning || isClusterPartial) && { + key: 'container-restart', + disabled: isClusterOpPending, + onClick: () => setRestartDialogOpen(true), + icon: , + label: 'Restart', + }, + update && isClusterRunning && { + key: 'container-restart-safe', + disabled: isClusterOpPending, + onClick: () => + void runClusterOp('restart', { safeMode: true, strategy: 'parallel', label: 'Restarting in safe mode' }), + icon: , + label: 'Restart in safe mode', + }, + update && (isClusterRunning || isClusterPartial) && { + key: 'container-stop', + variant: 'destructive' as const, + disabled: isClusterOpPending, + onClick: () => setStopConfirmOpen(true), + icon: , + label: 'Stop', + }, + isActive && view && !!cluster.fqdn && { type: 'separator' as const, key: 'copy-separator' }, isActive && view && !!cluster.fqdn && { key: 'copy-host-name', @@ -324,6 +384,23 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { deletionConfirmed={handleTerminatedCluster} deletionPending={isTerminateClusterPending} /> + + { + setStopConfirmOpen(false); + void runClusterOp('stop', { strategy: 'parallel' }); + }} + restartOpen={restartDialogOpen} + setRestartOpen={setRestartDialogOpen} + onConfirmRestart={(strategy: ContainerStrategy) => { + setRestartDialogOpen(false); + void runClusterOp('restart', { strategy }); + }} + /> ); diff --git a/src/features/clusters/components/ClusterContainerOpModals.tsx b/src/features/clusters/components/ClusterContainerOpModals.tsx new file mode 100644 index 000000000..a93243c34 --- /dev/null +++ b/src/features/clusters/components/ClusterContainerOpModals.tsx @@ -0,0 +1,98 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; + +/** + * Dialogs for cluster-wide container ops: + * - Stop confirmation (whole cluster goes offline). + * - Restart strategy picker (parallel vs rolling) — clicking a strategy dispatches the restart. + * State lives in the parent (ClusterCard), mirroring how the terminate modal is wired. + */ +export function ClusterContainerOpModals({ + clusterName, + isPending, + stopOpen, + setStopOpen, + onConfirmStop, + restartOpen, + setRestartOpen, + onConfirmRestart, +}: { + clusterName: string; + isPending: boolean; + stopOpen: boolean; + setStopOpen: (open: boolean) => void; + onConfirmStop: () => void; + restartOpen: boolean; + setRestartOpen: (open: boolean) => void; + onConfirmRestart: (strategy: ContainerStrategy) => void; +}) { + return ( + <> + + + + Stop cluster {clusterName}? + + This stops every instance in the cluster. It will be offline and stop serving traffic until you start it + again. + + + +
+ + +
+
+
+
+ + + + + Restart cluster {clusterName} + Choose how to restart the cluster's instances. + +
+ + +
+ + + +
+
+ + ); +} diff --git a/src/hooks/useClusterContainerOps.ts b/src/hooks/useClusterContainerOps.ts new file mode 100644 index 000000000..0f09a32a1 --- /dev/null +++ b/src/hooks/useClusterContainerOps.ts @@ -0,0 +1,59 @@ +import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy, useClusterContainerOperation } from '@/integrations/api/cluster/containerOperation'; +import { ContainerAction } from '@/integrations/api/instance/containerOperation'; +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback } from 'react'; +import { toast } from 'sonner'; + +const GERUND: Record = { + stop: 'Stopping', + start: 'Starting', + restart: 'Restarting', +}; + +export interface RunClusterOpOptions { + safeMode?: boolean; + strategy?: ContainerStrategy; + /** Override the display label, e.g. "Restarting in safe mode". */ + label?: string; +} + +/** + * Fires a cluster-wide container op with the standard loading → success/error toast + cache + * invalidation (mirrors {@link useInstanceContainerOps}). The op is async, so "accepted" means the + * fan-out started; the clusters list / overview poll reflect the resting state shortly after. + */ +export function useClusterContainerOps(cluster: Cluster) { + const { mutateAsync, isPending } = useClusterContainerOperation(); + const queryClient = useQueryClient(); + + const run = useCallback( + async (action: ContainerAction, opts?: RunClusterOpOptions) => { + const gerund = opts?.label ?? GERUND[action]; + const toastId = toast.loading(gerund, { + description: `${gerund} cluster ${cluster.name}. Status will update automatically.`, + duration: 30_000, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + try { + await mutateAsync({ clusterId: cluster.id, action, safeMode: opts?.safeMode, strategy: opts?.strategy }); + void queryClient.invalidateQueries({ queryKey: [cluster.organizationId] }); + void queryClient.invalidateQueries({ queryKey: [cluster.id] }); + toast.dismiss(toastId); + toast.success('Request accepted', { + description: `${cluster.name} is ${gerund.toLowerCase()}. The status will update automatically.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } catch { + toast.dismiss(toastId); + toast.error('Error', { + description: `Failed to ${action} cluster ${cluster.name}.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + } + }, + [cluster.id, cluster.name, cluster.organizationId, mutateAsync, queryClient], + ); + + return { run, isPending }; +} diff --git a/src/integrations/api/api.patch.d.ts b/src/integrations/api/api.patch.d.ts index bfa35d74d..eb1defb5c 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -128,9 +128,11 @@ export interface Instance extends SchemaHdbInstance { safeMode?: boolean; } -export interface Cluster extends SchemaCluster { +export interface Cluster extends Omit { // TODO: Can we return enums from the server to make this easier? status?: string | 'PROVISIONING' | 'UPDATING' | 'RUNNING' | 'TERMINATED' | 'FAILED'; + // Use the patched Instance (adds status + safeMode) rather than the raw generated shape. + instances?: Instance[]; } export interface ClusterUpsert extends SchemaClusterUpsert { diff --git a/src/integrations/api/cluster/containerOperation.ts b/src/integrations/api/cluster/containerOperation.ts new file mode 100644 index 000000000..4012903fd --- /dev/null +++ b/src/integrations/api/cluster/containerOperation.ts @@ -0,0 +1,48 @@ +import { apiClient } from '@/config/apiClient'; +import { ContainerAction } from '@/integrations/api/instance/containerOperation'; +import { useMutation } from '@tanstack/react-query'; + +export type ContainerStrategy = 'parallel' | 'rolling'; + +export interface ClusterContainerOperationParams { + clusterId: string; + action: ContainerAction; + /** start/restart only. Explicit false exits safe mode. Backend rejects safeMode + rolling. */ + safeMode?: boolean; + /** parallel (all at once) or rolling (one at a time, waiting for each to rejoin). */ + strategy?: ContainerStrategy; +} + +export interface ClusterContainerOperationResponse { + clusterId: string; + action: ContainerAction; + strategy: ContainerStrategy; + /** Transitional cluster status returned immediately (STOPPING / STARTING / RESTARTING). */ + status: string; + instanceIds: string[]; + message: string; +} + +/** + * Dispatch a cluster-wide container lifecycle op via the central manager: + * POST /Cluster/{id}/container/{action}. Fans the action out to every instance. + * + * Async: the endpoint returns a transitional status immediately; the resting status lands as the + * fan-out completes (the clusters list / overview poll reflect it). + * + * NOTE: hand-typed past the generated SDK (the /container/{action} path + safeMode/strategy body + * aren't in the CM OpenAPI yet) — same pattern as terminateCluster / the instance op. + */ +export async function clusterContainerOperation( + { clusterId, action, safeMode, strategy }: ClusterContainerOperationParams, +): Promise { + const body: { safeMode?: boolean; strategy?: ContainerStrategy } = {}; + if (safeMode !== undefined) { body.safeMode = safeMode; } + if (strategy !== undefined) { body.strategy = strategy; } + const { data } = await apiClient.post(`/Cluster/${clusterId}/container/${action}` as '/Cluster/{id}', body); + return data as ClusterContainerOperationResponse; +} + +export function useClusterContainerOperation() { + return useMutation({ mutationFn: clusterContainerOperation }); +} From fe79efe3f501d6e585e2a27eb732a39df31c2c27 Mon Sep 17 00:00:00 2001 From: David C Date: Wed, 8 Jul 2026 12:32:01 -0400 Subject: [PATCH 3/6] feat(users): resend invite button for pending org users 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 --- .../users/components/ResendInviteButton.tsx | 49 +++++++++++++++++++ ...tableDefinition.ts => tableDefinition.tsx} | 7 +++ 2 files changed, 56 insertions(+) create mode 100644 src/features/organization/users/components/ResendInviteButton.tsx rename src/features/organization/users/constants/{tableDefinition.ts => tableDefinition.tsx} (74%) diff --git a/src/features/organization/users/components/ResendInviteButton.tsx b/src/features/organization/users/components/ResendInviteButton.tsx new file mode 100644 index 000000000..52f4481c1 --- /dev/null +++ b/src/features/organization/users/components/ResendInviteButton.tsx @@ -0,0 +1,49 @@ +import { Button } from '@/components/ui/button'; +import { useInviteUserToOrganizationRole } from '@/features/organization/mutations/inviteUserToOrganizationRole'; +import { SchemaUser } from '@/integrations/api/api.gen'; +import { MailIcon } from 'lucide-react'; +import { MouseEvent, useCallback } from 'react'; +import { toast } from 'sonner'; + +/** + * Resend a pending user's org invite. The backend treats a repeat POST /UserInvite for an + * already-invited (still pending) user as a resend, so this reuses the same invite mutation and + * payload ({ email, roleId }) — we just target the user's existing role. + * + * Rendered only for PENDING rows (see the users table definition). Stops row-click propagation so + * clicking it doesn't also open the edit modal. + */ +export function ResendInviteButton({ user }: { user: SchemaUser }) { + const { mutate: inviteUser, isPending } = useInviteUserToOrganizationRole(); + const roleId = user.roles?.[0]?.id; + + const onClick = useCallback( + (event: MouseEvent) => { + event.stopPropagation(); + if (!user.email || !roleId) { + toast.error('Cannot resend invite: this user has no email or role.'); + return; + } + inviteUser( + { email: user.email, roleId }, + { + onSuccess: () => toast.success(`Invitation resent to ${user.email}.`), + onError: () => toast.error(`Failed to resend invitation to ${user.email}.`), + }, + ); + }, + [inviteUser, roleId, user.email], + ); + + return ( + + ); +} diff --git a/src/features/organization/users/constants/tableDefinition.ts b/src/features/organization/users/constants/tableDefinition.tsx similarity index 74% rename from src/features/organization/users/constants/tableDefinition.ts rename to src/features/organization/users/constants/tableDefinition.tsx index 2b1d3e35c..fa97f0ba0 100644 --- a/src/features/organization/users/constants/tableDefinition.ts +++ b/src/features/organization/users/constants/tableDefinition.tsx @@ -1,3 +1,4 @@ +import { ResendInviteButton } from '@/features/organization/users/components/ResendInviteButton'; import { SchemaUser } from '@/integrations/api/api.gen'; import { ColumnDef, createColumnHelper } from '@tanstack/react-table'; @@ -40,4 +41,10 @@ export const dataTableColumns: Array> = [ accessorKey: 'isVerified', enableSorting: false, }, + columnHelper.display({ + header: '', + enableSorting: false, + id: 'actions', + cell: (props) => props.row.original.status === 'PENDING' ? : null, + }), ]; From 7d27ecd498c99242a9028503dade1f87f0f41e96 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 9 Jul 2026 13:05:50 -0400 Subject: [PATCH 4/6] feat(clusters): surface container ops on the overview + card status/safe-mode UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/features/cluster/ClusterHome.tsx | 2 + .../clusters/components/ClusterCard.tsx | 47 ++++- .../clusters/components/ClusterStateMenu.tsx | 174 ++++++++++++++++++ .../components/SafeModeConfirmDialog.tsx | 66 +++++++ 4 files changed, 283 insertions(+), 6 deletions(-) create mode 100644 src/features/clusters/components/ClusterStateMenu.tsx create mode 100644 src/features/clusters/components/SafeModeConfirmDialog.tsx diff --git a/src/features/cluster/ClusterHome.tsx b/src/features/cluster/ClusterHome.tsx index 7703eca0c..ea14755b1 100644 --- a/src/features/cluster/ClusterHome.tsx +++ b/src/features/cluster/ClusterHome.tsx @@ -6,6 +6,7 @@ import { getInstanceClient } from '@/config/getInstanceClient'; import { authStore } from '@/features/auth/store/authStore'; import { ClusterPageLayout } from '@/features/cluster/components/ClusterPageLayout'; import { getClusterInfoQueryOptions } from '@/features/cluster/queries/getClusterInfoQuery'; +import { ClusterStateMenu } from '@/features/clusters/components/ClusterStateMenu'; import { useInstanceAuth } from '@/hooks/useAuth'; import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; @@ -174,6 +175,7 @@ export function ClusterHome() {
+
{isLoading ? : connected diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index d979b7c1a..39d297570 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -12,6 +12,7 @@ import { getClusterInfo } from '@/features/cluster/queries/getClusterInfoQuery'; import { ClusterCardAction } from '@/features/clusters/components/ClusterCardAction'; import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals'; import { ClusterProgress } from '@/features/clusters/components/ClusterProgress'; +import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster'; import { useInstanceAuth } from '@/hooks/useAuth'; import { useClusterContainerOps } from '@/hooks/useClusterContainerOps'; @@ -36,6 +37,7 @@ import { GlobeIcon, KeyIcon, LifeBuoyIcon, + Loader2, PlayIcon, RocketIcon, RotateCwIcon, @@ -62,6 +64,7 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { const [isTerminateClusterModalOpen, setIsTerminateClusterModalOpen] = useState(false); const [stopConfirmOpen, setStopConfirmOpen] = useState(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null); const { run: runClusterOp, isPending: isClusterOpPending } = useClusterContainerOps(cluster); // Container-op availability by cluster state (see the per-instance menu for the same idea): @@ -71,6 +74,13 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { const isClusterStopped = cluster.status === 'STOPPED'; const isClusterPartial = cluster.status === 'PARTIAL'; + // Temporary status badge on the card for container-op states. Transitional states tell the user + // what's happening (Stopping/Starting/Restarting) and clear on their own as the clusters list + // polls; STOPPED/PARTIAL are resting labels. RUNNING stays clean (no badge) on this route. + const isClusterTransitioning = cluster.status === 'STOPPING' || cluster.status === 'STARTING' + || cluster.status === 'RESTARTING'; + const showContainerOpBadge = isClusterTransitioning || isClusterStopped || isClusterPartial; + const isActive = useMemo( () => !!(cluster.status && activeClusterStatuses.includes(cluster.status)), [cluster.status], @@ -238,15 +248,14 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { update && (isClusterStopped || isClusterPartial) && { key: 'container-start', disabled: isClusterOpPending, - onClick: () => void runClusterOp('start', { strategy: 'parallel' }), + onClick: () => void runClusterOp('start', { safeMode: false, strategy: 'parallel' }), icon: , label: 'Start', }, update && isClusterStopped && { key: 'container-start-safe', disabled: isClusterOpPending, - onClick: () => - void runClusterOp('start', { safeMode: true, strategy: 'parallel', label: 'Starting in safe mode' }), + onClick: () => setSafeModeAction('start'), icon: , label: 'Start in safe mode', }, @@ -260,8 +269,7 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { update && isClusterRunning && { key: 'container-restart-safe', disabled: isClusterOpPending, - onClick: () => - void runClusterOp('restart', { safeMode: true, strategy: 'parallel', label: 'Restarting in safe mode' }), + onClick: () => setSafeModeAction('restart'), icon: , label: 'Restart in safe mode', }, @@ -365,6 +373,12 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { {isActive && view && } + {showContainerOpBadge && cluster.status && ( + + {isClusterTransitioning && } + {capitalizeWords(cluster.status)} + + )} {clusterHasFailed && cluster.status && ( <> {capitalizeWords(cluster.status)} @@ -398,7 +412,28 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { setRestartOpen={setRestartDialogOpen} onConfirmRestart={(strategy: ContainerStrategy) => { setRestartDialogOpen(false); - void runClusterOp('restart', { strategy }); + void runClusterOp('restart', { safeMode: false, strategy }); + }} + /> + + { + if (!isOpen) { setSafeModeAction(null); } + }} + action={safeModeAction ?? 'restart'} + clusterName={cluster.name} + isPending={isClusterOpPending} + onConfirm={() => { + const action = safeModeAction; + setSafeModeAction(null); + if (action) { + void runClusterOp(action, { + safeMode: true, + strategy: 'parallel', + label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode', + }); + } }} /> diff --git a/src/features/clusters/components/ClusterStateMenu.tsx b/src/features/clusters/components/ClusterStateMenu.tsx new file mode 100644 index 000000000..771f60bbe --- /dev/null +++ b/src/features/clusters/components/ClusterStateMenu.tsx @@ -0,0 +1,174 @@ +import { ConfirmDeletionModal } from '@/components/ConfirmDeletionModal'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdownMenu'; +import { ClusterContainerOpModals } from '@/features/clusters/components/ClusterContainerOpModals'; +import { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; +import { useTerminateClusterMutation } from '@/features/clusters/mutations/terminateCluster'; +import { useClusterContainerOps } from '@/hooks/useClusterContainerOps'; +import { useOrganizationClusterPermissions } from '@/hooks/usePermissions'; +import { Cluster } from '@/integrations/api/api.patch'; +import { ContainerStrategy } from '@/integrations/api/cluster/containerOperation'; +import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; +import { useQueryClient } from '@tanstack/react-query'; +import { useRouter } from '@tanstack/react-router'; +import { ChevronDown, LifeBuoyIcon, PlayIcon, RotateCwIcon, SquareIcon, TrashIcon } from 'lucide-react'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; + +/** + * "Cluster actions" dropdown (AWS EC2 "Instance state" style) for the cluster overview: every + * container lifecycle op plus Terminate in one discoverable, labeled place. Ops that don't apply to + * the current status are shown but disabled, so users can see the feature set exists. Self-hosted + * clusters have no container ops, so the control is hidden for them. + */ +export function ClusterStateMenu({ cluster }: { cluster: Cluster }) { + const router = useRouter(); + const queryClient = useQueryClient(); + const { update, remove } = useOrganizationClusterPermissions(cluster.organizationId, cluster.id); + const { run: runClusterOp, isPending } = useClusterContainerOps(cluster); + const { mutate: terminateCluster, isPending: isTerminatePending } = useTerminateClusterMutation(); + + const [stopOpen, setStopOpen] = useState(false); + const [restartOpen, setRestartOpen] = useState(false); + const [terminateOpen, setTerminateOpen] = useState(false); + // Both safe-mode ops route through one explain-and-confirm dialog; this tracks which. + const [safeModeAction, setSafeModeAction] = useState<'start' | 'restart' | null>(null); + + const isRunning = cluster.status === 'RUNNING'; + const isStopped = cluster.status === 'STOPPED'; + const isPartial = cluster.status === 'PARTIAL'; + const opsDisabled = !update || isPending; + + const onTerminate = useCallback(() => { + terminateCluster(cluster.id, { + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: [cluster.organizationId], refetchType: 'active' }); + setTerminateOpen(false); + toast.success('Success', { + description: 'Cluster successfully terminated.', + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + // The overview for a terminated cluster is a dead end — return to the clusters list. + void router.navigate({ to: `/${cluster.organizationId}` }); + }, + onError: () => { + toast.error('Error', { + description: `Failed to terminate cluster: ${cluster.name}.`, + action: { label: 'Dismiss', onClick: () => toast.dismiss() }, + }); + setTerminateOpen(false); + }, + }); + }, [cluster.id, cluster.name, cluster.organizationId, terminateCluster, queryClient, router]); + + const onConfirmSafeMode = useCallback(() => { + const action = safeModeAction; + setSafeModeAction(null); + if (action) { + void runClusterOp(action, { + safeMode: true, + strategy: 'parallel', + label: action === 'start' ? 'Starting in safe mode' : 'Restarting in safe mode', + }); + } + }, [safeModeAction, runClusterOp]); + + // Container ops are managed-cluster only; a user with neither permission gets no control. + if (clusterIsSelfManaged(cluster) || (!update && !remove)) { return null; } + + return ( + <> + + + + + + Container + void runClusterOp('start', { safeMode: false, strategy: 'parallel' })} + > + Start + + setSafeModeAction('start')}> + Start in safe mode + + setRestartOpen(true)} + > + Restart + + setSafeModeAction('restart')}> + Restart in safe mode + + setStopOpen(true)} + > + Stop + + + setTerminateOpen(true)}> + Terminate + + + + + { + setStopOpen(false); + void runClusterOp('stop', { strategy: 'parallel' }); + }} + restartOpen={restartOpen} + setRestartOpen={setRestartOpen} + onConfirmRestart={(strategy: ContainerStrategy) => { + setRestartOpen(false); + void runClusterOp('restart', { safeMode: false, strategy }); + }} + /> + + { + if (!isOpen) { setSafeModeAction(null); } + }} + action={safeModeAction ?? 'restart'} + clusterName={cluster.name} + isPending={isPending} + onConfirm={onConfirmSafeMode} + /> + + + + ); +} diff --git a/src/features/clusters/components/SafeModeConfirmDialog.tsx b/src/features/clusters/components/SafeModeConfirmDialog.tsx new file mode 100644 index 000000000..c6449dc61 --- /dev/null +++ b/src/features/clusters/components/SafeModeConfirmDialog.tsx @@ -0,0 +1,66 @@ +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { LifeBuoyIcon } from 'lucide-react'; + +/** + * Explain-and-confirm dialog for the safe-mode ops. "Safe mode" is jargon and a recovery action, so + * rather than a hover tooltip (hover-only, easy to miss) we explain what it does at the moment of + * use. Shared by the start-in-safe-mode and restart-in-safe-mode entries. + */ +export function SafeModeConfirmDialog({ + open, + setOpen, + action, + clusterName, + isPending, + onConfirm, +}: { + open: boolean; + setOpen: (open: boolean) => void; + action: 'start' | 'restart'; + clusterName: string; + isPending: boolean; + onConfirm: () => void; +}) { + const verb = action === 'start' ? 'Start' : 'Restart'; + return ( + + + + + + {verb} {clusterName} in safe mode? + + + Safe mode boots Harper{' '} + + without loading your applications or components + . Use it to recover a cluster that a bad app, component, or config is keeping from starting. Then + fix or remove what's broken and {action === 'start' ? 'start' : 'restart'}{' '} + normally to bring everything back. + + +

+ All instances are {action === 'start' ? 'started' : 'restarted'} at once (parallel){action === 'restart' + ? ', so expect a brief interruption while they come back.' + : '.'} +

+ +
+ + +
+
+
+
+ ); +} From 19918fa9110ff21bce0b776f130057d504a9e9bd Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 9 Jul 2026 13:20:01 -0400 Subject: [PATCH 5/6] =?UTF-8?q?fix(studio):=20address=20Gemini=20review=20?= =?UTF-8?q?=E2=80=94=20drop=20double-toasts,=20guard=20clusterId,=20a11y?= =?UTF-8?q?=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 + @@ -67,7 +67,7 @@ export function ClusterContainerOpModals({ 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" + 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" >
Rolling (recommended) @@ -80,7 +80,7 @@ export function ClusterContainerOpModals({ 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" + 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" >
Parallel
@@ -89,7 +89,7 @@ export function ClusterContainerOpModals({
- + diff --git a/src/features/clusters/components/SafeModeConfirmDialog.tsx b/src/features/clusters/components/SafeModeConfirmDialog.tsx index c6449dc61..a8edd4e60 100644 --- a/src/features/clusters/components/SafeModeConfirmDialog.tsx +++ b/src/features/clusters/components/SafeModeConfirmDialog.tsx @@ -54,7 +54,7 @@ export function SafeModeConfirmDialog({

- + diff --git a/src/features/organization/users/components/ResendInviteButton.tsx b/src/features/organization/users/components/ResendInviteButton.tsx index 52f4481c1..ff9c1e848 100644 --- a/src/features/organization/users/components/ResendInviteButton.tsx +++ b/src/features/organization/users/components/ResendInviteButton.tsx @@ -24,11 +24,12 @@ export function ResendInviteButton({ user }: { user: SchemaUser }) { toast.error('Cannot resend invite: this user has no email or role.'); return; } + // The global MutationCache.onError (react-query/queryClient) already surfaces failures with + // the server's message, so only the success toast is local here — avoids double-toasting. inviteUser( { email: user.email, roleId }, { onSuccess: () => toast.success(`Invitation resent to ${user.email}.`), - onError: () => toast.error(`Failed to resend invitation to ${user.email}.`), }, ); }, diff --git a/src/hooks/useClusterContainerOps.ts b/src/hooks/useClusterContainerOps.ts index 0f09a32a1..a87d8d103 100644 --- a/src/hooks/useClusterContainerOps.ts +++ b/src/hooks/useClusterContainerOps.ts @@ -45,11 +45,9 @@ export function useClusterContainerOps(cluster: Cluster) { action: { label: 'Dismiss', onClick: () => toast.dismiss() }, }); } catch { + // Just drop the loading toast; the global MutationCache.onError already shows the error + // (with the server's message), so a local error toast here would double up. toast.dismiss(toastId); - toast.error('Error', { - description: `Failed to ${action} cluster ${cluster.name}.`, - action: { label: 'Dismiss', onClick: () => toast.dismiss() }, - }); } }, [cluster.id, cluster.name, cluster.organizationId, mutateAsync, queryClient], diff --git a/src/hooks/useInstanceContainerOps.ts b/src/hooks/useInstanceContainerOps.ts index 57f86e31d..2a6a072a6 100644 --- a/src/hooks/useInstanceContainerOps.ts +++ b/src/hooks/useInstanceContainerOps.ts @@ -40,7 +40,10 @@ export function useInstanceContainerOps(instance: Instance) { }); try { await mutateAsync({ instanceId: instance.id, action, safeMode: opts?.safeMode }); - void queryClient.invalidateQueries({ queryKey: [clusterId] }); + // clusterId is optional (useParams strict:false); guard so we don't invalidate [undefined]. + if (clusterId) { + void queryClient.invalidateQueries({ queryKey: [clusterId] }); + } void queryClient.invalidateQueries({ queryKey: [instance.id] }); toast.dismiss(toastId); toast.success('Request accepted', { @@ -48,11 +51,9 @@ export function useInstanceContainerOps(instance: Instance) { action: { label: 'Dismiss', onClick: () => toast.dismiss() }, }); } catch { + // Just drop the loading toast; the global MutationCache.onError already shows the error + // (with the server's message), so a local error toast here would double up. toast.dismiss(toastId); - toast.error('Error', { - description: `Failed to ${action} ${target}.`, - action: { label: 'Dismiss', onClick: () => toast.dismiss() }, - }); } }, [clusterId, instance.id, instance.name, mutateAsync, queryClient], From 57a005857355a67a95f5bf62db89d31d77b21936 Mon Sep 17 00:00:00 2001 From: David C Date: Thu, 9 Jul 2026 15:03:27 -0400 Subject: [PATCH 6/6] fix(studio): drop terminate onError double-toast in ClusterStateMenu 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 --- src/features/clusters/components/ClusterStateMenu.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/features/clusters/components/ClusterStateMenu.tsx b/src/features/clusters/components/ClusterStateMenu.tsx index 771f60bbe..4c3fa545c 100644 --- a/src/features/clusters/components/ClusterStateMenu.tsx +++ b/src/features/clusters/components/ClusterStateMenu.tsx @@ -59,14 +59,12 @@ export function ClusterStateMenu({ cluster }: { cluster: Cluster }) { void router.navigate({ to: `/${cluster.organizationId}` }); }, onError: () => { - toast.error('Error', { - description: `Failed to terminate cluster: ${cluster.name}.`, - action: { label: 'Dismiss', onClick: () => toast.dismiss() }, - }); + // The global MutationCache.onError already toasts the failure (with the server's + // message); just close the modal here to avoid double-toasting. setTerminateOpen(false); }, }); - }, [cluster.id, cluster.name, cluster.organizationId, terminateCluster, queryClient, router]); + }, [cluster.id, cluster.organizationId, terminateCluster, queryClient, router]); const onConfirmSafeMode = useCallback(() => { const action = safeModeAction;