// Shared building blocks for the stack detail view. Extracted from EditorView so // the desktop two-pane layout and the mobile segmented layout render the exact // same identity header, container health list, and logs pane from one source. import { RotateCw, Play, Square, Terminal, MoreVertical, Trash2, ScrollText, Undo2, Loader2, Check, ShieldCheck, ArrowUpRight, Copy, CloudDownload, ArrowDownToLine, Layers, List, Maximize2, Minimize2, AlertCircle, RefreshCw, HeartPulse, } from 'lucide-react'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from '../ui/button'; import { Skeleton } from '../ui/skeleton'; import { CardTitle } from '../ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { StackMuteSubmenu } from '@/components/mute/MuteMenuItems'; import type { useStackMuteActions } from '@/hooks/useMuteRuleActions'; import { Sparkline } from '../ui/sparkline'; import { ImageSourceMenu } from '../ImageSourceMenu'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { buildServiceUrl } from '@/lib/serviceUrl'; import ErrorBoundary from '../ErrorBoundary'; import TerminalComponent from '../Terminal'; import StructuredLogViewer from '../StructuredLogViewer'; import type { Node } from '@/context/NodeContext'; import type { useAuth } from '@/context/AuthContext'; import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView'; import type { EffectiveServiceSpec } from '@/types/effectiveServices'; import type { StackServiceUpdateStatus } from '@/types/imageUpdates'; import { isConfirmedServiceUpdate } from '@/types/imageUpdates'; const extractUptime = (status: string | undefined): string | null => { if (!status) return null; const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); if (!match) return null; return `up ${match[1].trim()}`; }; const healthcheckLabel = ( health?: 'healthy' | 'unhealthy' | 'starting' | 'none', ): string | null => { if (!health || health === 'none') return null; return health; }; type StackPill = { label: string; dotClass: string; className: string; pulse: boolean; }; const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { if (!containers || containers.length === 0) return null; const running = containers.some(c => c.State === 'running'); if (!running) { return { label: 'exited', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: false, }; } const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); const anyStarting = containers.some(c => c.healthStatus === 'starting'); const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); if (anyUnhealthy) { return { label: 'running · unhealthy', dotClass: 'bg-destructive', className: 'border-destructive/40 bg-destructive/10 text-destructive', pulse: true, }; } if (anyStarting) { return { label: 'running · starting', dotClass: 'bg-warning', className: 'border-warning/40 bg-warning/10 text-warning', pulse: true, }; } if (anyHealthy) { return { label: 'running · healthy', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; } return { label: 'running', dotClass: 'bg-success', className: 'border-success/40 bg-success/10 text-success', pulse: true, }; }; export interface StackIdentityHeaderProps { stackName: string; activeNode: Node | null; safeContainers: ContainerInfo[]; isRunning: boolean; can: ReturnType['can']; trivy: { available: boolean }; backupInfo: { exists: boolean; timestamp: number | null }; loadingAction: StackAction | null; stackMisconfigScanning: boolean; deployStack: (e: React.MouseEvent) => Promise; restartStack: (e: React.MouseEvent) => Promise; stopStack: (e: React.MouseEvent) => Promise; updateStack: (e?: React.MouseEvent) => Promise; rollbackStack: () => Promise; scanStackConfig: () => Promise; requestDeleteStack: () => void; requestTakeDownStack: (stackName: string) => void; showTakeDown: boolean; /** True when this stack is the running Sencho instance on the active node. */ isSelfStack?: boolean; stackMuteActions?: ReturnType; /** Opens the stack Monitor sheet on the Alerts tab. */ onOpenMonitor?: () => void; } // Breadcrumb + serif title + state pill + action bar. The action buttons grow // to a 44px touch target below md without changing desktop. export function StackIdentityHeader({ stackName, activeNode, safeContainers, isRunning, can, trivy, backupInfo, loadingAction, stackMisconfigScanning, deployStack, restartStack, stopStack, updateStack, rollbackStack, scanStackConfig, requestDeleteStack, requestTakeDownStack, showTakeDown, isSelfStack = false, stackMuteActions, onOpenMonitor, }: StackIdentityHeaderProps) { const selfProtected = isSelfStack; return (
{/* Identity block */}
{(activeNode?.name || 'local')} stacks {stackName}
{stackName} {(() => { const pill = getStackStatePill(safeContainers); if (!pill) return null; return ( ); })()}
{/* Action Bar: deploy and delete affordances render against their own backend permissions so a delete-only or deploy-only persona sees exactly what they can act on. */} {(() => { const canDeploy = can('stack:deploy', 'stack', stackName, activeNode?.id); const canDelete = can('stack:delete', 'stack', stackName, activeNode?.id); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && canDeploy; const canMute = stackMuteActions?.canMute ?? false; const hasOverflowExtras = canRollback || canScan; const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor; if (!canDeploy && !hasOverflow) return null; return (
{canDeploy && ( <> {isRunning ? ( ) : ( )} {isRunning && ( )} {isRunning && showTakeDown && ( )} )} {hasOverflow && ( {canRollback && (
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} {backupInfo.timestamp && ( {new Date(backupInfo.timestamp).toLocaleString()} )}
)} {canScan && ( {stackMisconfigScanning ? ( ) : ( )} {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} {onOpenMonitor && ( Monitor )} {stackMuteActions && } {(canRollback || canScan || onOpenMonitor || stackMuteActions?.canMute) && canDelete && } {canDelete && ( {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} )}
)}
); })()}
); } /** Optional per-card Update/Rebuild affordance for flattened single-container * multi-service rows. Pass only from that call site; leave undefined on * multi-replica nested children and the single-service flat path. */ export interface ServiceUpdateAffordance { hasUpdate: boolean; mode: 'update' | 'rebuild'; showUpdateAction: boolean; busy: boolean; replicaCopy: string; onRequest: () => void; } export interface ContainersHealthProps { safeContainers: ContainerInfo[]; containerStats: Record; containerStatsError: string | null; isAdmin: boolean; activeNode: Node | null; openLogViewer: (containerId: string, containerName: string) => void; openBashModal: (containerId: string, containerName: string) => void; /** Opens Monitor (Alerts tab); preselects the Compose service in add forms when listed. */ onOpenServiceMonitor?: (serviceName: string) => void; serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise; // Declared Compose services from the effective model. Multi-service // headers (owning Update/Rebuild + badge + Start/Stop/Restart) render only // when this has more than one entry; empty/single leaves the flat // container-card layout below untouched. Optional so callers that never // deal in services (and existing tests) can omit them. effectiveServices?: EffectiveServiceSpec[]; serviceUpdateStatuses?: StackServiceUpdateStatus[]; serviceUpdateInProgress?: { service: string; mode: 'update' | 'rebuild' } | null; onRequestServiceUpdate?: (serviceName: string, mode: 'update' | 'rebuild') => void; containersExpanded?: boolean; onToggleContainersExpand?: () => void; containersLoadStatus?: 'idle' | 'loading' | 'success' | 'error'; containersLoadError?: string | null; onRetryContainersLoad?: () => void; /** * Soft live-refresh failures exhausted. Shown only when container cards are * visible (containersLoadStatus === 'success'). When status is 'error', the * existing error card Retry is sufficient and this chip is suppressed. */ syncStale?: boolean; onRetrySync?: () => void; } // Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net // sparklines. Row action buttons grow to a 44px touch target below md. export function ContainersHealth({ safeContainers, containerStats, containerStatsError, isAdmin, activeNode, openLogViewer, openBashModal, onOpenServiceMonitor, serviceAction, effectiveServices = [], serviceUpdateStatuses = [], serviceUpdateInProgress = null, onRequestServiceUpdate, containersExpanded, onToggleContainersExpand, containersLoadStatus = 'success', containersLoadError = null, onRetryContainersLoad, syncStale = false, onRetrySync, }: ContainersHealthProps) { // Multi-service only: a single-service stack keeps the existing flat layout // untouched, including its per-container Start/Stop/Restart kebab. const isMultiService = effectiveServices.length > 1; const [copiedUrlId, setCopiedUrlId] = useState(null); const copiedUrlTimerRef = useRef(null); // Compact mode hides sparkline grids across all containers for a denser // list. Detailed mode (default) shows CPU / Mem / Net per container. const [density, setDensity] = useState<'compact' | 'detailed'>( safeContainers.length > 1 ? 'compact' : 'detailed', ); useEffect(() => () => { if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current); }, []); const copyServiceUrl = useCallback((id: string | undefined, url: string) => { void copyToClipboard(url).then(() => { if (!id) return; setCopiedUrlId(id); if (copiedUrlTimerRef.current !== null) window.clearTimeout(copiedUrlTimerRef.current); copiedUrlTimerRef.current = window.setTimeout(() => { setCopiedUrlId(prev => (prev === id ? null : prev)); copiedUrlTimerRef.current = null; }, 1500); }).catch(() => { /* clipboard unavailable */ }); }, []); // Summary strip + density/expand toggles: multi-container stacks only, // whether the body is flat or grouped by declared service. const total = safeContainers.length; const running = safeContainers.filter(c => c.State === 'running').length; const unhealthy = safeContainers.filter(c => c.healthStatus === 'unhealthy').length; const paused = safeContainers.filter(c => c.State === 'paused').length; const densityToolbar = total > 1 ? (
{total} container{total !== 1 ? 's' : ''} {running} up {paused > 0 && {paused} paused} {unhealthy > 0 && {unhealthy} unhealthy}
Compact view Detailed view {onToggleContainersExpand && ( {containersExpanded ? 'Collapse containers' : 'Expand containers'} )}
) : null; // One container card. `hideServiceMenu` drops the per-container // Start/Stop/Restart kebab on multi-replica nested children; the // declared-service header above owns lifecycle actions there. Flattened // single-container multi-service rows pass updateAffordance and keep the // kebab (`hideServiceMenu=false`). Single-service flat rows leave // updateAffordance undefined. const renderServiceUpdateButton = (affordance: ServiceUpdateAffordance) => { if (!affordance.showUpdateAction) return null; return ( {affordance.replicaCopy} ); }; const renderServiceLifecycleMenu = (serviceName: string, isServiceActive: boolean) => ( {isServiceActive ? ( <> serviceAction('restart', serviceName)}> Restart service serviceAction('stop', serviceName)}> Stop service ) : ( serviceAction('start', serviceName)}> Start service )} ); const renderContainerCard = ( container: ContainerInfo, hideServiceMenu: boolean, updateAffordance?: ServiceUpdateAffordance, ) => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; let mainPortProto: string | undefined; // UDP ports are not browser-openable, so they never back a link. const tcpPorts = (container.Ports ?? []).filter(p => p.Type !== 'udp'); if (tcpPorts.length > 0) { const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; const IGNORE_PORTS = [1900, 53, 22]; let match = tcpPorts.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); if (!match) match = tcpPorts.find(p => WEB_UI_PORTS.includes(p.PublicPort)); if (!match) match = tcpPorts.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); const chosen = match || tcpPorts[0]; mainPort = chosen.PublicPort; mainPortPrivate = chosen.PrivatePort; mainPortProto = 'tcp'; } const serviceUrl = mainPort && mainPortPrivate ? buildServiceUrl({ node: activeNode, publicPort: mainPort, privatePort: mainPortPrivate }) : null; const portLabel = mainPort && mainPortPrivate ? `${mainPort} → ${mainPortPrivate}/${mainPortProto}` : ''; const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; const composeService = container.Service; const isActive = container.State === 'running' || container.State === 'paused'; const health = container.healthStatus; const uptime = isActive ? extractUptime(container.Status) : null; const hcLabel = healthcheckLabel(health); const stats = containerStats[container?.Id]; const history = stats?.history; const badgeClass = health === 'unhealthy' || !isActive ? 'bg-destructive text-destructive-foreground' : health === 'starting' ? 'bg-warning text-warning-foreground' : 'bg-success text-success-foreground'; const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓'; const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; return (
{badgeGlyph}
{containerName}
{updateAffordance?.hasUpdate && ( Update )}
{uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} {hcLabel ? <>·{hcLabel} : null} {mainPort && mainPortPrivate ? ( <> · {serviceUrl ? ( <> {portLabel} Copy service URL ) : ( {portLabel} )} ) : null}
{updateAffordance ? renderServiceUpdateButton(updateAffordance) : null} View logs {onOpenServiceMonitor && composeService && ( Monitor {composeService} )} {isAdmin && ( Open bash shell )} {!hideServiceMenu && container.Service && ( renderServiceLifecycleMenu( container.Service, isActive, ) )}
{isActive && density === 'detailed' ? (
{[ { label: 'cpu', value: stats?.cpu ?? '-', points: history?.cpu ?? [] }, { label: 'mem', value: stats?.ram ?? '-', points: history?.mem ?? [] }, { label: 'net i/o', value: stats?.net ?? '-', points: history?.netIn ?? [] }, ].map(({ label, value, points }) => (
{label} {value}
))}
) : null}
); }; const showConfirmedEmpty = containersLoadStatus === 'success' && safeContainers.length === 0; if (containersLoadStatus === 'idle' || containersLoadStatus === 'loading') { return (
); } if (containersLoadStatus === 'error') { return (

{containersLoadError ?? 'Could not load containers.'}

{onRetryContainersLoad && ( )}
); } return (
{containerStatsError && safeContainers.length > 0 && (
Stats unavailable {containerStatsError}
)} {syncStale && onRetrySync && (
Container state may be stale
)} {densityToolbar} {isMultiService ? (
{effectiveServices.map(spec => { const group = safeContainers.filter(c => c.Service === spec.name); const status = serviceUpdateStatuses.find(s => s.service === spec.name); const busy = serviceUpdateInProgress?.service === spec.name; const hasUpdate = status ? isConfirmedServiceUpdate(status) : false; const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update'; // Registry Update only when a check confirmed a pending // image update (clears after a successful recheck). Rebuild // stays available for build-backed services without one. // Stack-level Update in the identity header remains the // always-on full-stack pull path. const showUpdateAction = hasUpdate || spec.hasBuild; const isServiceActive = group.some(c => c.State === 'running' || c.State === 'paused'); const runningCount = group.filter(c => c.State === 'running').length; const replicaWord = spec.expectedReplicas === 1 ? 'replica' : 'replicas'; const replicaCopy = mode === 'rebuild' ? `Rebuilds all ${spec.expectedReplicas} ${replicaWord}` : `Updates all ${spec.expectedReplicas} ${replicaWord}`; const updateAffordance: ServiceUpdateAffordance = { hasUpdate, mode, showUpdateAction, busy, replicaCopy, onRequest: () => onRequestServiceUpdate?.(spec.name, mode), }; // Single-container declared service: one flat card with // Update left of ImageSourceMenu and the lifecycle kebab. if (group.length === 1) { return (
{renderContainerCard(group[0], false, updateAffordance)}
); } // Zero containers: compact row (name + Update + kebab), // not renderContainerCard (no ContainerInfo). if (group.length === 0) { return (
{spec.name} {hasUpdate && ( Update )}
{renderServiceUpdateButton(updateAffordance)} {renderServiceLifecycleMenu(spec.name, false)}
); } // Multi-replica: keep header + nested children (no // updateAffordance on child cards). return (
{spec.name} {runningCount}/{spec.expectedReplicas} running {hasUpdate && ( Update )}
{renderServiceUpdateButton(updateAffordance)} {renderServiceLifecycleMenu(spec.name, isServiceActive)}
{group.map(container => renderContainerCard(container, true))}
); })}
) : showConfirmedEmpty ? (
No containers running for this stack.
) : (
{safeContainers.map(container => renderContainerCard(container, false))}
)}
); } export interface StackLogsSectionProps { stackName: string; logsMode: 'structured' | 'raw'; setLogsMode: (mode: 'structured' | 'raw') => void; /** True when the stack has more than one service or container; gates log chips. */ showServiceChips: boolean; /** When set, the structured viewer shows an expand control that collapses * the Command Center to give the logs more vertical room. */ logsExpanded?: boolean; onToggleLogsExpand?: () => void; } // Logs pane: structured / raw-terminal toggle + the live viewer. export function StackLogsSection({ stackName, logsMode, setLogsMode, showServiceChips, logsExpanded, onToggleLogsExpand }: StackLogsSectionProps) { return (

Logs

{logsMode === 'structured' ? ( ) : (
)}
); }