Files
sencho/frontend/src/components/EditorLayout/editor-view-blocks.tsx
T
Anso 4d2270a097 fix(ui): use Docker health status terms on container cards (#1696)
Per-container metadata showed healthcheck passing/failing instead of Docker's healthy/unhealthy/starting, which disagreed with the stack pill. Render the normalized healthStatus token directly and document the same terms.

Closes #1677
2026-07-24 22:48:53 -04:00

893 lines
52 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
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<typeof useAuth>['can'];
isAdmin: boolean;
trivy: { available: boolean };
backupInfo: { exists: boolean; timestamp: number | null };
loadingAction: StackAction | null;
stackMisconfigScanning: boolean;
deployStack: (e: React.MouseEvent) => Promise<void>;
restartStack: (e: React.MouseEvent) => Promise<void>;
stopStack: (e: React.MouseEvent) => Promise<void>;
updateStack: (e?: React.MouseEvent) => Promise<void>;
rollbackStack: () => Promise<void>;
scanStackConfig: () => Promise<void>;
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<typeof useStackMuteActions>;
/** 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,
isAdmin,
trivy,
backupInfo,
loadingAction,
stackMisconfigScanning,
deployStack,
restartStack,
stopStack,
updateStack,
rollbackStack,
scanStackConfig,
requestDeleteStack,
requestTakeDownStack,
showTakeDown,
isSelfStack = false,
stackMuteActions,
onOpenMonitor,
}: StackIdentityHeaderProps) {
const selfProtected = isSelfStack;
return (
<div className="flex flex-col gap-3">
{/* Identity block */}
<div className="flex flex-col gap-1.5">
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60"></span> stacks <span className="text-muted-foreground/60"></span> {stackName}
</div>
<div className="flex items-center gap-3 flex-wrap">
<CardTitle className="font-heading text-3xl leading-none tracking-tight">{stackName}</CardTitle>
{(() => {
const pill = getStackStatePill(safeContainers);
if (!pill) return null;
return (
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 ${pill.className}`}>
<span
aria-hidden="true"
className={`h-1.5 w-1.5 rounded-full ${pill.dotClass} ${pill.pulse ? 'animate-[pulse_2.4s_ease-in-out_infinite]' : ''}`}
/>
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">{pill.label}</span>
</span>
);
})()}
</div>
</div>
{/* 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 canMute = stackMuteActions?.canMute ?? false;
const hasOverflowExtras = canRollback || canScan;
const hasOverflow = hasOverflowExtras || canDelete || canMute || onOpenMonitor;
if (!canDeploy && !hasOverflow) return null;
return (
<div className="flex items-center gap-2 flex-wrap">
{canDeploy && (
<>
{isRunning ? (
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
</Button>
) : (
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null || selfProtected}>
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
</Button>
)}
{isRunning && (
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={stopStack} disabled={loadingAction !== null || selfProtected}>
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
</Button>
)}
{isRunning && showTakeDown && (
<Button
type="button"
size="sm"
variant="outline"
data-testid="stack-take-down-button"
className="rounded-lg max-md:h-11 border-warning/40 text-warning hover:bg-warning/10"
onClick={() => requestTakeDownStack(stackName)}
disabled={loadingAction !== null || selfProtected}
>
<ArrowDownToLine className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'down' ? 'Taking down...' : 'Take down'}
</Button>
)}
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null || selfProtected}>
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'update' ? 'Updating...' : 'Update'}
</Button>
</>
)}
{hasOverflow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0 max-md:h-11 max-md:w-11" disabled={loadingAction !== null} aria-label="More actions">
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{canRollback && (
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null || selfProtected}>
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
<div className="flex flex-col gap-0.5">
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
{backupInfo.timestamp && (
<span className="text-[10px] text-stat-subtitle font-mono">{new Date(backupInfo.timestamp).toLocaleString()}</span>
)}
</div>
</DropdownMenuItem>
)}
{canScan && (
<DropdownMenuItem onClick={scanStackConfig} disabled={loadingAction !== null || stackMisconfigScanning}>
{stackMisconfigScanning ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
) : (
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
)}
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
</DropdownMenuItem>
)}
{onOpenMonitor && (
<DropdownMenuItem onClick={onOpenMonitor}>
<HeartPulse className="w-4 h-4 mr-2" strokeWidth={1.5} />
Monitor
</DropdownMenuItem>
)}
{stackMuteActions && <StackMuteSubmenu actions={stackMuteActions} />}
{(canRollback || canScan || onOpenMonitor || stackMuteActions?.canMute) && canDelete && <DropdownMenuSeparator />}
{canDelete && (
<DropdownMenuItem
className="text-destructive focus:text-destructive focus:bg-destructive/10"
disabled={loadingAction !== null || selfProtected}
onClick={requestDeleteStack}
>
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})()}
</div>
);
}
export interface ContainersHealthProps {
safeContainers: ContainerInfo[];
containerStats: Record<string, ContainerStatsEntry>;
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<void>;
// 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;
}
// 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,
}: 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<string | null>(null);
const copiedUrlTimerRef = useRef<number | null>(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 ? (
<div className="flex items-center justify-between mb-1 px-1">
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
<span>{total} container{total !== 1 ? 's' : ''}</span>
<span className="text-success/80">{running} up</span>
{paused > 0 && <span className="text-warning/80">{paused} paused</span>}
{unhealthy > 0 && <span className="text-destructive/80">{unhealthy} unhealthy</span>}
</div>
<div className="flex items-center gap-1">
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setDensity('compact')}
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'compact' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
aria-pressed={density === 'compact'}
aria-label="Compact view"
>
<List className="h-3 w-3" strokeWidth={1.5} />
</button>
</TooltipTrigger>
<TooltipContent>Compact view</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => setDensity('detailed')}
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${density === 'detailed' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
aria-pressed={density === 'detailed'}
aria-label="Detailed view"
>
<Layers className="h-3 w-3" strokeWidth={1.5} />
</button>
</TooltipTrigger>
<TooltipContent>Detailed view</TooltipContent>
</Tooltip>
</TooltipProvider>
{onToggleContainersExpand && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onToggleContainersExpand}
className={`rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors ${containersExpanded ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground'}`}
aria-pressed={containersExpanded}
aria-label={containersExpanded ? 'Collapse containers' : 'Expand containers'}
>
{containersExpanded
? <Minimize2 className="h-3 w-3" strokeWidth={1.5} />
: <Maximize2 className="h-3 w-3" strokeWidth={1.5} />}
</button>
</TooltipTrigger>
<TooltipContent>{containersExpanded ? 'Collapse containers' : 'Expand containers'}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</div>
) : null;
// One container card. `hideServiceMenu` drops the per-container
// Start/Stop/Restart kebab on multi-service stacks; the declared-service
// header above owns lifecycle actions. Child cards keep logs, shell, ports,
// and metrics only.
const renderContainerCard = (container: ContainerInfo, hideServiceMenu: boolean) => {
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 (
<div key={container?.Id || Math.random()} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2.5">
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 min-w-0 flex-1">
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
{badgeGlyph}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
{mainPort && mainPortPrivate ? (
<>
<span>·</span>
{serviceUrl ? (
<>
<a
href={serviceUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-brand hover:underline"
>
{portLabel} <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
</a>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={copiedUrlId === container?.Id ? 'Copied' : 'Copy service URL'}
onClick={() => copyServiceUrl(container?.Id, serviceUrl)}
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
>
{copiedUrlId === container?.Id ? (
<Check className="h-3 w-3" strokeWidth={2} />
) : (
<Copy className="h-3 w-3" strokeWidth={1.5} />
)}
</button>
</TooltipTrigger>
<TooltipContent>Copy service URL</TooltipContent>
</Tooltip>
</TooltipProvider>
</>
) : (
<span>{portLabel}</span>
)}
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<ImageSourceMenu
imageRef={container.Image}
imageId={container.ImageID}
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
onClick={() => openLogViewer(container?.Id, containerName)}
disabled={!isActive}
aria-label="View logs"
>
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>View logs</TooltipContent>
</Tooltip>
</TooltipProvider>
{onOpenServiceMonitor && composeService && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
onClick={() => onOpenServiceMonitor(composeService)}
aria-label={`Monitor ${composeService}`}
>
<HeartPulse className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Monitor {composeService}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isAdmin && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
onClick={() => openBashModal(container?.Id, containerName)}
disabled={!isActive}
aria-label="Open bash shell"
>
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</TooltipTrigger>
<TooltipContent>Open bash shell</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{!hideServiceMenu && container.Service && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{isActive && density === 'detailed' ? (
<div className="mt-2 grid grid-cols-3 gap-2">
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">cpu</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">mem</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
<div className="flex flex-col">
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">net i/o</span>
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
</div>
<div className="ml-auto h-5 w-16">
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
</div>
</div>
</div>
) : null}
</div>
);
};
const showConfirmedEmpty = containersLoadStatus === 'success' && safeContainers.length === 0;
if (containersLoadStatus === 'idle' || containersLoadStatus === 'loading') {
return (
<div className="space-y-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
);
}
if (containersLoadStatus === 'error') {
return (
<div className="rounded-lg border border-card-border bg-card/40 p-4 text-center">
<AlertCircle className="mx-auto mb-2 h-8 w-8 text-muted-foreground" aria-hidden />
<p className="mb-3 text-sm text-muted-foreground">
{containersLoadError ?? 'Could not load containers.'}
</p>
{onRetryContainersLoad && (
<Button type="button" variant="outline" size="sm" onClick={onRetryContainersLoad}>
<RefreshCw className="h-4 w-4" />
Retry
</Button>
)}
</div>
);
}
return (
<div>
{containerStatsError && safeContainers.length > 0 && (
<div className="mb-3 flex items-center justify-end">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5">
Stats unavailable
</span>
</TooltipTrigger>
<TooltipContent>{containerStatsError}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
{densityToolbar}
{isMultiService ? (
<div className="flex flex-col gap-3">
{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?.hasUpdate === true;
const mode: 'update' | 'rebuild' = !hasUpdate && spec.hasBuild ? 'rebuild' : 'update';
const showUpdateAction = spec.declaredImage !== null || 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}`;
return (
<div key={spec.name} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 rounded-lg border border-card-border bg-muted/40 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate font-mono text-sm font-medium text-foreground">{spec.name}</span>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{runningCount}/{spec.expectedReplicas} running
</span>
{hasUpdate && (
<span className="rounded-full border border-brand/30 bg-brand/10 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wide text-brand">
Update
</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{showUpdateAction && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="sm"
variant="outline"
className="h-7 rounded-md px-2 max-md:h-11"
onClick={() => onRequestServiceUpdate?.(spec.name, mode)}
disabled={busy}
>
<CloudDownload className="h-3.5 w-3.5 mr-1.5" strokeWidth={1.5} />
{busy
? (mode === 'rebuild' ? 'Rebuilding...' : 'Updating...')
: (mode === 'rebuild' ? 'Rebuild' : 'Update')}
</Button>
</TooltipTrigger>
<TooltipContent>{replicaCopy}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
aria-label="Service actions"
>
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{isServiceActive ? (
<>
<DropdownMenuItem onSelect={() => serviceAction('restart', spec.name)}>
Restart service
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => serviceAction('stop', spec.name)}>
Stop service
</DropdownMenuItem>
</>
) : (
<DropdownMenuItem onSelect={() => serviceAction('start', spec.name)}>
Start service
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{group.length > 0 ? (
<div className="ml-2 flex flex-col gap-2 border-l border-hairline pl-3">
{group.map(container => renderContainerCard(container, true))}
</div>
) : (
<div className="ml-2 pl-3 font-mono text-xs text-muted-foreground">
No containers running for this service.
</div>
)}
</div>
);
})}
</div>
) : showConfirmedEmpty ? (
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
) : (
<div className="flex flex-col gap-2">
{safeContainers.map(container => renderContainerCard(container, false))}
</div>
)}
</div>
);
}
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 (
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
<button
type="button"
onClick={() => setLogsMode('structured')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Structured
</button>
<button
type="button"
onClick={() => setLogsMode('raw')}
className={cn(
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
)}
>
Raw terminal
</button>
</div>
</div>
{logsMode === 'structured' ? (
<ErrorBoundary>
<StructuredLogViewer stackName={stackName} showServiceChips={showServiceChips} expanded={logsExpanded} onToggleExpand={onToggleLogsExpand} />
</ErrorBoundary>
) : (
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
<div className="h-full">
<ErrorBoundary>
<TerminalComponent stackName={stackName} />
</ErrorBoundary>
</div>
</div>
)}
</div>
);
}