Skip to content
18 changes: 17 additions & 1 deletion src/components/ui/utils/badgeStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function renderBadgeStatusVariant(value: BadgeStatusVariant): BadgeStatus
}
switch (value) {
case 'STOPPED':
return 'secondary';
return 'destructive';
case 'TERMINATING':
case 'TERMINATED':
case 'FAILED':
Expand All @@ -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':
Expand Down
51 changes: 45 additions & 6 deletions src/features/cluster/ClusterHome.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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 (
<ClusterHomeShell>
<div className="flex items-start gap-4 mb-6">
Expand All @@ -127,6 +147,7 @@ export function ClusterHome() {
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-2xl font-light text-foreground">{cluster.name}</h1>
<StatusPill status={cluster.status} />
{allInSafeMode && <SafeModePill />}
</div>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<span>{cluster.instances?.length ?? 0} instances</span>
Expand Down Expand Up @@ -154,6 +175,7 @@ export function ClusterHome() {
</DropdownMenu>
</div>
</div>
<ClusterStateMenu cluster={cluster} />
</div>

{isLoading ? <Spinner /> : connected
Expand Down Expand Up @@ -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 (
<span
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-0.5 rounded-full ${
active ? 'text-green bg-green/10' : 'text-muted-foreground bg-muted'
}`}
>
<span className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-0.5 rounded-full ${colorClass}`}>
<span className="size-1.5 rounded-full bg-current" />
{status ? status.charAt(0) + status.slice(1).toLowerCase() : 'Unknown'}
</span>
);
}

// 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 (
<span
title="All instances running in safe mode — user apps/components are not loaded"
className="inline-flex items-center gap-1.5 text-xs px-2.5 py-0.5 rounded-full text-yellow bg-yellow/10"
>
<LifeBuoy className="size-3" />
Safe mode
</span>
);
}

function ConnectOption(
{ icon: Icon, title, description, pill, children }: {
icon: ComponentType<{ className?: string }>;
Expand Down
7 changes: 7 additions & 0 deletions src/features/cluster/InstanceLogInCell.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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 <span className="text-muted-foreground text-sm">—</span>;
}

if (
instanceAuthIsLoading || !instance.status
|| !['CLONE_READY', 'RUNNING', 'UPDATED', 'PENDING_UPGRADE'].includes(instance.status)
Expand Down
22 changes: 21 additions & 1 deletion src/features/cluster/InstanceStatusCell.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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 (
<div className="flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-block size-4 rounded-full bg-muted-foreground/40" aria-label="Not running" />
</TooltipTrigger>
<TooltipContent>Not running</TooltipContent>
</Tooltip>
</div>
);
}

return (
<div className="flex items-center gap-2">
<Tooltip>
Expand Down
11 changes: 10 additions & 1 deletion src/features/cluster/Instances.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -73,6 +74,14 @@ export function Instances() {
<div className="flex items-center gap-2">
<InstanceStatusCell instance={cell.row.original} index={cell.row.index} />
{status ? <Badge variant={renderBadgeStatusVariant(status)}>{capitalizeWords(status)}</Badge> : null}
{cell.row.original.safeMode && !isStoppedOrTransitioning(cell.row.original.status)
? (
<Badge variant="warning" title="Running in safe mode — user apps/components are not loaded">
<LifeBuoyIcon />
Comment thread
DavidCockerill marked this conversation as resolved.
Safe mode
</Badge>
Comment thread
DavidCockerill marked this conversation as resolved.
)
: null}
</div>
);
},
Expand Down
71 changes: 69 additions & 2 deletions src/features/cluster/useInstanceMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
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';
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';
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'];
Expand All @@ -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({
Expand All @@ -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',
Expand Down Expand Up @@ -110,6 +133,50 @@ export function useInstanceMenuItems(
icon: <ShieldCheckIcon />,
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: <PlayIcon />,
label: 'Start',
},
hasContainerOps && isStopped && {
key: 'container-start-safe',
disabled: isContainerOpPending,
onClick: () => void runContainerOp('start', { safeMode: true, label: 'Starting in safe mode' }),
icon: <LifeBuoyIcon />,
label: 'Start in safe mode',
},
hasContainerOps && isRunning && {
key: 'container-restart',
disabled: isContainerOpPending,
onClick: () => void runContainerOp('restart', { safeMode: false }),
icon: <RotateCwIcon />,
label: 'Restart',
},
hasContainerOps && isRunning && {
key: 'container-restart-safe',
disabled: isContainerOpPending,
onClick: () => void runContainerOp('restart', { safeMode: true, label: 'Restarting in safe mode' }),
icon: <LifeBuoyIcon />,
label: 'Restart in safe mode',
},
hasContainerOps && isRunning && {
key: 'container-stop',
variant: 'destructive' as const,
disabled: isContainerOpPending,
onClick: () => void runContainerOp('stop'),
icon: <SquareIcon />,
label: 'Stop',
},
].filter(excludeFalsy);

if (!actions.length) {
Expand Down
Loading