diff --git a/frontend-modern/src/components/Dashboard/GuestRow.tsx b/frontend-modern/src/components/Dashboard/GuestRow.tsx index 408aa2b..d14b8f5 100644 --- a/frontend-modern/src/components/Dashboard/GuestRow.tsx +++ b/frontend-modern/src/components/Dashboard/GuestRow.tsx @@ -5,7 +5,8 @@ import { MetricBar } from './MetricBar'; import { IOMetric } from './IOMetric'; import { TagBadges } from './TagBadges'; import { DiskList } from './DiskList'; -import { isGuestRunning } from '@/utils/status'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getGuestPowerIndicator, isGuestRunning } from '@/utils/status'; import { GuestMetadataAPI } from '@/api/guestMetadata'; import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; @@ -334,6 +335,7 @@ export function GuestRow(props: GuestRowProps) { const parentOnline = createMemo(() => props.parentNodeOnline !== false); const isRunning = createMemo(() => isGuestRunning(props.guest, parentOnline())); + const guestStatus = createMemo(() => getGuestPowerIndicator(props.guest, parentOnline())); const lockLabel = createMemo(() => (props.guest.lock || '').trim()); // Get helpful tooltip for disk status @@ -425,96 +427,106 @@ export function GuestRow(props: GuestRowProps) { {/* Name - Sticky column */}
- {/* Name - show input when editing, otherwise show name with optional link */} - - - {props.guest.name} - - - event.stopPropagation()} - > - + + - } - > -
- { - editingValues.set(guestId(), e.currentTarget.value); - setEditingValuesVersion(v => v + 1); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); - saveUrl(); - } else if (e.key === 'Escape') { - e.preventDefault(); - cancelEditingUrl(); - } - }} - onClick={(e) => e.stopPropagation()} - placeholder="https://192.168.1.100:8006" - class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
+ } > - ✓ - - +
+ { + editingValues.set(guestId(), e.currentTarget.value); + setEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://192.168.1.100:8006" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + + +
+
- + {/* Tag badges - hide when editing URL to save space */} diff --git a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx index 2e3bd18..43b590c 100644 --- a/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx +++ b/frontend-modern/src/components/Docker/DockerHostSummaryTable.tsx @@ -7,6 +7,8 @@ import { resolveHostRuntime } from './runtimeDisplay'; import { formatPercent, formatUptime } from '@/utils/format'; import { ScrollableTable } from '@/components/shared/ScrollableTable'; import { buildMetricKey } from '@/utils/metricsKeys'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getDockerHostStatusIndicator } from '@/utils/status'; export interface DockerHostSummary { host: DockerHost; @@ -243,6 +245,7 @@ export const DockerHostSummaryTable: Component = (p const runtimeInfo = resolveHostRuntime(summary.host); const runtimeVersion = summary.host.runtimeVersion || summary.host.dockerVersion; const metricsKey = buildMetricKey('dockerHost', summary.host.id); + const hostStatus = createMemo(() => getDockerHostStatusIndicator(summary.host)); return ( = (p onClick={() => props.onSelect(summary.host.id)} > -
+
+ {getDisplayName(summary.host)} diff --git a/frontend-modern/src/components/Docker/DockerHosts.tsx b/frontend-modern/src/components/Docker/DockerHosts.tsx index 62be288..fcda3f5 100644 --- a/frontend-modern/src/components/Docker/DockerHosts.tsx +++ b/frontend-modern/src/components/Docker/DockerHosts.tsx @@ -13,16 +13,7 @@ import { formatBytes, formatRelativeTime } from '@/utils/format'; import { DockerMetadataAPI, type DockerMetadata } from '@/api/dockerMetadata'; import { logger } from '@/utils/logger'; import { STORAGE_KEYS } from '@/utils/localStorage'; - -const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); -const DEGRADED_HOST_STATUSES = new Set([ - 'degraded', - 'warning', - 'maintenance', - 'partial', - 'initializing', - 'unknown', -]); +import { DEGRADED_HEALTH_STATUSES, OFFLINE_HEALTH_STATUSES } from '@/utils/status'; type DockerMetadataRecord = Record; @@ -224,10 +215,8 @@ export const DockerHosts: Component = (props) => { if (status === 'all') return true; const normalized = host.status?.toLowerCase() ?? ''; if (status === 'online') return normalized === 'online'; - if (status === 'offline') return OFFLINE_HOST_STATUSES.has(normalized); - if (status === 'degraded') { - return DEGRADED_HOST_STATUSES.has(normalized) || normalized === 'degraded'; - } + if (status === 'offline') return OFFLINE_HEALTH_STATUSES.has(normalized); + if (status === 'degraded') return DEGRADED_HEALTH_STATUSES.has(normalized); return true; }; diff --git a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx index 40eb474..49f8662 100644 --- a/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx +++ b/frontend-modern/src/components/Docker/DockerUnifiedTable.tsx @@ -11,27 +11,16 @@ import { resolveHostRuntime } from './runtimeDisplay'; import { showSuccess, showError } from '@/utils/toast'; import { logger } from '@/utils/logger'; import { buildMetricKey } from '@/utils/metricsKeys'; - -const OFFLINE_HOST_STATUSES = new Set(['offline', 'error', 'unreachable', 'down', 'disconnected']); -const DEGRADED_HOST_STATUSES = new Set([ - 'degraded', - 'warning', - 'maintenance', - 'partial', - 'initializing', - 'unknown', -]); - -const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); -const ERROR_CONTAINER_STATES = new Set([ - 'restarting', - 'dead', - 'removing', - 'failed', - 'error', - 'oomkilled', - 'unhealthy', -]); +import { StatusDot } from '@/components/shared/StatusDot'; +import { + DEGRADED_HEALTH_STATUSES, + ERROR_CONTAINER_STATES, + OFFLINE_HEALTH_STATUSES, + STOPPED_CONTAINER_STATES, + getDockerContainerStatusIndicator, + getDockerHostStatusIndicator, + getDockerServiceStatusIndicator, +} from '@/utils/status'; const typeBadgeClass = (type: 'container' | 'service' | 'task' | 'unknown') => { switch (type) { @@ -347,10 +336,10 @@ const hostMatchesFilter = (filter: StatsFilter, host: DockerHost) => { if (!filter || filter.type !== 'host-status') return true; const status = toLower(host.status); if (filter.value === 'offline') { - return OFFLINE_HOST_STATUSES.has(status); + return OFFLINE_HEALTH_STATUSES.has(status); } if (filter.value === 'degraded') { - return DEGRADED_HOST_STATUSES.has(status) || status === 'degraded'; + return DEGRADED_HEALTH_STATUSES.has(status); } if (filter.value === 'online') { return status === 'online'; @@ -548,10 +537,21 @@ const GROUPED_RESOURCE_INDENT = 'pl-5 sm:pl-6 lg:pl-8'; const DockerHostGroupHeader: Component<{ host: DockerHost; colspan: number }> = (props) => { const displayName = props.host.customDisplayName || props.host.displayName || props.host.hostname || props.host.id; + const hostStatus = () => getDockerHostStatusIndicator(props.host); + const isOnline = () => hostStatus().variant === 'success'; return ( -
+
+ {displayName} @@ -858,6 +858,7 @@ const DockerContainerRow: Component<{ } return 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'; }; + const containerStatusIndicator = createMemo(() => getDockerContainerStatusIndicator(container)); const statusLabel = () => { if (health()) { @@ -883,108 +884,116 @@ const DockerContainerRow: Component<{ >
- {/* Name - show input when editing, otherwise show name with optional link */} - - - {container.name || container.id} - - - {(name) => ( - - Pod: {name()} - - - infra - - - - )} - - - event.stopPropagation()} + + - } - > -
- { - dockerEditingValues.set(resourceId(), e.currentTarget.value); - setDockerEditingValuesVersion(v => v + 1); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); + + + + + +
+ } + > +
+ { + dockerEditingValues.set(resourceId(), e.currentTarget.value); + setDockerEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + - -
-
+ }} + class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" + title="Save (or press Enter)" + > + ✓ + + +
+
+
@@ -1607,6 +1616,7 @@ const DockerServiceRow: Component<{ const running = service.runningTasks ?? 0; return desired > 0 && running >= desired; }; + const serviceStatusIndicator = createMemo(() => getDockerServiceStatusIndicator(service)); const serviceTitle = () => { const primary = service.name || service.id || 'Service'; @@ -1629,101 +1639,109 @@ const DockerServiceRow: Component<{ >
- {/* Name - show input when editing, otherwise show name with optional link */} - - - {service.name || service.id || 'Service'} - - - event.stopPropagation()} + +
+ {/* Name - show input when editing, otherwise show name with optional link */} + + - - - - - - - - Stack: {service.stack} + {service.name || service.id || 'Service'} - -
- } - > -
- { - dockerEditingValues.set(resourceId(), e.currentTarget.value); - setDockerEditingValuesVersion(v => v + 1); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.preventDefault(); + + event.stopPropagation()} + > + + + + + + + + Stack: {service.stack} + + +
+ } + > +
+ { + dockerEditingValues.set(resourceId(), e.currentTarget.value); + setDockerEditingValuesVersion(v => v + 1); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + saveUrl(); + } else if (e.key === 'Escape') { + e.preventDefault(); + cancelEditingUrl(); + } + }} + onClick={(e) => e.stopPropagation()} + placeholder="https://example.com:8080" + class="flex-1 min-w-0 px-2 py-0.5 text-sm border border-blue-500 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + - -
-
+ }} + class="flex-shrink-0 w-6 h-6 flex items-center justify-center text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors" + title="Save (or press Enter)" + > + ✓ + + +
+ +
diff --git a/frontend-modern/src/components/Hosts/HostsOverview.tsx b/frontend-modern/src/components/Hosts/HostsOverview.tsx index d554e04..19b95d9 100644 --- a/frontend-modern/src/components/Hosts/HostsOverview.tsx +++ b/frontend-modern/src/components/Hosts/HostsOverview.tsx @@ -9,6 +9,8 @@ import { EmptyState } from '@/components/shared/EmptyState'; import { MetricBar } from '@/components/Dashboard/MetricBar'; import { HostsFilter } from './HostsFilter'; import { useWebSocket } from '@/App'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getHostStatusIndicator } from '@/utils/status'; // Global drawer state to persist across re-renders const drawerState = new Map(); @@ -18,22 +20,6 @@ interface HostsOverviewProps { connectionHealth: Record; } -const renderStatusIndicator = (status: string | undefined) => { - const normalized = (status || 'offline').toLowerCase(); - - const indicatorStyles: Record = { - online: 'bg-green-500', - degraded: 'bg-amber-500', - offline: 'bg-red-500', - }; - - const style = indicatorStyles[normalized] || indicatorStyles.offline; - - return ( -
- ); -}; - export const HostsOverview: Component = (props) => { const navigate = useNavigate(); const wsContext = useWebSocket(); @@ -213,6 +199,7 @@ export const HostsOverview: Component = (props) => { const memPercent = () => host.memory?.usage ?? 0; const memUsed = () => formatBytes(host.memory?.used ?? 0, 0); const memTotal = () => formatBytes(host.memory?.total ?? 0, 0); + const hostStatus = createMemo(() => getHostStatusIndicator(host)); // Drawer state const [drawerOpen, setDrawerOpen] = createSignal(drawerState.get(host.id) ?? false); @@ -269,7 +256,12 @@ export const HostsOverview: Component = (props) => {
- {renderStatusIndicator(host.status)} +

{host.displayName || host.hostname || host.id}

diff --git a/frontend-modern/src/components/shared/NodeGroupHeader.tsx b/frontend-modern/src/components/shared/NodeGroupHeader.tsx index ae1d6f9..639e644 100644 --- a/frontend-modern/src/components/shared/NodeGroupHeader.tsx +++ b/frontend-modern/src/components/shared/NodeGroupHeader.tsx @@ -1,6 +1,8 @@ import { Component, Show } from 'solid-js'; import type { Node } from '@/types/api'; import { getNodeDisplayName, hasAlternateDisplayName } from '@/utils/nodes'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { getNodeStatusIndicator } from '@/utils/status'; interface NodeGroupHeaderProps { node: Node; @@ -8,7 +10,8 @@ interface NodeGroupHeaderProps { } export const NodeGroupHeader: Component = (props) => { - const isOnline = () => props.node.status === 'online' && (props.node.uptime || 0) > 0; + const nodeStatus = () => getNodeStatusIndicator(props.node); + const isOnline = () => nodeStatus().variant === 'success'; const nodeUrl = () => props.node.guestURL || props.node.host || `https://${props.node.name}:8006`; const displayName = () => getNodeDisplayName(props.node); const showActualName = () => hasAlternateDisplayName(props.node); @@ -20,11 +23,15 @@ export const NodeGroupHeader: Component = (props) => { class="py-1 pr-2 pl-4 text-[12px] sm:text-sm font-semibold text-slate-700 dark:text-slate-100" >
+ = (props) => { const pbs = isPBS ? (item.data as PBSInstance) : null; const online = isItemOnline(item); + const statusIndicator = createMemo(() => + isPVE ? getNodeStatusIndicator(node as Node) : getPBSStatusIndicator(pbs as PBSInstance), + ); const cpuPercentValue = getCpuPercent(item); const memoryPercentValue = getMemoryPercent(item); const diskPercentValue = getDiskPercent(item); @@ -508,7 +513,13 @@ export const NodeSummaryTable: Component = (props) => { -
+
+ = { + success: 'bg-emerald-500 dark:bg-emerald-400', + warning: 'bg-amber-500 dark:bg-amber-400', + danger: 'bg-red-500 dark:bg-red-400', + muted: 'bg-gray-400 dark:bg-gray-500', +}; + +const SIZE_CLASSES: Record = { + xs: 'h-1.5 w-1.5', + sm: 'h-2 w-2', + md: 'h-2.5 w-2.5', +}; + +export function StatusDot(props: StatusDotProps): JSX.Element { + const variant = props.variant ?? 'muted'; + const size = props.size ?? 'sm'; + const ariaHidden = props.ariaHidden ?? !props.ariaLabel; + + const className = [ + 'inline-block rounded-full flex-shrink-0', + SIZE_CLASSES[size], + VARIANT_CLASSES[variant], + props.pulse ? 'animate-pulse' : '', + props.class ?? '', + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +} + +export default StatusDot; diff --git a/frontend-modern/src/utils/status.ts b/frontend-modern/src/utils/status.ts index b061aae..168b054 100644 --- a/frontend-modern/src/utils/status.ts +++ b/frontend-modern/src/utils/status.ts @@ -1,14 +1,80 @@ -import type { Node, VM, Container } from '@/types/api'; +import type { + Node, + VM, + Container, + PBSInstance, + Host, + DockerHost, + DockerContainer, + DockerService, +} from '@/types/api'; const ONLINE_STATUS = 'online'; const RUNNING_STATUS = 'running'; +const normalize = (value?: string | null): string => (value || '').trim().toLowerCase(); + +const formatStatusLabel = (value?: string | null, fallback = 'Unknown'): string => { + if (!value) return fallback; + const normalized = value.trim(); + if (!normalized) return fallback; + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +}; + +export type StatusIndicatorVariant = 'success' | 'warning' | 'danger' | 'muted'; + +export interface StatusIndicator { + variant: StatusIndicatorVariant; + label: string; +} + +const defaultIndicator: StatusIndicator = { variant: 'muted', label: 'Unknown' }; + +export const OFFLINE_HEALTH_STATUSES = new Set([ + 'offline', + 'error', + 'failed', + 'down', + 'unreachable', + 'disconnected', + 'timeout', + 'stopped', + 'inactive', +]); + +export const DEGRADED_HEALTH_STATUSES = new Set([ + 'degraded', + 'warning', + 'maintenance', + 'syncing', + 'initializing', + 'starting', + 'pending', + 'partial', + 'unknown', + 'recovering', + 'pausing', + 'restarting', +]); + +export const STOPPED_CONTAINER_STATES = new Set(['exited', 'stopped', 'created', 'paused']); +export const ERROR_CONTAINER_STATES = new Set([ + 'restarting', + 'dead', + 'removing', + 'failed', + 'error', + 'oomkilled', + 'unhealthy', +]); + export function isNodeOnline(node: Partial | undefined | null): boolean { if (!node) return false; if (node.status !== ONLINE_STATUS) return false; if ((node.uptime ?? 0) <= 0) return false; const connection = (node as Node).connectionHealth; - if (connection === 'offline' || connection === 'error') return false; + const normalizedConnection = normalize(connection); + if (normalizedConnection === 'offline' || normalizedConnection === 'error') return false; return true; } @@ -20,3 +86,161 @@ export function isGuestRunning( if (!parentNodeOnline) return false; return guest.status === RUNNING_STATUS; } + +export function getNodeStatusIndicator(node: Partial | undefined | null): StatusIndicator { + if (!node) return defaultIndicator; + + const connection = normalize((node as Node).connectionHealth); + const status = normalize(node.status); + const uptime = node.uptime ?? 0; + + if (OFFLINE_HEALTH_STATUSES.has(connection) || OFFLINE_HEALTH_STATUSES.has(status) || uptime <= 0) { + return { variant: 'danger', label: formatStatusLabel(connection || status, 'Offline') }; + } + + if (DEGRADED_HEALTH_STATUSES.has(connection) || DEGRADED_HEALTH_STATUSES.has(status)) { + return { variant: 'warning', label: formatStatusLabel(connection || status, 'Degraded') }; + } + + if (isNodeOnline(node)) { + return { variant: 'success', label: 'Online' }; + } + + return defaultIndicator; +} + +export function getPBSStatusIndicator( + instance: Partial | undefined | null, +): StatusIndicator { + if (!instance) return defaultIndicator; + + const connection = normalize(instance.connectionHealth); + const status = normalize(instance.status); + + if (OFFLINE_HEALTH_STATUSES.has(connection) || OFFLINE_HEALTH_STATUSES.has(status)) { + return { variant: 'danger', label: formatStatusLabel(connection || status, 'Offline') }; + } + + if (status === 'healthy' || status === ONLINE_STATUS) { + return { variant: 'success', label: formatStatusLabel(status, 'Online') }; + } + + if (DEGRADED_HEALTH_STATUSES.has(connection) || DEGRADED_HEALTH_STATUSES.has(status)) { + return { variant: 'warning', label: formatStatusLabel(connection || status, 'Degraded') }; + } + + return defaultIndicator; +} + +export function getGuestPowerIndicator( + guest: Partial | undefined | null, + parentNodeOnline = true, +): StatusIndicator { + if (!guest) return defaultIndicator; + if (!parentNodeOnline) { + return { variant: 'danger', label: 'Node offline' }; + } + return isGuestRunning(guest, parentNodeOnline) + ? { variant: 'success', label: 'Running' } + : { variant: 'danger', label: 'Stopped' }; +} + +export function getHostStatusIndicator(host: Partial | undefined | null): StatusIndicator { + if (!host) return defaultIndicator; + const status = normalize(host.status); + + if (OFFLINE_HEALTH_STATUSES.has(status)) { + return { variant: 'danger', label: formatStatusLabel(status, 'Offline') }; + } + + if (DEGRADED_HEALTH_STATUSES.has(status)) { + return { variant: 'warning', label: formatStatusLabel(status, 'Degraded') }; + } + + if (status === ONLINE_STATUS || status === RUNNING_STATUS) { + return { variant: 'success', label: 'Online' }; + } + + return status + ? { variant: 'muted', label: formatStatusLabel(status, 'Unknown') } + : defaultIndicator; +} + +export function getDockerHostStatusIndicator( + host: Partial | string | undefined | null, +): StatusIndicator { + const rawStatus = typeof host === 'string' ? host : host?.status; + const status = normalize(rawStatus); + + if (OFFLINE_HEALTH_STATUSES.has(status)) { + return { variant: 'danger', label: formatStatusLabel(status, 'Offline') }; + } + + if (DEGRADED_HEALTH_STATUSES.has(status)) { + return { variant: 'warning', label: formatStatusLabel(status, 'Degraded') }; + } + + if (status === ONLINE_STATUS || status === RUNNING_STATUS || status === 'healthy') { + return { variant: 'success', label: formatStatusLabel(status, 'Online') }; + } + + return status + ? { variant: 'muted', label: formatStatusLabel(status, 'Unknown') } + : defaultIndicator; +} + +export function getDockerContainerStatusIndicator( + container: Partial | undefined | null, +): StatusIndicator { + if (!container) return defaultIndicator; + const state = normalize(container.state); + const health = normalize(container.health); + + if (state === RUNNING_STATUS && (!health || health === 'healthy')) { + return { variant: 'success', label: 'Running' }; + } + + if (ERROR_CONTAINER_STATES.has(state) || health === 'unhealthy') { + const label = health === 'unhealthy' ? 'Unhealthy' : formatStatusLabel(state, 'Error'); + return { variant: 'danger', label }; + } + + if (STOPPED_CONTAINER_STATES.has(state)) { + return { variant: 'danger', label: formatStatusLabel(state, 'Stopped') }; + } + + if (!state && health) { + return { variant: 'warning', label: formatStatusLabel(health, 'Unknown') }; + } + + if (state) { + return { variant: 'warning', label: formatStatusLabel(state, 'Unknown') }; + } + + return defaultIndicator; +} + +export function getDockerServiceStatusIndicator( + service: Partial | undefined | null, +): StatusIndicator { + if (!service) return defaultIndicator; + const desired = service.desiredTasks ?? 0; + const running = service.runningTasks ?? 0; + + if (desired <= 0) { + if (running > 0) { + return { variant: 'warning', label: `Running ${running} task${running === 1 ? '' : 's'}` }; + } + return { variant: 'muted', label: 'No tasks' }; + } + + if (running >= desired) { + return { variant: 'success', label: 'Healthy' }; + } + + if (running === 0) { + return { variant: 'danger', label: `Stopped (${running}/${desired})` }; + } + + return { variant: 'warning', label: `Degraded (${running}/${desired})` }; +}