mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 00:47:52 +00:00
1cf996142b
* refactor(frontend): extract useOverlayState hook from EditorLayout * refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout * refactor(frontend): fix quality issues in useStackActions post-review * fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions * refactor(frontend): extract useSidebarContextMenu hook from EditorLayout * refactor(frontend): extract ShellOverlays component from EditorLayout * refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout The Monaco tab-switch layout effect is now self-contained in EditorView, alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves into useOverlayState, where openLogViewer lives. EditorLayout is left with the two coordination effects that depend on cross-hook state.
838 lines
52 KiB
TypeScript
838 lines
52 KiB
TypeScript
import { Suspense, useRef, useEffect } 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<string, ContainerStatsEntry>;
|
||
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;
|
||
isPaid: 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;
|
||
}
|
||
|
||
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,
|
||
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 monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||
|
||
// 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');
|
||
|
||
return (
|
||
<ErrorBoundary>
|
||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
|
||
{/* Left column: identity + health strip + logs, stacked */}
|
||
<div className="flex flex-col gap-6 min-h-0">
|
||
{/* Command Center Card (identity + health strip) */}
|
||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||
<CardHeader className="p-4 pb-2">
|
||
<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-display italic 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>
|
||
{(() => {
|
||
const first = safeContainers[0];
|
||
if (!first?.Image) return null;
|
||
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
|
||
return (
|
||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
|
||
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
|
||
{digest && first.ImageID && (
|
||
<>
|
||
<span className="text-muted-foreground/60">·</span>
|
||
<span>digest <span className="text-foreground/90">{digest}</span></span>
|
||
<button
|
||
type="button"
|
||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
||
onClick={() => {
|
||
const id = first.ImageID as string;
|
||
void copyToClipboard(id).then(() => {
|
||
setCopiedDigest(id);
|
||
if (copiedDigestTimerRef.current !== null) {
|
||
window.clearTimeout(copiedDigestTimerRef.current);
|
||
}
|
||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
||
setCopiedDigest(prev => (prev === id ? null : prev));
|
||
copiedDigestTimerRef.current = null;
|
||
}, 1500);
|
||
}).catch(() => { /* clipboard unavailable */ });
|
||
}}
|
||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||
>
|
||
{copiedDigest === first.ImageID ? (
|
||
<Check className="h-3 w-3" strokeWidth={2} />
|
||
) : (
|
||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||
)}
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
{/* Action Bar */}
|
||
{can('stack:deploy', 'stack', stackName) && (
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{isRunning ? (
|
||
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg 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 bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
|
||
<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" onClick={stopStack} disabled={loadingAction !== null}>
|
||
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
|
||
</Button>
|
||
)}
|
||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
|
||
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||
</Button>
|
||
{(() => {
|
||
const canRollback = isPaid && backupInfo.exists;
|
||
const canScan = trivy.available && isAdmin && isPaid;
|
||
const hasOverflowExtras = canRollback || canScan;
|
||
return (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0" 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}>
|
||
<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>
|
||
)}
|
||
{hasOverflowExtras && <DropdownMenuSeparator />}
|
||
<DropdownMenuItem
|
||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||
disabled={loadingAction !== null}
|
||
onClick={requestDeleteStack}
|
||
>
|
||
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
);
|
||
})()}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardHeader>
|
||
<CardContent className="p-4 pt-2">
|
||
{/* Per-container health strip */}
|
||
<div className="mt-4">
|
||
<h4 className="text-sm font-medium text-muted-foreground mb-3">CONTAINERS</h4>
|
||
{safeContainers.length === 0 ? (
|
||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||
) : (
|
||
<div className="flex flex-col gap-2">
|
||
{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 (
|
||
<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>
|
||
<span>{mainPort} → {mainPortPrivate}/{mainPortProto}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||
? new URL(activeNode.api_url).hostname
|
||
: window.location.hostname;
|
||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||
}}
|
||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||
>
|
||
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
|
||
</button>
|
||
</>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-1">
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-7 w-7 rounded-md"
|
||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||
disabled={!isActive}
|
||
aria-label="View logs"
|
||
>
|
||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||
</Button>
|
||
{isAdmin && (
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-7 w-7 rounded-md"
|
||
onClick={() => openBashModal(container?.Id, containerName)}
|
||
disabled={!isActive}
|
||
aria-label="Open bash shell"
|
||
>
|
||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||
</Button>
|
||
)}
|
||
{container.Service && (
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button
|
||
size="icon"
|
||
variant="ghost"
|
||
className="h-7 w-7 rounded-md"
|
||
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 ? (
|
||
<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>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Logs Section (fills remaining left-column height) */}
|
||
<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} />
|
||
</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>
|
||
</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>
|
||
<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>
|
||
)}
|
||
</>
|
||
)}
|
||
<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' ? (
|
||
<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 here are automatically 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, you must 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' : 'plaintext'}
|
||
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)}
|
||
onOpenFiles={() => { setEditingCompose(true); setActiveTab('files'); }}
|
||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||
onApplyUpdate={() => { void updateStack(); }}
|
||
canEdit={can('stack:edit', 'stack', stackName)}
|
||
notifications={notifications}
|
||
/>
|
||
)}
|
||
</div>
|
||
</ErrorBoundary>
|
||
);
|
||
}
|