Files
sencho/frontend/src/components/EditorLayout/EditorView.tsx
T
Anso fbe98676cb feat: bound container health pane so logs stay visible on large stacks (#1556)
* feat: bound container health pane so logs stay visible on large stacks

On the stack overview page, multi-container stacks (8+ services) pushed the
logs pane off-screen because the Command Center card had shrink-0 and no
scroll mechanism. This reworks the desktop layout for stacks with more than
one container:

- The Command Center card is bounded at max-h-[42%] so it never consumes
  the full left column. The container list scrolls internally via Sencho's
  existing Radix ScrollArea.
- A summary strip shows total / running / paused / unhealthy counts above
  the list, with a Compact / Detailed density toggle. Detailed (default)
  shows CPU / Mem / Net sparklines; Compact hides them for a denser list.
- The logs pane has a guaranteed min-h-[180px] so it is always reachable.
- Single-container stacks are untouched: the card keeps its original
  shrink-0 behaviour, and no summary strip or density toggle appears.

Mobile is unaffected (it already uses segmented Health / Logs / Compose
panes).

* fix: key ContainersHealth by node+stack so density resets on navigation

The density toggle state (compact/detailed) is local to ContainersHealth.
Without a key, selecting Compact on one multi-container stack would persist
when navigating to a single-container stack, hiding sparklines while also
hiding the toggle itself. Keying by node+stack ensures a clean remount.

Also added test coverage for zero containers and density reset on remount.

* fix: remove unused rerender variable from ContainersHealth test
2026-07-05 02:53:26 -04:00

664 lines
32 KiB
TypeScript

import { Suspense, useRef, useEffect, useState } from 'react';
import { Editor } from '@/lib/monacoLoader';
import {
Save,
Pencil,
X,
Rocket,
ChevronDown,
GitBranch,
FolderOpen,
Maximize2,
Minimize2,
} from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent, CardHeader } from '../ui/card';
import {
Tabs,
TabsList,
TabsTrigger,
TabsHighlight,
TabsHighlightItem,
} from '../ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { springs } from '@/lib/motion';
import ErrorBoundary from '../ErrorBoundary';
import StackAnatomyPanel from '../StackAnatomyPanel';
import { StackFileExplorer } from '@/components/files/StackFileExplorer';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { ScrollArea } from '../ui/scroll-area';
import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks';
import { MobileStackDetail } from './MobileStackDetail';
import { RecoveryChip } from './RecoveryChip';
import { StackOperationBanner } from './StackOperationBanner';
import { retryHandlerFor } from './recovery-retry';
import type { NotificationItem } from '../dashboard/types';
import type { Node } from '@/context/NodeContext';
import type { useAuth } from '@/context/AuthContext';
import type { useStackMuteActions } from '@/hooks/useMuteRuleActions';
export interface ContainerInfo {
Id: string;
Names: string[];
Service?: string;
State: string;
Status?: string;
Ports?: { PrivatePort: number; PublicPort: number; Type?: string }[];
healthStatus?: 'healthy' | 'unhealthy' | 'starting' | 'none';
Image?: string;
ImageID?: string;
}
export type StackAction =
| 'deploy'
| 'stop'
| 'restart'
| 'update'
| 'delete'
| 'rollback';
/**
* Stack operations the recovery panel can offer safe next steps for. A failed
* stop/start/delete is not "recoverable" through retry/restart/rollback, so it
* never produces a record; narrowing the type keeps the panel's retry routing
* exhaustive.
*/
export type RecoverableAction = Extract<StackAction, 'deploy' | 'update' | 'restart' | 'rollback'>;
/**
* Server-side classification of a failed deploy/update: a cause headline and a
* suggested next step. `reason` stays a plain string here; the backend owns the
* category vocabulary and the UI only displays it.
*/
export interface FailureClassification {
reason: string;
label: string;
suggestion: string;
}
/**
* Terminal record of a failed stack operation, kept in memory per stack so the
* recovery panel can offer safe next steps after an update/deploy fails or
* stalls. Cleared when the same stack's next operation succeeds or is dismissed,
* and on active-node change (the keyed stack filename can repeat across nodes).
*/
export interface StackActionResult {
action: RecoverableAction;
rolledBack: boolean;
errorMessage?: string;
startedAt: number;
endedAt: number;
// Last live output line captured only when a matching deploy-feedback
// session was streaming this stack at failure time; omitted otherwise so a
// line from another stack/session never leaks into diagnostics.
lastOutputLine?: string;
// Classified cause + suggested next action from the failed response body,
// when the backend (or the unreachable-node fallback) provided one.
failure?: FailureClassification;
}
export interface ContainerStatsEntry {
cpu: string;
ram: string;
net: string;
lastRx?: number;
lastTx?: number;
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
}
export interface EditorViewProps {
// Identity
stackName: string;
isDarkMode: boolean;
// Stack data (raw; safe-wrapped locally for backwards-compat with prior idiom)
containers: ContainerInfo[];
containerStats: Record<string, ContainerStatsEntry>;
containerStatsError: string | null;
content: string;
envContent: string;
envExists: boolean;
envFiles: string[];
selectedEnvFile: string;
isFileLoading: boolean;
backupInfo: { exists: boolean; timestamp: number | null };
gitSourcePendingMap: Record<string, boolean>;
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<typeof useAuth>['can'];
isAdmin: boolean;
trivy: { available: boolean };
activeNode: Node | null;
// Refs
copiedDigestTimerRef: React.MutableRefObject<number | null>;
// Stack actions
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>;
// 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<void>;
// Container / service actions
openLogViewer: (containerId: string, containerName: string) => void;
openBashModal: (containerId: string, containerName: string) => void;
serviceAction: (
action: 'start' | 'stop' | 'restart',
serviceName: string,
) => Promise<void>;
// 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<React.SetStateAction<string | null>>;
// Composed action: wraps setStackToDelete + setDeleteDialogOpen
requestDeleteStack: () => void;
// Recovery surface for a failed/stalled operation on this stack (undefined
// when the last op succeeded or none has run). onRefreshState re-syncs
// container state; onDismissRecovery drops the record.
recoveryResult?: StackActionResult;
onRefreshState: () => void;
onDismissRecovery: () => void;
// Session start (ms) of the active deploy-feedback op, or null when none, for
// the inline progress banner's elapsed readout.
panelStartedAt: number | null;
// Mobile-only: back affordance in the detail header returns to the stack list.
onMobileBack?: () => void;
// Mobile-only (always supplied by renderEditor): close the full-screen
// compose/.env editor, routed through the unsaved-changes guard so a dirty
// close prompts before discarding. Required, not optional, because the
// fallback would silently discard edits; the desktop EditorView ignores it.
onCloseEditor: () => void;
// Mobile-only (always supplied by renderEditor): true when the compose or env
// buffer differs from disk; gates the env-file selector so switching files
// cannot drop unsaved edits. Required so a wiring gap is a compile error
// rather than a silently-disabled data-loss guard.
hasUnsavedChanges: () => boolean;
// Mobile-only: notifications + more-menu cluster for the detail header right
// slot (the global TopBar is dropped on the full-screen detail surface).
headerActions?: React.ReactNode;
stackMuteActions?: ReturnType<typeof useStackMuteActions>;
}
export function EditorView(props: EditorViewProps) {
const {
stackName,
isDarkMode,
containers,
containerStats,
containerStatsError,
content,
envContent,
envExists,
envFiles,
selectedEnvFile,
isFileLoading,
backupInfo,
gitSourcePendingMap,
notifications,
activeTab,
isEditing,
editingCompose,
logsMode,
copiedDigest,
loadingAction,
stackMisconfigScanning,
can,
isAdmin,
trivy,
activeNode,
copiedDigestTimerRef,
deployStack,
restartStack,
stopStack,
updateStack,
rollbackStack,
scanStackConfig,
enterEditMode,
requestSave,
requestSaveAndDeploy,
discardChanges,
setContent,
setEnvContent,
changeEnvFile,
openLogViewer,
openBashModal,
serviceAction,
setActiveTab,
setLogsMode,
setEditingCompose,
setGitSourceOpen,
setCopiedDigest,
requestDeleteStack,
recoveryResult,
onRefreshState,
onDismissRecovery,
panelStartedAt,
stackMuteActions,
} = props;
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
// Dispose the underlying Monaco model when EditorView unmounts. The
// @monaco-editor/react wrapper reuses a single model per editor instance
// (we do not pass a `path`), so this catches the unmount case rather than
// a per-stack-switch leak.
useEffect(() => {
return () => {
const editor = monacoEditorRef.current;
if (!editor) return;
try {
editor.getModel()?.dispose();
} catch {
// Editor already torn down by Monaco; nothing to do.
}
};
}, []);
// Force Monaco to re-measure its container after the tab switch DOM settles.
// Monaco's internal child is position:static with an explicit pixel height that
// creates a circular CSS dependency (Monaco drives card height -> grid height -> Monaco).
// Fix: reset Monaco to 0x0 first (breaks the cycle), then trigger a forced synchronous
// reflow so the container has its CSS-correct size before Monaco re-measures.
useEffect(() => {
const id = requestAnimationFrame(() => {
const editor = monacoEditorRef.current;
if (!editor) return;
editor.layout({ width: 0, height: 0 }); // collapse -> breaks CSS circular dependency
editor.layout(); // forced reflow -> measures correct container size
});
return () => cancelAnimationFrame(id);
}, [activeTab]);
const safeContainers = containers || [];
const safeContent = content || '';
const safeEnvContent = envContent || '';
const isRunning = safeContainers.some(c => c.State === 'running');
const canRead = can('stack:read', 'stack', stackName);
useEffect(() => {
if (activeTab === 'files' && !canRead) {
setActiveTab('compose');
}
}, [activeTab, canRead, setActiveTab]);
// Fullscreen the file browser + editor by collapsing the left column. Only
// meaningful on the files tab; reset when leaving it or closing the editor so
// it can never strand the compose/env panels in a single-column layout.
const [filesFullscreen, setFilesFullscreen] = useState(false);
useEffect(() => {
if (!editingCompose || activeTab !== 'files') setFilesFullscreen(false);
}, [editingCompose, activeTab]);
// Expand the logs by collapsing the Command Center card so the logs pane
// fills the left column. Toggled from the structured log viewer header.
const [logsExpanded, setLogsExpanded] = useState(false);
// The expand control lives only in the structured viewer; reset when the
// raw terminal is selected so the Command Center can't be stranded hidden.
useEffect(() => {
if (logsMode === 'raw') setLogsExpanded(false);
}, [logsMode]);
// Below md, render the segmented full-screen mobile detail instead of the
// desktop two-pane grid. All hooks above run unconditionally before this
// branch so hook order stays stable across breakpoints.
const isMobile = useIsMobile();
if (isMobile) {
return <MobileStackDetail {...props} />;
}
return (
<ErrorBoundary>
<div className={`grid gap-6 ${filesFullscreen ? 'grid-cols-1' : 'grid-cols-1 lg:grid-cols-2'} min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]`}>
{/* Left column: identity + health strip + logs, stacked. Hidden in
files fullscreen so the editor card fills the width. */}
{!filesFullscreen && (
<div className="flex flex-col gap-6 min-h-0">
{/* Command Center Card (identity + health strip). Hidden when
the logs are expanded so the logs pane fills the column. */}
{!logsExpanded && (
<Card className={`rounded-xl border-muted bg-card ${safeContainers.length > 1 ? 'flex flex-col min-h-0 max-h-[42%]' : 'shrink-0'}`}>
<CardHeader className="p-4 pb-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<StackIdentityHeader
stackName={stackName}
activeNode={activeNode}
safeContainers={safeContainers}
isRunning={isRunning}
copiedDigest={copiedDigest}
setCopiedDigest={setCopiedDigest}
copiedDigestTimerRef={copiedDigestTimerRef}
can={can}
isAdmin={isAdmin}
trivy={trivy}
backupInfo={backupInfo}
loadingAction={loadingAction}
stackMisconfigScanning={stackMisconfigScanning}
deployStack={deployStack}
restartStack={restartStack}
stopStack={stopStack}
updateStack={updateStack}
rollbackStack={rollbackStack}
scanStackConfig={scanStackConfig}
requestDeleteStack={requestDeleteStack}
stackMuteActions={stackMuteActions}
/>
</div>
{recoveryResult && loadingAction == null && (
<div className="shrink-0">
<RecoveryChip
stackName={stackName}
result={recoveryResult}
activeNode={activeNode}
backupInfo={backupInfo}
canDeploy={can('stack:deploy', 'stack', stackName)}
onRetry={retryHandlerFor(recoveryResult.action, { deployStack, restartStack, updateStack, rollbackStack })}
onRestart={restartStack}
onRollback={rollbackStack}
onRefreshState={onRefreshState}
onDismiss={onDismissRecovery}
/>
</div>
)}
</div>
</CardHeader>
<StackOperationBanner
stackName={stackName}
activeNode={activeNode}
panelStartedAt={panelStartedAt}
variant="band"
/>
{safeContainers.length > 1 ? (
<CardContent className="p-4 pt-2 flex-1 min-h-0">
<ScrollArea className="h-full">
<ContainersHealth
safeContainers={safeContainers}
containerStats={containerStats}
containerStatsError={containerStatsError}
isAdmin={isAdmin}
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
serviceAction={serviceAction}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</ScrollArea>
</CardContent>
) : (
<CardContent className="p-4 pt-2">
<ContainersHealth
safeContainers={safeContainers}
containerStats={containerStats}
containerStatsError={containerStatsError}
isAdmin={isAdmin}
activeNode={activeNode}
openLogViewer={openLogViewer}
openBashModal={openBashModal}
serviceAction={serviceAction}
key={`${activeNode?.id ?? 'local'}:${stackName}`}
/>
</CardContent>
)}
</Card>
)}
{/* Logs Section (fills remaining left-column height). On multi-
container stacks a min-h guarantees logs are never hidden. */}
{safeContainers.length > 1 ? (
<div className="flex-1 min-h-[180px] flex flex-col">
<StackLogsSection
stackName={stackName}
logsMode={logsMode}
setLogsMode={setLogsMode}
logsExpanded={logsExpanded}
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
/>
</div>
) : (
<StackLogsSection
stackName={stackName}
logsMode={logsMode}
setLogsMode={setLogsMode}
logsExpanded={logsExpanded}
onToggleLogsExpand={() => setLogsExpanded((v) => !v)}
/>
)}
</div>
)}
{/* Right column: anatomy panel by default, Monaco editor when editing */}
{editingCompose ? (
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-0 bg-card">
<div className="p-4 border-b border-muted flex items-center justify-between shrink-0">
<div className="flex items-center gap-4">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env' | 'files')}>
<TabsList>
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
<TabsHighlightItem value="compose">
<TabsTrigger value="compose">compose.yaml</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="env">
<TabsTrigger value="env" disabled={!envExists}>.env</TabsTrigger>
</TabsHighlightItem>
{canRead && (
<TabsHighlightItem value="files">
<TabsTrigger value="files">
<FolderOpen className="w-3.5 h-3.5 mr-1" strokeWidth={1.5} />
Files
</TabsTrigger>
</TabsHighlightItem>
)}
</TabsHighlight>
</TabsList>
</Tabs>
{activeTab === 'env' && envFiles.length > 1 && (
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={isEditing || isFileLoading}>
<SelectTrigger className="h-9 text-xs bg-muted border-none min-w-[200px]">
<SelectValue placeholder="Select environment file" />
</SelectTrigger>
<SelectContent>
{envFiles.map((file) => (
<SelectItem key={file} value={file} className="text-xs">
{file.split('/').pop()}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
<div className="flex items-center gap-2">
{activeTab !== 'files' && can('stack:edit', 'stack', stackName) && (
<>
<Button
size="sm"
variant="outline"
className="rounded-lg relative"
onClick={() => setGitSourceOpen(true)}
>
<GitBranch className="w-4 h-4 mr-2" strokeWidth={1.5} />
Git Source
{gitSourcePendingMap[stackName] && (
<span className="absolute -top-1 -right-1 w-2.5 h-2.5 rounded-full bg-brand animate-pulse" />
)}
</Button>
{!isEditing ? (
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
<Pencil className="w-4 h-4 mr-2" />
Edit
</Button>
) : (
<div className="flex items-center">
<Button size="sm" variant="default" className="rounded-l-lg rounded-r-none" onClick={requestSaveAndDeploy} disabled={loadingAction === 'deploy'}>
<Rocket className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save & Deploy
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="default" className="rounded-r-lg rounded-l-none border-l border-primary-foreground/20 px-1.5" disabled={loadingAction === 'deploy'}>
<ChevronDown className="w-3.5 h-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={requestSave}>
<Save className="w-4 h-4 mr-2" strokeWidth={1.5} />
Save Only
</DropdownMenuItem>
<DropdownMenuItem onClick={discardChanges} className="text-destructive/80 focus:text-destructive">
<X className="w-4 h-4 mr-2" strokeWidth={1.5} />
Discard Changes
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</>
)}
{activeTab === 'files' && (
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => setFilesFullscreen((v) => !v)}
aria-label={filesFullscreen ? 'Exit full screen' : 'Full screen'}
title={filesFullscreen ? 'Exit full screen' : 'Full screen'}
>
{filesFullscreen
? <Minimize2 className="w-4 h-4" strokeWidth={1.5} />
: <Maximize2 className="w-4 h-4" strokeWidth={1.5} />}
</Button>
)}
<Button
size="sm"
variant="ghost"
className="rounded-lg h-8 w-8 p-0"
onClick={() => {
if (isEditing) {
discardChanges();
}
setEditingCompose(false);
}}
aria-label="Close editor"
>
<X className="w-4 h-4" strokeWidth={1.5} />
</Button>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col">
{activeTab === 'files' && canRead ? (
<StackFileExplorer
stackName={stackName}
canEdit={can('stack:edit', 'stack', stackName)}
isDarkMode={isDarkMode}
onNavigateToCompose={() => setActiveTab('compose')}
onNavigateToEnv={() => setActiveTab('env')}
/>
) : (
<>
{activeTab === 'env' && (
<div className="bg-brand/8 border-b border-brand/20 px-4 py-2 flex items-center gap-2 text-xs text-brand">
<span>
Variables defined in the project environment file are available for substitution in your compose.yaml (e.g., <code className="bg-background px-1 rounded text-[10px]">${'{}'}VAR</code>). To pass them directly into your container, add <code className="bg-background px-1 rounded text-[10px]">env_file: - .env</code> to your service definition.
</span>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{!isFileLoading && (
<Suspense fallback={<div className="w-full h-full" aria-busy="true" />}>
<Editor
height="100%"
language={activeTab === 'compose' ? 'yaml' : 'ini'}
theme={isDarkMode ? 'vs-dark' : 'vs'}
value={activeTab === 'compose' ? safeContent : safeEnvContent}
onMount={(editor) => { 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),
}}
/>
</Suspense>
)}
{isFileLoading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
</div>
</>
)}
</div>
</Card>
) : (
<StackAnatomyPanel
stackName={stackName}
content={content}
envContent={envContent}
selectedEnvFile={selectedEnvFile}
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
onEditCompose={() => { setEditingCompose(true); setActiveTab('compose'); }}
onOpenFiles={canRead ? () => { setEditingCompose(true); setActiveTab('files'); } : undefined}
onOpenGitSource={() => setGitSourceOpen(true)}
onApplyUpdate={() => { void updateStack(); }}
applying={loadingAction === 'update'}
canEdit={can('stack:edit', 'stack', stackName)}
notifications={notifications}
stackMuteActions={stackMuteActions}
/>
)}
</div>
</ErrorBoundary>
);
}