// 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, } from 'lucide-react'; import { Button } from '../ui/button'; import { CardTitle } from '../ui/card'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { Sparkline } from '../ui/sparkline'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/lib/clipboard'; 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'; 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; if (health === 'healthy') return 'healthcheck passing'; if (health === 'unhealthy') return 'healthcheck failing'; return 'healthcheck starting'; }; 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; copiedDigest: string | null; setCopiedDigest: React.Dispatch>; copiedDigestTimerRef: React.MutableRefObject; can: ReturnType['can']; isAdmin: boolean; 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; } // Breadcrumb + serif title + state pill + image ref + action bar. The action // buttons grow to a 44px touch target below md without changing desktop. export function StackIdentityHeader({ stackName, activeNode, safeContainers, isRunning, copiedDigest, setCopiedDigest, copiedDigestTimerRef, can, isAdmin, trivy, backupInfo, loadingAction, stackMisconfigScanning, deployStack, restartStack, stopStack, updateStack, rollbackStack, scanStackConfig, requestDeleteStack, }: StackIdentityHeaderProps) { return (
{/* Identity block */}
{(activeNode?.name || 'local')} stacks {stackName}
{stackName} {(() => { const pill = getStackStatePill(safeContainers); if (!pill) return null; return ( ); })()}
{(() => { const first = safeContainers[0]; if (!first?.Image) return null; const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; return (
image · {first.Image} {digest && first.ImageID && ( <> · digest {digest} )}
); })()}
{/* 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); const canDelete = can('stack:delete', 'stack', stackName); const canRollback = canDeploy && backupInfo.exists; const canScan = trivy.available && isAdmin; const hasOverflowExtras = canRollback || canScan; const hasOverflow = hasOverflowExtras || canDelete; if (!canDeploy && !hasOverflow) return null; return (
{canDeploy && ( <> {isRunning ? ( ) : ( )} {isRunning && ( )} )} {hasOverflow && ( {canRollback && (
{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} {backupInfo.timestamp && ( {new Date(backupInfo.timestamp).toLocaleString()} )}
)} {canScan && ( {stackMisconfigScanning ? ( ) : ( )} {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} )} {hasOverflowExtras && canDelete && } {canDelete && ( {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} )}
)}
); })()}
); } 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; serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise; } // 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, serviceAction, }: ContainersHealthProps) { return (

CONTAINERS

{containerStatsError && safeContainers.length > 0 && ( Stats unavailable )}
{safeContainers.length === 0 ? (
No containers running for this stack.
) : (
{safeContainers.map(container => { let mainPort: number | undefined; let mainPortPrivate: number | undefined; let mainPortProto: string | undefined; if (container.Ports && container.Ports.length > 0) { const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; const IGNORE_PORTS = [1900, 53, 22]; let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); const chosen = match || container.Ports[0]; mainPort = chosen.PublicPort; mainPortPrivate = chosen.PrivatePort; mainPortProto = 'tcp'; } const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; 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}
{uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} {hcLabel ? <>·{hcLabel} : null} {mainPort && mainPortPrivate ? ( <> · {mainPort} → {mainPortPrivate}/{mainPortProto} ) : null}
{isAdmin && ( )} {container.Service && ( {isActive ? ( <> serviceAction('restart', container.Service!)}> Restart service serviceAction('stop', container.Service!)}> Stop service ) : ( serviceAction('start', container.Service!)}> Start service )} )}
{isActive ? (
cpu {stats?.cpu ?? '-'}
mem {stats?.ram ?? '-'}
net i/o {stats?.net ?? '-'}
) : null}
); })}
)}
); } export interface StackLogsSectionProps { stackName: string; logsMode: 'structured' | 'raw'; setLogsMode: (mode: 'structured' | 'raw') => void; } // Logs pane: structured / raw-terminal toggle + the live viewer. export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogsSectionProps) { return (

Logs

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