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..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'; @@ -17,7 +18,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 +129,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 +147,7 @@ export function ClusterHome() {

{cluster.name}

+ {allInSafeMode && }
{cluster.instances?.length ?? 0} instances @@ -154,6 +175,7 @@ export function ClusterHome() {
+ {isLoading ? : connected @@ -384,18 +406,35 @@ 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'} ); } +// 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/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..39d297570 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -10,13 +10,17 @@ 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 { SafeModeConfirmDialog } from '@/features/clusters/components/SafeModeConfirmDialog'; 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 +36,14 @@ import { GitGraphIcon, GlobeIcon, KeyIcon, + LifeBuoyIcon, + Loader2, + PlayIcon, RocketIcon, + RotateCwIcon, ScaleIcon, ServerIcon, + SquareIcon, TrashIcon, } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; @@ -53,6 +62,24 @@ 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 [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): + // 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'; + + // 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)), @@ -137,7 +164,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}` @@ -205,6 +241,47 @@ 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', { safeMode: false, strategy: 'parallel' }), + icon: , + label: 'Start', + }, + update && isClusterStopped && { + key: 'container-start-safe', + disabled: isClusterOpPending, + onClick: () => setSafeModeAction('start'), + 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: () => setSafeModeAction('restart'), + 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', @@ -296,6 +373,12 @@ export function ClusterCard({ cluster }: { cluster: Cluster }) { {isActive && view && } + {showContainerOpBadge && cluster.status && ( + + {isClusterTransitioning && } + {capitalizeWords(cluster.status)} + + )} {clusterHasFailed && cluster.status && ( <> {capitalizeWords(cluster.status)} @@ -315,6 +398,44 @@ 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', { 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/ClusterContainerOpModals.tsx b/src/features/clusters/components/ClusterContainerOpModals.tsx new file mode 100644 index 000000000..5c72ce3db --- /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/features/clusters/components/ClusterStateMenu.tsx b/src/features/clusters/components/ClusterStateMenu.tsx new file mode 100644 index 000000000..4c3fa545c --- /dev/null +++ b/src/features/clusters/components/ClusterStateMenu.tsx @@ -0,0 +1,172 @@ +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: () => { + // 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.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..a8edd4e60 --- /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.' + : '.'} +

+ +
+ + +
+
+
+
+ ); +} diff --git a/src/features/organization/users/components/ResendInviteButton.tsx b/src/features/organization/users/components/ResendInviteButton.tsx new file mode 100644 index 000000000..ff9c1e848 --- /dev/null +++ b/src/features/organization/users/components/ResendInviteButton.tsx @@ -0,0 +1,50 @@ +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; + } + // 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}.`), + }, + ); + }, + [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, + }), ]; diff --git a/src/hooks/useClusterContainerOps.ts b/src/hooks/useClusterContainerOps.ts new file mode 100644 index 000000000..a87d8d103 --- /dev/null +++ b/src/hooks/useClusterContainerOps.ts @@ -0,0 +1,57 @@ +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 { + // 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); + } + }, + [cluster.id, cluster.name, cluster.organizationId, mutateAsync, queryClient], + ); + + return { run, isPending }; +} diff --git a/src/hooks/useInstanceContainerOps.ts b/src/hooks/useInstanceContainerOps.ts new file mode 100644 index 000000000..2a6a072a6 --- /dev/null +++ b/src/hooks/useInstanceContainerOps.ts @@ -0,0 +1,63 @@ +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 }); + // 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', { + description: `${target} is ${gerund.toLowerCase()}. The status will update automatically.`, + 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); + } + }, + [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..eb1defb5c 100644 --- a/src/integrations/api/api.patch.d.ts +++ b/src/integrations/api/api.patch.d.ts @@ -123,11 +123,16 @@ export type LocalRoleAttributePermissionAction = keyof 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 }); +} diff --git a/src/integrations/api/instance/containerOperation.ts b/src/integrations/api/instance/containerOperation.ts new file mode 100644 index 000000000..cfd0ca9a3 --- /dev/null +++ b/src/integrations/api/instance/containerOperation.ts @@ -0,0 +1,48 @@ +import { apiClient } from '@/config/apiClient'; +import { useMutation } from '@tanstack/react-query'; + +export type ContainerAction = 'stop' | 'start' | 'restart'; + +export interface InstanceContainerOperationParams { + instanceId: string; + action: ContainerAction; + /** Only meaningful for start/restart; omit for stop. Explicit `false` exits safe mode. */ + safeMode?: boolean; +} + +export interface InstanceContainerOperationResponse { + instanceId: string; + action: ContainerAction; + /** Transitional status returned immediately (STOPPING / STARTING / RESTARTING). */ + status: string; + message: string; +} + +/** + * Dispatch a container lifecycle op (stop/start/restart) to an instance via the central manager: + * POST /HDBInstance/{id}/container/{action}. + * + * This is a DIFFERENT class from the proxied Harper `restart` operation (which goes through the + * instance ops API and needs Harper to be up). Container ops are handled host-side and support + * `safeMode` (boot without loading user apps/components). The op is async: the endpoint returns a + * transitional status immediately and the resting status lands later — the instances-page poll + * reflects it. + * + * NOTE: these endpoints aren't in the generated OpenAPI/SDK yet, so the URL is cast past the + * typed-path check (same pattern as terminateCluster). Replace the cast with the generated path + * once the CM OpenAPI exposes /HDBInstance/{id}/container/{action}. + */ +export async function instanceContainerOperation( + { instanceId, action, safeMode }: InstanceContainerOperationParams, +): Promise { + 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 }); +}