diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index cc6c5e42..78387109 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,42 +1,30 @@ import { useState, useEffect, useRef, useMemo, useCallback, lazy, Suspense } from 'react'; type Theme = 'light' | 'dark' | 'auto'; -import { Editor } from '@/lib/monacoLoader'; import { useImageUpdates } from '@/hooks/useImageUpdates'; -import TerminalComponent from './Terminal'; -import ErrorBoundary from './ErrorBoundary'; import type { NotificationItem } from './dashboard/types'; import BashExecModal from './BashExecModal'; import LazyBoundary from './LazyBoundary'; import { Button } from './ui/button'; -import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; -import { springs } from '@/lib/motion'; -import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, ShieldCheck, ArrowUpRight, Copy, FolderOpen } from 'lucide-react'; +import { Plus, Terminal, CloudDownload, Home, HardDrive, ScrollText, Activity, Radar, RefreshCw, Clock } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { type Label as StackLabel, type LabelColor } from './label-types'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; -import { copyToClipboard } from '@/lib/clipboard'; import { toast } from '@/components/ui/toast-store'; import { PolicyBlockDialog, type PolicyBlockPayload } from './stack/PolicyBlockDialog'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { TopBar } from './TopBar'; -import { cn } from '@/lib/utils'; import type { SectionId } from './settings/types'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; import { DeleteStackDialog } from './EditorLayout/DeleteStackDialog'; import { UnsavedChangesDialog } from './EditorLayout/UnsavedChangesDialog'; +import { EditorView, type ContainerInfo, type StackAction } from './EditorLayout/EditorView'; import { StackAlertSheet } from './StackAlertSheet'; import { StackAutoHealSheet } from '@/components/StackAutoHealSheet'; import { GitSourcePanel } from './stack/GitSourcePanel'; import { LogViewer } from './LogViewer'; -import StructuredLogViewer from './StructuredLogViewer'; -import StackAnatomyPanel from './StackAnatomyPanel'; -import { Sparkline } from './ui/sparkline'; import type { ScheduleTaskPrefill } from './ScheduledOperationsView'; // SecurityHistoryView is the only lazy-loaded view that lives outside @@ -71,22 +59,9 @@ import type { StackRowStatus } from '@/components/sidebar/stack-status-utils'; import type { FilterChip, StackMenuCtx } from '@/components/sidebar/sidebar-types'; import { useBulkStackActions, type BulkAction } from '@/hooks/useBulkStackActions'; import { isInputFocused, isPaletteOpen } from '@/lib/keyboard-guards'; -import { StackFileExplorer } from '@/components/files/StackFileExplorer'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { ComposeDiffPreviewDialog } from '@/components/ComposeDiffPreviewDialog'; -interface ContainerInfo { - Id: string; - Names: string[]; - Service?: string; - State: string; - Status?: string; - Ports?: { PrivatePort: number, PublicPort: number }[]; - healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none'; - Image?: string; - ImageID?: string; -} - interface StackStatus { [key: string]: 'running' | 'exited' | 'unknown'; } @@ -96,8 +71,6 @@ interface StackStatusInfo { mainPort?: number; } -type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback'; - const formatBytes = (bytes: number) => { if (bytes === 0) return '0 B'; const k = 1024; @@ -106,71 +79,6 @@ const formatBytes = (bytes: number) => { return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }; -// Extract the "up X time" portion from a Docker status string like -// "Up 12 days (healthy)" → "up 12 days". Returns null when the container -// is not in an uptime-reporting state (exited, created, restarting, etc.). -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 default function EditorLayout() { const { isAdmin, can } = useAuth(); const { isPaid, license } = useLicense(); @@ -1615,6 +1523,11 @@ export default function EditorLayout() { } }; + const requestDeleteStack = () => { + setStackToDelete(selectedFile); + setDeleteDialogOpen(true); + }; + // Context-menu-friendly stack actions (accept file name directly) const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => { if (isStackBusy(stackFile)) return; @@ -1722,15 +1635,6 @@ export default function EditorLayout() { return () => window.removeEventListener(SENCHO_OPEN_LOGS_EVENT, handler); }, []); - // Safe container list with fallback - const safeContainers = containers || []; - // Safe content strings with fallback - const safeContent = content || ''; - const safeEnvContent = envContent || ''; - - // Stack state booleans for dynamic button rendering - const isRunning = safeContainers?.some(c => c.State === 'running'); - // Stack name is now the same as selectedFile (no extension to strip) const stackName = selectedFile || ''; @@ -2081,539 +1985,57 @@ export default function EditorLayout() { onOpenSettingsSection={(section) => handleOpenSettings(section)} onClearNotifications={clearAllNotifications} renderEditor={() => ( - -
- {/* Left column: identity + health strip + logs, stacked */} -
- {/* Command Center Card (identity + health strip) */} - - -
- {/* 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 */} - {can('stack:deploy', 'stack', stackName) && ( -
- {isRunning ? ( - - ) : ( - - )} - {isRunning && ( - - )} - - {(() => { - const canRollback = isPaid && backupInfo.exists; - const canScan = trivy.available && isAdmin && isPaid; - const hasOverflowExtras = canRollback || canScan; - return ( - - - - - - {canRollback && ( - - -
- {loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} - {backupInfo.timestamp && ( - {new Date(backupInfo.timestamp).toLocaleString()} - )} -
-
- )} - {canScan && ( - - {stackMisconfigScanning ? ( - - ) : ( - - )} - {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} - - )} - {hasOverflowExtras && } - { - setStackToDelete(selectedFile); - setDeleteDialogOpen(true); - }} - > - - {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} - -
-
- ); - })()} -
- )} -
-
- - {/* Per-container health strip */} -
-

CONTAINERS

- {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} -
- ); - })} -
- )} -
-
-
- - {/* Logs Section (fills remaining left-column height) */} -
-
-

Logs

-
- - -
-
- {logsMode === 'structured' ? ( - - - - ) : ( -
-
- - - -
-
- )} -
-
- - {/* Right column: anatomy panel by default, Monaco editor when editing */} - {editingCompose ? ( - -
-
- setActiveTab(value as 'compose' | 'env' | 'files')}> - - - - compose.yaml - - - .env - - - - - Files - - - - - - - {activeTab === 'env' && envFiles.length > 1 && ( - - )} -
-
- {activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( - <> - - {!isEditing ? ( - - ) : ( -
- - - - - - - - - Save Only - - - - Discard Changes - - - -
- )} - - )} - -
-
-
- {activeTab === 'files' ? ( - setActiveTab('compose')} - onNavigateToEnv={() => setActiveTab('env')} - /> - ) : ( - <> - {activeTab === 'env' && ( -
- - Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. - -
- )} -
- {!isFileLoading && ( - }> - { monacoEditorRef.current = editor; }} - onChange={(value) => { - if (!isEditing) return; // Prevent changes in view mode - if (activeTab === 'compose') { - setContent(value || ''); - } else { - setEnvContent(value || ''); - } - }} - options={{ - minimap: { enabled: false }, - fontFamily: "'Geist Mono', monospace", - fontSize: 14, - padding: { top: 10 }, - scrollBeyondLastLine: false, - readOnly: !isEditing || !can('stack:edit', 'stack', stackName), - }} - /> - - )} - {isFileLoading && ( -
- Loading... -
- )} -
- - )} -
-
- ) : ( - setEditingCompose(true)} - onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} - onOpenGitSource={() => setGitSourceOpen(true)} - onApplyUpdate={() => { void updateStack(); }} - canEdit={can('stack:edit', 'stack', stackName)} - notifications={notifications} - /> - )} -
-
+ )} /> diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx new file mode 100644 index 00000000..9e788533 --- /dev/null +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -0,0 +1,824 @@ +import { Suspense } from 'react'; +import { Editor } from '@/lib/monacoLoader'; +import { + RotateCw, + Play, + Square, + Save, + Terminal, + CloudDownload, + Pencil, + X, + MoreVertical, + Rocket, + Trash2, + ScrollText, + Undo2, + Loader2, + Check, + ChevronDown, + GitBranch, + ShieldCheck, + ArrowUpRight, + Copy, + FolderOpen, +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { + Tabs, + TabsList, + TabsTrigger, + TabsHighlight, + TabsHighlightItem, +} from '../ui/tabs'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Sparkline } from '../ui/sparkline'; +import { springs } from '@/lib/motion'; +import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/lib/clipboard'; +import ErrorBoundary from '../ErrorBoundary'; +import TerminalComponent from '../Terminal'; +import StructuredLogViewer from '../StructuredLogViewer'; +import StackAnatomyPanel from '../StackAnatomyPanel'; +import { StackFileExplorer } from '@/components/files/StackFileExplorer'; +import type { NotificationItem } from '../dashboard/types'; +import type { Node } from '@/context/NodeContext'; +import type { useAuth } from '@/context/AuthContext'; + +export interface ContainerInfo { + Id: string; + Names: string[]; + Service?: string; + State: string; + Status?: string; + Ports?: { PrivatePort: number; PublicPort: number }[]; + healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none'; + Image?: string; + ImageID?: string; +} + +export type StackAction = + | 'deploy' + | 'stop' + | 'restart' + | 'update' + | 'delete' + | 'rollback'; + +export interface ContainerStatsEntry { + cpu: string; + ram: string; + net: string; + lastRx?: number; + lastTx?: number; + history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] }; +} + +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 EditorViewProps { + // Identity + stackName: string; + isDarkMode: boolean; + + // Stack data (raw; safe-wrapped locally for backwards-compat with prior idiom) + containers: ContainerInfo[]; + containerStats: Record; + content: string; + envContent: string; + envExists: boolean; + envFiles: string[]; + selectedEnvFile: string; + isFileLoading: boolean; + backupInfo: { exists: boolean; timestamp: number | null }; + gitSourcePendingMap: Record; + notifications: NotificationItem[]; + + // Editor mode + activeTab: 'compose' | 'env' | 'files'; + isEditing: boolean; + editingCompose: boolean; + logsMode: 'structured' | 'raw'; + copiedDigest: string | null; + loadingAction: StackAction | null; + stackMisconfigScanning: boolean; + + // Permissions / tier / context + can: ReturnType['can']; + isAdmin: boolean; + isPaid: boolean; + trivy: { available: boolean }; + activeNode: Node | null; + + // Refs + monacoEditorRef: React.MutableRefObject< + import('monaco-editor').editor.IStandaloneCodeEditor | null + >; + copiedDigestTimerRef: React.MutableRefObject; + + // Stack actions + deployStack: (e: React.MouseEvent) => Promise; + restartStack: (e: React.MouseEvent) => Promise; + stopStack: (e: React.MouseEvent) => Promise; + updateStack: (e?: React.MouseEvent) => Promise; + rollbackStack: () => Promise; + scanStackConfig: () => Promise; + + // Edit lifecycle + enterEditMode: () => void; + requestSave: () => void; + requestSaveAndDeploy: (e: React.MouseEvent) => void; + discardChanges: () => void; + setContent: (next: string) => void; + setEnvContent: (next: string) => void; + changeEnvFile: (file: string) => Promise; + + // Container / service actions + openLogViewer: (containerId: string, containerName: string) => void; + openBashModal: (containerId: string, containerName: string) => void; + serviceAction: ( + action: 'start' | 'stop' | 'restart', + serviceName: string, + ) => Promise; + + // UI state setters + setActiveTab: (tab: 'compose' | 'env' | 'files') => void; + setLogsMode: (mode: 'structured' | 'raw') => void; + setEditingCompose: (open: boolean) => void; + setGitSourceOpen: (open: boolean) => void; + setCopiedDigest: React.Dispatch>; + + // Composed action — wraps setStackToDelete + setDeleteDialogOpen + requestDeleteStack: () => void; +} + +export function EditorView({ + stackName, + isDarkMode, + containers, + containerStats, + content, + envContent, + envExists, + envFiles, + selectedEnvFile, + isFileLoading, + backupInfo, + gitSourcePendingMap, + notifications, + activeTab, + isEditing, + editingCompose, + logsMode, + copiedDigest, + loadingAction, + stackMisconfigScanning, + can, + isAdmin, + isPaid, + trivy, + activeNode, + monacoEditorRef, + copiedDigestTimerRef, + deployStack, + restartStack, + stopStack, + updateStack, + rollbackStack, + scanStackConfig, + enterEditMode, + requestSave, + requestSaveAndDeploy, + discardChanges, + setContent, + setEnvContent, + changeEnvFile, + openLogViewer, + openBashModal, + serviceAction, + setActiveTab, + setLogsMode, + setEditingCompose, + setGitSourceOpen, + setCopiedDigest, + requestDeleteStack, +}: EditorViewProps) { + const safeContainers = containers || []; + const safeContent = content || ''; + const safeEnvContent = envContent || ''; + const isRunning = safeContainers.some(c => c.State === 'running'); + + return ( + +
+ {/* Left column: identity + health strip + logs, stacked */} +
+ {/* Command Center Card (identity + health strip) */} + + +
+ {/* 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 */} + {can('stack:deploy', 'stack', stackName) && ( +
+ {isRunning ? ( + + ) : ( + + )} + {isRunning && ( + + )} + + {(() => { + const canRollback = isPaid && backupInfo.exists; + const canScan = trivy.available && isAdmin && isPaid; + const hasOverflowExtras = canRollback || canScan; + return ( + + + + + + {canRollback && ( + + +
+ {loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} + {backupInfo.timestamp && ( + {new Date(backupInfo.timestamp).toLocaleString()} + )} +
+
+ )} + {canScan && ( + + {stackMisconfigScanning ? ( + + ) : ( + + )} + {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} + + )} + {hasOverflowExtras && } + + + {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} + +
+
+ ); + })()} +
+ )} +
+
+ + {/* Per-container health strip */} +
+

CONTAINERS

+ {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} +
+ ); + })} +
+ )} +
+
+
+ + {/* Logs Section (fills remaining left-column height) */} +
+
+

Logs

+
+ + +
+
+ {logsMode === 'structured' ? ( + + + + ) : ( +
+
+ + + +
+
+ )} +
+
+ + {/* Right column: anatomy panel by default, Monaco editor when editing */} + {editingCompose ? ( + +
+
+ setActiveTab(value as 'compose' | 'env' | 'files')}> + + + + compose.yaml + + + .env + + + + + Files + + + + + + + {activeTab === 'env' && envFiles.length > 1 && ( + + )} +
+
+ {activeTab !== 'files' && can('stack:edit', 'stack', stackName) && ( + <> + + {!isEditing ? ( + + ) : ( +
+ + + + + + + + + Save Only + + + + Discard Changes + + + +
+ )} + + )} + +
+
+
+ {activeTab === 'files' ? ( + setActiveTab('compose')} + onNavigateToEnv={() => setActiveTab('env')} + /> + ) : ( + <> + {activeTab === 'env' && ( +
+ + Variables defined here are automatically available for substitution in your compose.yaml (e.g., ${'{}'}VAR). To pass them directly into your container, you must add env_file: - .env to your service definition. + +
+ )} +
+ {!isFileLoading && ( + }> + { monacoEditorRef.current = editor; }} + onChange={(value) => { + if (!isEditing) return; // Prevent changes in view mode + if (activeTab === 'compose') { + setContent(value || ''); + } else { + setEnvContent(value || ''); + } + }} + options={{ + minimap: { enabled: false }, + fontFamily: "'Geist Mono', monospace", + fontSize: 14, + padding: { top: 10 }, + scrollBeyondLastLine: false, + readOnly: !isEditing || !can('stack:edit', 'stack', stackName), + }} + /> + + )} + {isFileLoading && ( +
+ Loading... +
+ )} +
+ + )} +
+
+ ) : ( + setEditingCompose(true)} + onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }} + onOpenGitSource={() => setGitSourceOpen(true)} + onApplyUpdate={() => { void updateStack(); }} + canEdit={can('stack:edit', 'stack', stackName)} + notifications={notifications} + /> + )} +
+
+ ); +}