mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-03 23:47:46 +00:00
a65a1c0e86
* feat(stack-view): per-container health strip and structured logs viewer Replaces the flat container list with a per-container health strip showing healthcheck state, uptime, port mapping with an open-app link, and live cpu/memory/network sparklines fed by a 60-sample ring buffer on the stats WebSocket. Adds a structured logs viewer that parses docker timestamps (emitted by the -t flag on the logs stream) and classifies each line by level. Rows render as a DOM grid with filter pills (all / info / warn / err with count), following indicator, and plain-text download. A segmented toggle switches between the structured viewer and the original xterm view; the choice is persisted in localStorage. * fix(stack-view): disable no-control-regex for ANSI escape pattern ANSI escape sequences start with ESC (0x1B), which is a control character. The regex is intentional and cannot be rewritten without it.
3307 lines
153 KiB
TypeScript
3307 lines
153 KiB
TypeScript
import { useState, useEffect, useRef, useMemo } from 'react';
|
||
|
||
type Theme = 'light' | 'dark' | 'auto';
|
||
import Editor from '@monaco-editor/react';
|
||
import TerminalComponent from './Terminal';
|
||
import ErrorBoundary from './ErrorBoundary';
|
||
import HomeDashboard from './HomeDashboard';
|
||
import type { NotificationItem } from './dashboard/types';
|
||
import BashExecModal from './BashExecModal';
|
||
import HostConsole from './HostConsole';
|
||
import { AdmiralGate } from './AdmiralGate';
|
||
import { CapabilityGate } from './CapabilityGate';
|
||
import ResourcesView from './ResourcesView';
|
||
import { Button } from './ui/button';
|
||
import { Input } from './ui/input';
|
||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, DialogTrigger } from './ui/dialog';
|
||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog';
|
||
import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs';
|
||
import { springs } from '@/lib/motion';
|
||
import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight';
|
||
import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor';
|
||
import { Card, CardContent, CardHeader, CardTitle } from './ui/card';
|
||
import { Badge } from './ui/badge';
|
||
import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react';
|
||
import type { LucideIcon } from 'lucide-react';
|
||
import { LabelPill, LabelDot } from './LabelPill';
|
||
import { type Label as StackLabel } from './label-types';
|
||
import { LabelAssignPopover } from './LabelAssignPopover';
|
||
import { UserProfileDropdown } from './UserProfileDropdown';
|
||
import { apiFetch, fetchForNode } from '@/lib/api';
|
||
import { isValidVersion } from '@/lib/version';
|
||
import { toast } from '@/components/ui/toast-store';
|
||
import { Label } from './ui/label';
|
||
import { Command, CommandInput, CommandList, CommandItem } from './ui/command';
|
||
import { ScrollArea } from './ui/scroll-area';
|
||
import { Checkbox } from './ui/checkbox';
|
||
import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields';
|
||
import { Skeleton } from './ui/skeleton';
|
||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Sheet, SheetContent, SheetTrigger } from './ui/sheet';
|
||
import { cn } from '@/lib/utils';
|
||
import { SettingsModal } from './SettingsModal';
|
||
import { StackAlertSheet } from './StackAlertSheet';
|
||
import { StackAutoHealSheet } from '@/components/StackAutoHealSheet';
|
||
import { GitSourcePanel } from './stack/GitSourcePanel';
|
||
import { AppStoreView } from './AppStoreView';
|
||
import { LogViewer } from './LogViewer';
|
||
import StructuredLogViewer from './StructuredLogViewer';
|
||
import { Sparkline } from './ui/sparkline';
|
||
import { GlobalObservabilityView } from './GlobalObservabilityView';
|
||
import { FleetView } from './FleetView';
|
||
import { AuditLogView } from './AuditLogView';
|
||
import ScheduledOperationsView from './ScheduledOperationsView';
|
||
import AutoUpdateReadinessView from './AutoUpdateReadinessView';
|
||
import { SecurityHistoryView } from './SecurityHistoryView';
|
||
import { SENCHO_NAVIGATE_EVENT } from './NodeManager';
|
||
import type { SenchoNavigateDetail } from './NodeManager';
|
||
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
||
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
||
import { useNodes } from '@/context/NodeContext';
|
||
import type { Node } from '@/context/NodeContext';
|
||
import { useAuth } from '@/context/AuthContext';
|
||
import { useLicense } from '@/context/LicenseContext';
|
||
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
||
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
|
||
|
||
interface ContainerInfo {
|
||
Id: string;
|
||
Names: 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';
|
||
}
|
||
|
||
interface StackStatusInfo {
|
||
status: 'running' | 'exited' | 'unknown';
|
||
mainPort?: number;
|
||
}
|
||
|
||
type StackAction = 'deploy' | 'stop' | 'restart' | 'update' | 'delete' | 'rollback';
|
||
|
||
interface BulkActionResult {
|
||
stackName: string;
|
||
success: boolean;
|
||
error?: string;
|
||
}
|
||
|
||
|
||
const formatBytes = (bytes: number) => {
|
||
if (bytes === 0) return '0 B';
|
||
const k = 1024;
|
||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||
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();
|
||
const { status: trivy } = useTrivyStatus();
|
||
const [stackMisconfigScanning, setStackMisconfigScanning] = useState(false);
|
||
const [stackMisconfigScanId, setStackMisconfigScanId] = useState<number | null>(null);
|
||
const [copiedDigest, setCopiedDigest] = useState<string | null>(null);
|
||
const copiedDigestTimerRef = useRef<number | null>(null);
|
||
useEffect(() => {
|
||
return () => {
|
||
if (copiedDigestTimerRef.current !== null) {
|
||
window.clearTimeout(copiedDigestTimerRef.current);
|
||
}
|
||
};
|
||
}, []);
|
||
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
|
||
// Stable ref so notification callbacks always read the latest nodes list
|
||
// without needing nodes in their dependency arrays (which would cause loops).
|
||
const nodesRef = useRef<Node[]>([]);
|
||
nodesRef.current = nodes;
|
||
// Tracks cleanup functions for per-remote-node notification WebSocket connections.
|
||
const remoteNotifWsRef = useRef<Map<number, () => void>>(new Map());
|
||
const [files, setFiles] = useState<string[]>([]);
|
||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||
const [content, setContent] = useState<string>('');
|
||
const [originalContent, setOriginalContent] = useState<string>('');
|
||
const [envContent, setEnvContent] = useState<string>('');
|
||
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
|
||
const [envExists, setEnvExists] = useState<boolean>(false);
|
||
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||
const [containerStats, setContainerStats] = useState<Record<string, {
|
||
cpu: string;
|
||
ram: string;
|
||
net: string;
|
||
lastRx?: number;
|
||
lastTx?: number;
|
||
history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] };
|
||
}>>({});
|
||
// Incoming WebSocket stats are written here first (no re-render), then flushed
|
||
// to React state in one batched update every 1.5 s.
|
||
const pendingStatsRef = useRef<Record<string, {
|
||
cpu: string;
|
||
ram: string;
|
||
net: string;
|
||
lastRx: number;
|
||
lastTx: number;
|
||
cpuNum: number;
|
||
memNum: number;
|
||
netInNum: number;
|
||
netOutNum: number;
|
||
}>>({});
|
||
// Raw rx/tx byte totals used for rate calculation. Never cleared on flush so
|
||
// the delta is always computed against the most recent known value, avoiding
|
||
// the stale-closure bug that occurs when reading containerStats directly.
|
||
const rawBytesRef = useRef<Record<string, { lastRx: number; lastTx: number }>>({});
|
||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||
const [logsMode, setLogsMode] = useState<'structured' | 'raw'>(() => {
|
||
if (typeof window === 'undefined') return 'structured';
|
||
return (localStorage.getItem('sencho.stackView.logsMode') as 'structured' | 'raw' | null) ?? 'structured';
|
||
});
|
||
useEffect(() => {
|
||
try { localStorage.setItem('sencho.stackView.logsMode', logsMode); } catch { /* ignore */ }
|
||
}, [logsMode]);
|
||
const [gitSourceOpen, setGitSourceOpen] = useState(false);
|
||
const [gitSourcePendingMap, setGitSourcePendingMap] = useState<Record<string, boolean>>({});
|
||
const monacoEditorRef = useRef<import('monaco-editor').editor.IStandaloneCodeEditor | null>(null);
|
||
const pendingStackLoadRef = useRef<string | null>(null);
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty');
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [newStackName, setNewStackName] = useState('');
|
||
// "From Docker Run" tab state
|
||
const [dockerRunInput, setDockerRunInput] = useState('');
|
||
const [convertedYaml, setConvertedYaml] = useState<string | null>(null);
|
||
const [isConverting, setIsConverting] = useState(false);
|
||
const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false);
|
||
// "From Git" tab state
|
||
const [gitRepoUrl, setGitRepoUrl] = useState('');
|
||
const [gitBranch, setGitBranch] = useState('main');
|
||
const [gitComposePath, setGitComposePath] = useState('compose.yaml');
|
||
const [gitSyncEnv, setGitSyncEnv] = useState(false);
|
||
const [gitAuthType, setGitAuthType] = useState<'none' | 'token'>('none');
|
||
const [gitToken, setGitToken] = useState('');
|
||
const [gitApplyMode, setGitApplyMode] = useState<ApplyMode>('review');
|
||
const [gitDeployNow, setGitDeployNow] = useState(false);
|
||
const [creatingFromGit, setCreatingFromGit] = useState(false);
|
||
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
|
||
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
|
||
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
|
||
const stackActionsRef = useRef<Record<string, StackAction>>({});
|
||
stackActionsRef.current = stackActions;
|
||
|
||
const setStackAction = (stackFile: string, action: StackAction) => {
|
||
setStackActions(prev => ({ ...prev, [stackFile]: action }));
|
||
};
|
||
const clearStackAction = (stackFile: string) => {
|
||
setStackActions(prev => {
|
||
const next = { ...prev };
|
||
delete next[stackFile];
|
||
return next;
|
||
});
|
||
};
|
||
const isStackBusy = (stackFile: string) => stackFile in stackActions;
|
||
|
||
const getStackMenuVisibility = (file: string) => {
|
||
const status = stackStatuses[file];
|
||
return {
|
||
showDeploy: status !== 'running',
|
||
showStop: status === 'running',
|
||
showRestart: status === 'running',
|
||
showUpdate: status === 'running',
|
||
};
|
||
};
|
||
|
||
const openStackApp = (file: string) => {
|
||
const port = stackPorts[file];
|
||
if (!port) return;
|
||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||
? new URL(activeNode.api_url).hostname
|
||
: window.location.hostname;
|
||
window.open(`http://${host}:${port}`, '_blank');
|
||
};
|
||
|
||
const loadingAction = selectedFile ? (stackActions[selectedFile] ?? null) : null;
|
||
|
||
const [isScanning, setIsScanning] = useState(false);
|
||
const [isFileLoading, setIsFileLoading] = useState(false);
|
||
const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null });
|
||
const [theme, setTheme] = useState<Theme>(() => {
|
||
const saved = localStorage.getItem('sencho-theme') as Theme | null;
|
||
if (saved === 'light' || saved === 'dark' || saved === 'auto') return saved;
|
||
return 'dark'; // Default to dark mode
|
||
});
|
||
const [systemDark, setSystemDark] = useState(() =>
|
||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||
);
|
||
const isDarkMode = theme === 'dark' || (theme === 'auto' && systemDark);
|
||
const [activeView, setActiveView] = useState<'dashboard' | 'editor' | 'host-console' | 'resources' | 'templates' | 'global-observability' | 'fleet' | 'audit-log' | 'scheduled-ops' | 'auto-updates' | 'security-history'>('dashboard');
|
||
const [filterNodeId, setFilterNodeId] = useState<number | null>(null);
|
||
const [isEditing, setIsEditing] = useState(false);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [remoteStackResults, setRemoteStackResults] = useState<Record<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>>>({});
|
||
const [remoteSearchLoading, setRemoteSearchLoading] = useState(false);
|
||
const [stackStatuses, setStackStatuses] = useState<StackStatus>({});
|
||
const [stackPorts, setStackPorts] = useState<Record<string, number | undefined>>({});
|
||
const [labels, setLabels] = useState<StackLabel[]>([]);
|
||
const [stackLabelMap, setStackLabelMap] = useState<Record<string, StackLabel[]>>({});
|
||
const [activeLabelFilters, setActiveLabelFilters] = useState<Set<number>>(new Set());
|
||
const [bulkActionLabel, setBulkActionLabel] = useState<StackLabel | null>(null);
|
||
const [bulkAction, setBulkAction] = useState<string>('');
|
||
const [bulkActionOpen, setBulkActionOpen] = useState(false);
|
||
const [bulkActionRunning, setBulkActionRunning] = useState(false);
|
||
|
||
// Bash exec modal state
|
||
const [bashModalOpen, setBashModalOpen] = useState(false);
|
||
const [selectedContainer, setSelectedContainer] = useState<{ id: string; name: string } | null>(null);
|
||
|
||
// LogViewer state
|
||
const [logViewerOpen, setLogViewerOpen] = useState(false);
|
||
const [logContainer, setLogContainer] = useState<{ id: string; name: string } | null>(null);
|
||
|
||
|
||
// Image update checker state
|
||
const [stackUpdates, setStackUpdates] = useState<Record<string, boolean>>({});
|
||
|
||
// Notifications & Settings state
|
||
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
|
||
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
|
||
const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account');
|
||
const [alertSheetOpen, setAlertSheetOpen] = useState(false);
|
||
const [alertSheetStack, setAlertSheetStack] = useState('');
|
||
const [autoHealStackName, setAutoHealStackName] = useState<string | null>(null);
|
||
|
||
// Mobile navigation sheet state
|
||
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
||
|
||
const openAlertSheet = (stackName: string) => {
|
||
setAlertSheetStack(stackName);
|
||
setAlertSheetOpen(true);
|
||
};
|
||
|
||
// Navigation items (permission-aware, data-driven)
|
||
const navItems = useMemo(() => {
|
||
const items: Array<{ value: string; label: string; icon: LucideIcon }> = [
|
||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||
{ value: 'fleet', label: 'Fleet', icon: Radar },
|
||
];
|
||
items.push(
|
||
{ value: 'resources', label: 'Resources', icon: HardDrive },
|
||
{ value: 'templates', label: 'App Store', icon: CloudDownload },
|
||
{ value: 'global-observability', label: 'Logs', icon: Activity },
|
||
);
|
||
if (isPaid && isAdmin) {
|
||
items.push({ value: 'auto-updates', label: 'Auto-Update', icon: RefreshCw });
|
||
}
|
||
if (isPaid && license?.variant === 'admiral') {
|
||
if (isAdmin) items.push({ value: 'host-console', label: 'Console', icon: Terminal });
|
||
if (can('system:audit')) items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText });
|
||
if (isAdmin) items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock });
|
||
}
|
||
return items;
|
||
}, [isAdmin, isPaid, license?.variant, can]);
|
||
|
||
// Only highlight a tab if activeView matches a nav item
|
||
const navTabValue = navItems.some(i => i.value === activeView) ? activeView : undefined;
|
||
|
||
// Reset editor state (extracted from Home button onClick)
|
||
const resetEditorState = () => {
|
||
setSelectedFile(null);
|
||
setContent('');
|
||
setOriginalContent('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setEnvFiles([]);
|
||
setSelectedEnvFile('');
|
||
setEnvExists(false);
|
||
setContainers([]);
|
||
setIsEditing(false);
|
||
};
|
||
|
||
const handleNavigate = (value: string) => {
|
||
if (value === activeView) return;
|
||
if (value === 'dashboard') {
|
||
resetEditorState();
|
||
setActiveView('dashboard');
|
||
} else {
|
||
setActiveView(value as typeof activeView);
|
||
setFilterNodeId(null);
|
||
}
|
||
};
|
||
|
||
// Listen for system dark mode changes (for 'auto' theme)
|
||
useEffect(() => {
|
||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||
const handler = (e: MediaQueryListEvent) => setSystemDark(e.matches);
|
||
mq.addEventListener('change', handler);
|
||
return () => mq.removeEventListener('change', handler);
|
||
}, []);
|
||
|
||
// Apply dark class and persist theme preference
|
||
useEffect(() => {
|
||
document.documentElement.classList.toggle('dark', isDarkMode);
|
||
localStorage.setItem('sencho-theme', theme);
|
||
}, [isDarkMode, theme]);
|
||
|
||
// ⌘K / Ctrl+K — focus stack search input
|
||
useEffect(() => {
|
||
const handler = (e: KeyboardEvent) => {
|
||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||
e.preventDefault();
|
||
const input = document.querySelector<HTMLInputElement>('[cmdk-input]');
|
||
input?.focus();
|
||
}
|
||
};
|
||
window.addEventListener('keydown', handler);
|
||
return () => window.removeEventListener('keydown', handler);
|
||
}, []);
|
||
|
||
// Listen for cross-component navigation (e.g., NodeManager → Schedules)
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const detail = (e as CustomEvent<SenchoNavigateDetail>).detail;
|
||
if (detail?.view) {
|
||
setActiveView(detail.view);
|
||
setFilterNodeId(detail.nodeId ?? null);
|
||
}
|
||
};
|
||
window.addEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||
return () => window.removeEventListener(SENCHO_NAVIGATE_EVENT, handler);
|
||
}, []);
|
||
|
||
// Global stack search: when the user types a query, fan out to every other online
|
||
// node and fetch its stack list so the sidebar can surface matches from the whole
|
||
// fleet. Debounced 250ms; cleared as soon as the query is empty.
|
||
useEffect(() => {
|
||
const query = searchQuery.trim().toLowerCase();
|
||
if (!query) {
|
||
setRemoteStackResults({});
|
||
setRemoteSearchLoading(false);
|
||
return;
|
||
}
|
||
const otherNodes = nodes.filter(n => n.id !== activeNode?.id && n.status !== 'offline');
|
||
if (otherNodes.length === 0) {
|
||
setRemoteStackResults({});
|
||
return;
|
||
}
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(async () => {
|
||
setRemoteSearchLoading(true);
|
||
try {
|
||
const entries = await Promise.all(otherNodes.map(async (node) => {
|
||
const empty = [] as Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>;
|
||
try {
|
||
const [listRes, statusRes] = await Promise.all([
|
||
fetchForNode('/stacks', node.id, { signal: controller.signal }),
|
||
fetchForNode('/stacks/statuses', node.id, { signal: controller.signal }),
|
||
]);
|
||
if (!listRes.ok) return [node.id, empty] as const;
|
||
const listData = await listRes.json();
|
||
const list: string[] = Array.isArray(listData) ? listData : [];
|
||
const statuses: Record<string, 'running' | 'exited' | 'unknown'> = {};
|
||
if (statusRes.ok) {
|
||
const raw = await statusRes.json();
|
||
for (const [key, val] of Object.entries(raw)) {
|
||
if (typeof val === 'string') {
|
||
statuses[key] = val as 'running' | 'exited' | 'unknown';
|
||
} else if (val && typeof val === 'object' && 'status' in val) {
|
||
statuses[key] = (val as StackStatusInfo).status;
|
||
}
|
||
}
|
||
}
|
||
const matches = list
|
||
.filter(f => f.toLowerCase().includes(query))
|
||
.map(file => ({ file, status: statuses[file] ?? 'unknown' as const }));
|
||
return [node.id, matches] as const;
|
||
} catch {
|
||
return [node.id, empty] as const;
|
||
}
|
||
}));
|
||
if (controller.signal.aborted) return;
|
||
const next: Record<number, Array<{ file: string; status: 'running' | 'exited' | 'unknown' }>> = {};
|
||
for (const [id, matches] of entries) {
|
||
if (matches.length > 0) next[id] = matches;
|
||
}
|
||
setRemoteStackResults(next);
|
||
} finally {
|
||
if (!controller.signal.aborted) setRemoteSearchLoading(false);
|
||
}
|
||
}, 250);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [searchQuery, activeNode?.id, nodes]);
|
||
|
||
// 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 0×0 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 refreshStacks = async (background = false): Promise<string[]> => {
|
||
if (!background) setIsLoading(true);
|
||
try {
|
||
const res = await apiFetch('/stacks');
|
||
if (!res.ok) {
|
||
setFiles([]);
|
||
return [];
|
||
}
|
||
const data = await res.json();
|
||
const fileList: string[] = Array.isArray(data) ? data : [];
|
||
setFiles(fileList);
|
||
|
||
// Fetch all stack statuses in a single bulk call (falls back to per-stack queries for older remote nodes)
|
||
const statusRes = await apiFetch('/stacks/statuses');
|
||
let bulkStatuses: Record<string, 'running' | 'exited' | 'unknown'> | null = null;
|
||
const bulkPorts: Record<string, number | undefined> = {};
|
||
if (statusRes.ok) {
|
||
const raw = await statusRes.json();
|
||
bulkStatuses = {};
|
||
// Handle both old format (plain string) and new format ({ status, mainPort })
|
||
for (const [key, val] of Object.entries(raw)) {
|
||
if (typeof val === 'string') {
|
||
bulkStatuses[key] = val as 'running' | 'exited' | 'unknown';
|
||
} else if (val && typeof val === 'object' && 'status' in val) {
|
||
const info = val as StackStatusInfo;
|
||
bulkStatuses[key] = info.status;
|
||
if (info.mainPort) bulkPorts[key] = info.mainPort;
|
||
}
|
||
}
|
||
} else {
|
||
// Fallback: query each stack individually (remote node may not have bulk endpoint)
|
||
const statusResults = await Promise.allSettled(
|
||
fileList.map(async (file) => {
|
||
const containersRes = await apiFetch(`/stacks/${file}/containers`);
|
||
if (!containersRes.ok) return { file, status: 'unknown' as const };
|
||
const containers = await containersRes.json();
|
||
const hasRunning = Array.isArray(containers) && containers.some((c: ContainerInfo) => c.State === 'running');
|
||
return { file, status: hasRunning ? 'running' as const : (Array.isArray(containers) && containers.length > 0 ? 'exited' as const : 'unknown' as const) };
|
||
})
|
||
);
|
||
bulkStatuses = {};
|
||
for (const result of statusResults) {
|
||
if (result.status === 'fulfilled') {
|
||
bulkStatuses[result.value.file] = result.value.status;
|
||
}
|
||
}
|
||
}
|
||
setStackStatuses(prev => {
|
||
const next: StackStatus = {};
|
||
for (const file of fileList) {
|
||
const status = bulkStatuses?.[file] ?? 'unknown';
|
||
next[file] = (file in stackActionsRef.current) ? (prev[file] ?? status) : status;
|
||
}
|
||
return next;
|
||
});
|
||
setStackPorts(prev => {
|
||
const keys = Object.keys(bulkPorts);
|
||
if (keys.length === Object.keys(prev).length && keys.every(k => prev[k] === bulkPorts[k])) return prev;
|
||
return bulkPorts;
|
||
});
|
||
refreshLabels();
|
||
return fileList;
|
||
} catch (error) {
|
||
console.error('Failed to refresh stacks:', error);
|
||
setFiles([]);
|
||
return [];
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const setOptimisticStatus = (stackFile: string, status: 'running' | 'exited') => {
|
||
setStackStatuses(prev => ({ ...prev, [stackFile]: status }));
|
||
};
|
||
|
||
const refreshLabels = async () => {
|
||
if (!isPaid) return;
|
||
try {
|
||
const [labelsRes, assignmentsRes] = await Promise.all([
|
||
apiFetch('/labels'),
|
||
apiFetch('/labels/assignments'),
|
||
]);
|
||
if (labelsRes.ok) setLabels(await labelsRes.json());
|
||
if (assignmentsRes.ok) setStackLabelMap(await assignmentsRes.json());
|
||
} catch {
|
||
// Labels are non-critical; fail silently
|
||
}
|
||
};
|
||
|
||
/**
|
||
* Populate the per-stack "pending git source update" map. Runs on mount and
|
||
* whenever a git-source change is signalled by the panel. Backend failure
|
||
* leaves the map empty, which is the correct fallback (no badges shown).
|
||
*/
|
||
const refreshGitSourcePending = async () => {
|
||
try {
|
||
const res = await apiFetch('/git-sources');
|
||
if (!res.ok) return;
|
||
const sources: Array<{ stack_name: string; pending_commit_sha: string | null }> = await res.json();
|
||
const map: Record<string, boolean> = {};
|
||
for (const s of sources) {
|
||
if (s.pending_commit_sha) map[s.stack_name] = true;
|
||
}
|
||
setGitSourcePendingMap(map);
|
||
} catch {
|
||
// Non-critical; leave prior state.
|
||
}
|
||
};
|
||
|
||
const handleScanStacks = async () => {
|
||
if (isScanning) return;
|
||
setIsScanning(true);
|
||
const previousStacks = [...files];
|
||
try {
|
||
const currentStacks = await refreshStacks();
|
||
const added = currentStacks.filter(s => !previousStacks.includes(s));
|
||
const removed = previousStacks.filter(s => !currentStacks.includes(s));
|
||
|
||
if (added.length > 0) {
|
||
toast.success(`Found ${added.length} new stack${added.length !== 1 ? 's' : ''}: ${added.join(', ')}`);
|
||
}
|
||
if (removed.length > 0) {
|
||
toast.info(`${removed.length} stack${removed.length !== 1 ? 's' : ''} no longer detected: ${removed.join(', ')}`);
|
||
}
|
||
if (added.length === 0 && removed.length === 0) {
|
||
toast.info('No new stacks found.');
|
||
}
|
||
} catch (error: unknown) {
|
||
const err = error as Record<string, unknown>;
|
||
const data = err?.data as Record<string, unknown> | undefined;
|
||
toast.error((err?.message as string) || (err?.error as string) || (data?.error as string) || 'Something went wrong.');
|
||
} finally {
|
||
setIsScanning(false);
|
||
}
|
||
};
|
||
|
||
// Notification WS push - subscribe to local real-time alerts.
|
||
// Initial history load is handled by the [nodes] effect below.
|
||
useEffect(() => {
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const wsBase = `${wsProtocol}//${window.location.host}`;
|
||
let ws: WebSocket | null = null;
|
||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let isMounted = true;
|
||
let retryCount = 0;
|
||
const MAX_RETRY_DELAY_MS = 30000;
|
||
|
||
const connect = () => {
|
||
if (!isMounted) return;
|
||
ws = new WebSocket(`${wsBase}/ws/notifications`);
|
||
|
||
ws.onopen = () => {
|
||
if (!isMounted) {
|
||
// Component unmounted while the handshake was in-flight (React StrictMode double-mount)
|
||
ws?.close();
|
||
return;
|
||
}
|
||
retryCount = 0; // Reset backoff on successful connect
|
||
};
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data as string);
|
||
if (msg.type === 'notification' && msg.payload) {
|
||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||
const tagged: NotificationItem = {
|
||
...(msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>),
|
||
nodeId: localNode?.id ?? -1,
|
||
nodeName: localNode?.name ?? 'Local',
|
||
};
|
||
setNotifications(prev => [tagged, ...prev].sort((a, b) => b.timestamp - a.timestamp));
|
||
}
|
||
} catch (e) {
|
||
console.error('[WS notifications] parse error', e);
|
||
}
|
||
};
|
||
|
||
ws.onclose = (event) => {
|
||
if (!isMounted) return;
|
||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max
|
||
const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS);
|
||
retryCount++;
|
||
console.debug(`[WS notifications] closed (code=${event.code}), reconnecting in ${delay}ms (attempt ${retryCount})`);
|
||
reconnectTimer = setTimeout(connect, delay);
|
||
};
|
||
|
||
ws.onerror = (event) => {
|
||
// onerror always fires before onclose - log it and let onclose handle reconnect
|
||
console.warn('[WS notifications] error event', event);
|
||
};
|
||
};
|
||
|
||
connect();
|
||
|
||
return () => {
|
||
isMounted = false;
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
// Only close an already-open connection. If still CONNECTING, let onopen
|
||
// detect isMounted=false and close then - avoids the browser warning
|
||
// "WebSocket is closed before the connection is established".
|
||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||
ws.close();
|
||
}
|
||
};
|
||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Re-fetch all notifications when the nodes list changes (e.g. remote node added/removed).
|
||
// nodesRef ensures fetchNotifications always reads the latest nodes at call time.
|
||
useEffect(() => {
|
||
fetchNotifications();
|
||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Open / close per-remote-node notification WebSocket connections as the nodes list changes.
|
||
// Uses remoteNotifWsRef to avoid tearing down existing connections on unrelated node updates.
|
||
useEffect(() => {
|
||
const remoteNodes = nodes.filter(n => n.type === 'remote');
|
||
const currentIds = new Set(remoteNotifWsRef.current.keys());
|
||
const newIds = new Set(remoteNodes.map(n => n.id));
|
||
|
||
// Close connections for nodes that are no longer registered as remote
|
||
for (const id of currentIds) {
|
||
if (!newIds.has(id)) {
|
||
remoteNotifWsRef.current.get(id)?.();
|
||
remoteNotifWsRef.current.delete(id);
|
||
}
|
||
}
|
||
|
||
// Open connections for newly-added remote nodes
|
||
for (const rn of remoteNodes) {
|
||
if (remoteNotifWsRef.current.has(rn.id)) continue;
|
||
|
||
let ws: WebSocket | null = null;
|
||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||
let active = true;
|
||
let retryCount = 0;
|
||
|
||
const connect = () => {
|
||
if (!active) return;
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws/notifications?nodeId=${rn.id}`);
|
||
|
||
ws.onopen = () => { if (!active) { ws?.close(); } else { retryCount = 0; } };
|
||
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const msg = JSON.parse(event.data as string);
|
||
if (msg.type === 'notification' && msg.payload) {
|
||
// Read node name from ref so it stays fresh even if the node was renamed
|
||
const current = nodesRef.current.find(n => n.id === rn.id);
|
||
setNotifications(prev =>
|
||
[{ ...msg.payload as Omit<NotificationItem, 'nodeId' | 'nodeName'>, nodeId: rn.id, nodeName: current?.name ?? rn.name }, ...prev]
|
||
.sort((a, b) => b.timestamp - a.timestamp)
|
||
);
|
||
}
|
||
} catch (e) {
|
||
console.error(`[WS notifications:${rn.name}] parse error`, e);
|
||
}
|
||
};
|
||
|
||
ws.onclose = () => {
|
||
if (!active) return;
|
||
const delay = Math.min(1000 * Math.pow(2, retryCount), 30000);
|
||
retryCount++;
|
||
reconnectTimer = setTimeout(connect, delay);
|
||
};
|
||
|
||
ws.onerror = (e) => console.warn(`[WS notifications:${rn.name}] error`, e);
|
||
};
|
||
|
||
connect();
|
||
|
||
remoteNotifWsRef.current.set(rn.id, () => {
|
||
active = false;
|
||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||
if (ws && ws.readyState === WebSocket.OPEN) ws.close();
|
||
});
|
||
}
|
||
}, [nodes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
// Cleanup all remote notification WebSocket connections on unmount
|
||
useEffect(() => {
|
||
return () => {
|
||
for (const cleanup of remoteNotifWsRef.current.values()) cleanup();
|
||
remoteNotifWsRef.current.clear();
|
||
};
|
||
}, []);
|
||
|
||
// Re-fetch stacks whenever the active node changes (or becomes available on mount).
|
||
// Also clears any stale editor/container state that belonged to the previous node.
|
||
useEffect(() => {
|
||
if (!activeNode) return;
|
||
const pendingStack = pendingStackLoadRef.current;
|
||
pendingStackLoadRef.current = null;
|
||
|
||
setSelectedFile(null);
|
||
setContent('');
|
||
setOriginalContent('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setContainers([]);
|
||
setIsEditing(false);
|
||
|
||
if (pendingStack) {
|
||
loadFile(pendingStack);
|
||
} else {
|
||
setActiveView('dashboard');
|
||
}
|
||
|
||
refreshStacks();
|
||
fetchImageUpdates();
|
||
refreshGitSourcePending();
|
||
|
||
// Poll for image update results every 5 minutes so background checks are picked up
|
||
const imageUpdateInterval = setInterval(fetchImageUpdates, 5 * 60 * 1000);
|
||
return () => clearInterval(imageUpdateInterval);
|
||
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const fetchNotifications = async () => {
|
||
try {
|
||
const currentNodes = nodesRef.current;
|
||
const localNode = currentNodes.find(n => n.type === 'local');
|
||
const remoteNodes = currentNodes.filter(n => n.type === 'remote');
|
||
|
||
const [localResult, ...remoteResults] = await Promise.allSettled([
|
||
apiFetch('/notifications', { localOnly: true }),
|
||
...remoteNodes.map(n => fetchForNode('/notifications', n.id)),
|
||
]);
|
||
|
||
const all: NotificationItem[] = [];
|
||
|
||
if (localResult.status === 'fulfilled' && localResult.value.ok) {
|
||
const data = await localResult.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||
data.forEach(n => all.push({ ...n, nodeId: localNode?.id ?? -1, nodeName: localNode?.name ?? 'Local' }));
|
||
}
|
||
|
||
for (let i = 0; i < remoteNodes.length; i++) {
|
||
const result = remoteResults[i];
|
||
if (result?.status === 'fulfilled' && result.value.ok) {
|
||
const data = await result.value.json() as Omit<NotificationItem, 'nodeId' | 'nodeName'>[];
|
||
const rn = remoteNodes[i];
|
||
data.forEach(n => all.push({ ...n, nodeId: rn.id, nodeName: rn.name }));
|
||
}
|
||
}
|
||
|
||
all.sort((a, b) => b.timestamp - a.timestamp);
|
||
setNotifications(all);
|
||
} catch (e) {
|
||
console.error('[Notifications] fetch error:', e);
|
||
}
|
||
};
|
||
|
||
const fetchImageUpdates = async () => {
|
||
try {
|
||
const res = await apiFetch('/image-updates');
|
||
if (res.ok) {
|
||
const data = await res.json();
|
||
setStackUpdates(data);
|
||
}
|
||
} catch (e: unknown) {
|
||
console.error('[ImageUpdates] fetch failed:', e);
|
||
}
|
||
};
|
||
|
||
const markAllRead = async () => {
|
||
try {
|
||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||
const unreadNodeIds = [...new Set(notifications.filter(n => !n.is_read && n.nodeId != null).map(n => n.nodeId as number))];
|
||
await Promise.allSettled(unreadNodeIds.map(nodeId =>
|
||
nodeId === localNode?.id
|
||
? apiFetch('/notifications/read', { method: 'POST', localOnly: true })
|
||
: fetchForNode('/notifications/read', nodeId, { method: 'POST' })
|
||
));
|
||
setNotifications(prev => prev.map(n => ({ ...n, is_read: 1 })));
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string; error?: string };
|
||
toast.error(err?.message || err?.error || 'Failed to mark notifications as read');
|
||
}
|
||
};
|
||
|
||
const deleteNotification = async (notif: NotificationItem) => {
|
||
try {
|
||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||
if (notif.nodeId === localNode?.id) {
|
||
await apiFetch(`/notifications/${notif.id}`, { method: 'DELETE', localOnly: true });
|
||
} else if (notif.nodeId != null) {
|
||
await fetchForNode(`/notifications/${notif.id}`, notif.nodeId, { method: 'DELETE' });
|
||
}
|
||
setNotifications(prev => prev.filter(n => !(n.id === notif.id && n.nodeId === notif.nodeId)));
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string; error?: string };
|
||
toast.error(err?.message || err?.error || 'Failed to delete notification');
|
||
}
|
||
};
|
||
|
||
const clearAllNotifications = async () => {
|
||
try {
|
||
const localNode = nodesRef.current.find(n => n.type === 'local');
|
||
const uniqueNodeIds = [...new Set(notifications.filter(n => n.nodeId != null).map(n => n.nodeId as number))];
|
||
await Promise.allSettled(uniqueNodeIds.map(nodeId =>
|
||
nodeId === localNode?.id
|
||
? apiFetch('/notifications', { method: 'DELETE', localOnly: true })
|
||
: fetchForNode('/notifications', nodeId, { method: 'DELETE' })
|
||
));
|
||
setNotifications([]);
|
||
} catch (e: unknown) {
|
||
const err = e as { message?: string; error?: string };
|
||
toast.error(err?.message || err?.error || 'Failed to clear notifications');
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const wsMap: Record<string, WebSocket> = {};
|
||
|
||
(containers || []).forEach(container => {
|
||
if (!container?.Id) return;
|
||
try {
|
||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||
const activeNodeId = localStorage.getItem('sencho-active-node') || '';
|
||
const ws = new WebSocket(`${wsProtocol}//${window.location.host}/ws${activeNodeId ? `?nodeId=${activeNodeId}` : ''}`);
|
||
wsMap[container.Id] = ws;
|
||
ws.onopen = () => ws.send(JSON.stringify({
|
||
action: 'streamStats',
|
||
containerId: container.Id,
|
||
nodeId: activeNodeId || undefined
|
||
}));
|
||
ws.onmessage = (event) => {
|
||
try {
|
||
const data = JSON.parse(event.data);
|
||
// Skip initial empty chunks where stats fields are missing
|
||
if (!data.cpu_stats?.cpu_usage || !data.precpu_stats?.cpu_usage || !data.memory_stats?.usage) return;
|
||
|
||
const cpuDelta = data.cpu_stats.cpu_usage.total_usage - data.precpu_stats.cpu_usage.total_usage;
|
||
const systemDelta = (data.cpu_stats.system_cpu_usage || 0) - (data.precpu_stats.system_cpu_usage || 0);
|
||
const onlineCpus = data.cpu_stats.online_cpus || 1;
|
||
const cpuPercent = systemDelta > 0 ? ((cpuDelta / systemDelta) * onlineCpus * 100).toFixed(2) : '0.00';
|
||
const ramUsage = (data.memory_stats.usage / (1024 * 1024)).toFixed(2) + ' MB';
|
||
|
||
let currentRx = 0;
|
||
let currentTx = 0;
|
||
if (data.networks) {
|
||
Object.values(data.networks as Record<string, { rx_bytes?: number; tx_bytes?: number }>).forEach((net) => {
|
||
currentRx += net.rx_bytes || 0;
|
||
currentTx += net.tx_bytes || 0;
|
||
});
|
||
}
|
||
|
||
// Rate is derived from rawBytesRef which is never cleared on flush,
|
||
// so the delta is always accurate - no stale-closure risk.
|
||
const prevRaw = rawBytesRef.current[container.Id];
|
||
const rxRate = prevRaw ? Math.max(0, currentRx - prevRaw.lastRx) : 0;
|
||
const txRate = prevRaw ? Math.max(0, currentTx - prevRaw.lastTx) : 0;
|
||
rawBytesRef.current[container.Id] = { lastRx: currentRx, lastTx: currentTx };
|
||
|
||
const netIO = `${formatBytes(rxRate)}/s ↓ / ${formatBytes(txRate)}/s ↑`;
|
||
|
||
// Write into the buffer ref only - zero re-render cost.
|
||
pendingStatsRef.current[container.Id] = {
|
||
cpu: cpuPercent + '%',
|
||
ram: ramUsage,
|
||
net: netIO,
|
||
lastRx: currentRx,
|
||
lastTx: currentTx,
|
||
cpuNum: parseFloat(cpuPercent) || 0,
|
||
memNum: data.memory_stats.usage / (1024 * 1024),
|
||
netInNum: rxRate,
|
||
netOutNum: txRate,
|
||
};
|
||
} catch {
|
||
// Ignore parse errors
|
||
}
|
||
};
|
||
} catch {
|
||
// Ignore WebSocket errors
|
||
}
|
||
});
|
||
|
||
// Flush buffered stats into React state once every 1.5 s.
|
||
// Snapshot + clear the buffer BEFORE calling setState so the updater
|
||
// function remains pure (no side-effects inside it).
|
||
const flushInterval = setInterval(() => {
|
||
const pending = pendingStatsRef.current;
|
||
if (Object.keys(pending).length === 0) return;
|
||
pendingStatsRef.current = {};
|
||
|
||
setContainerStats(prev => {
|
||
const next = { ...prev };
|
||
const HISTORY_CAP = 60;
|
||
for (const [id, newStats] of Object.entries(pending)) {
|
||
const prior = prev[id]?.history ?? { cpu: [], mem: [], netIn: [], netOut: [] };
|
||
const history = {
|
||
cpu: [...prior.cpu, newStats.cpuNum].slice(-HISTORY_CAP),
|
||
mem: [...prior.mem, newStats.memNum].slice(-HISTORY_CAP),
|
||
netIn: [...prior.netIn, newStats.netInNum].slice(-HISTORY_CAP),
|
||
netOut: [...prior.netOut, newStats.netOutNum].slice(-HISTORY_CAP),
|
||
};
|
||
next[id] = {
|
||
cpu: newStats.cpu,
|
||
ram: newStats.ram,
|
||
net: newStats.net,
|
||
lastRx: newStats.lastRx,
|
||
lastTx: newStats.lastTx,
|
||
history,
|
||
};
|
||
}
|
||
return next;
|
||
});
|
||
}, 1500);
|
||
|
||
return () => {
|
||
clearInterval(flushInterval);
|
||
// Discard buffered stats for the old stack so stale entries don't
|
||
// briefly appear when a new stack is selected.
|
||
pendingStatsRef.current = {};
|
||
Object.values(wsMap).forEach(ws => {
|
||
try { ws.close(); } catch { /* ignore */ }
|
||
});
|
||
};
|
||
}, [containers]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const hasUnsavedChanges = () =>
|
||
content !== originalContent || envContent !== originalEnvContent;
|
||
|
||
// Global-search result click: switch the active node, clear the query so the
|
||
// sidebar snaps back to the new node's full stack list, then open the stack.
|
||
// setActiveNode writes to localStorage synchronously, so the next apiFetch
|
||
// picks up the new node-id header without waiting for a re-render.
|
||
const loadFileOnNode = async (node: Node, filename: string) => {
|
||
if (!filename) return;
|
||
if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) {
|
||
setPendingUnsavedNode(node);
|
||
setPendingUnsavedLoad(filename);
|
||
return;
|
||
}
|
||
setActiveNode(node);
|
||
setSearchQuery('');
|
||
await loadFile(filename);
|
||
};
|
||
|
||
const loadFile = async (filename: string) => {
|
||
if (!filename) return;
|
||
// Guard: if there are unsaved changes and we're switching to a different stack, confirm first
|
||
if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) {
|
||
setPendingUnsavedLoad(filename);
|
||
return;
|
||
}
|
||
setIsFileLoading(true);
|
||
setIsEditing(false); // Reset to view mode when loading a new file
|
||
try {
|
||
const res = await apiFetch(`/stacks/${filename}`);
|
||
const text = await res.text();
|
||
setSelectedFile(filename);
|
||
setActiveView('editor');
|
||
setContent(text || '');
|
||
setOriginalContent(text || '');
|
||
|
||
// Load env files
|
||
try {
|
||
const envsRes = await apiFetch(`/stacks/${filename}/envs`);
|
||
if (envsRes.ok) {
|
||
const { envFiles } = await envsRes.json();
|
||
if (envFiles && envFiles.length > 0) {
|
||
setEnvFiles(envFiles);
|
||
const firstFile = envFiles[0];
|
||
setSelectedEnvFile(firstFile);
|
||
setEnvExists(true);
|
||
|
||
// Load specific env file content
|
||
const envContentRes = await apiFetch(`/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`);
|
||
if (envContentRes.ok) {
|
||
const envText = await envContentRes.text();
|
||
setEnvContent(envText || '');
|
||
setOriginalEnvContent(envText || '');
|
||
} else {
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
}
|
||
} else {
|
||
setEnvFiles([]);
|
||
setSelectedEnvFile('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setEnvExists(false);
|
||
}
|
||
} else {
|
||
setEnvFiles([]);
|
||
setSelectedEnvFile('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setEnvExists(false);
|
||
}
|
||
} catch {
|
||
setEnvFiles([]);
|
||
setSelectedEnvFile('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setEnvExists(false);
|
||
}
|
||
|
||
// Load containers
|
||
try {
|
||
const containersRes = await apiFetch(`/stacks/${filename}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
} catch (error) {
|
||
console.error('Failed to load containers:', error);
|
||
setContainers([]);
|
||
}
|
||
|
||
// Load backup info (Skipper+ only)
|
||
if (isPaid) {
|
||
try {
|
||
const backupRes = await apiFetch(`/stacks/${filename}/backup`);
|
||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||
else setBackupInfo({ exists: false, timestamp: null });
|
||
} catch {
|
||
setBackupInfo({ exists: false, timestamp: null });
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to load file:', error);
|
||
setSelectedFile(null);
|
||
setContent('');
|
||
setOriginalContent('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setContainers([]);
|
||
} finally {
|
||
setIsFileLoading(false);
|
||
}
|
||
};
|
||
|
||
const changeEnvFile = async (file: string) => {
|
||
setSelectedEnvFile(file);
|
||
setIsFileLoading(true);
|
||
try {
|
||
const res = await apiFetch(`/stacks/${selectedFile}/env?file=${encodeURIComponent(file)}`);
|
||
const text = await res.text();
|
||
setEnvContent(text || '');
|
||
setOriginalEnvContent(text || '');
|
||
} catch (e) {
|
||
console.error('Failed to switch env file', e);
|
||
} finally {
|
||
setIsFileLoading(false);
|
||
}
|
||
};
|
||
|
||
const saveFile = async () => {
|
||
if (!selectedFile) return;
|
||
const currentContent = activeTab === 'compose' ? (content || '') : (envContent || '');
|
||
const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`;
|
||
try {
|
||
const response = await apiFetch(endpoint, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ content: currentContent }),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
||
}
|
||
// Update original content after save
|
||
if (activeTab === 'compose') {
|
||
setOriginalContent(content);
|
||
} else {
|
||
setOriginalEnvContent(envContent);
|
||
}
|
||
setIsEditing(false);
|
||
toast.success('File saved successfully!');
|
||
} catch (error) {
|
||
console.error('Failed to save file:', error);
|
||
toast.error(`Failed to save file: ${(error as Error).message}`);
|
||
}
|
||
};
|
||
|
||
const rollbackStack = async () => {
|
||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||
const stackFile = selectedFile;
|
||
setStackAction(stackFile, 'rollback');
|
||
setOptimisticStatus(stackFile, 'running');
|
||
try {
|
||
const res = await apiFetch(`/stacks/${stackFile}/rollback`, { method: 'POST' });
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err?.error || 'Rollback failed');
|
||
}
|
||
toast.success('Stack rolled back successfully.');
|
||
// Reload the editor content
|
||
const contentRes = await apiFetch(`/stacks/${stackFile}`);
|
||
const text = await contentRes.text();
|
||
setContent(text || '');
|
||
setOriginalContent(text || '');
|
||
// Refresh backup info
|
||
const backupRes = await apiFetch(`/stacks/${stackFile}/backup`);
|
||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||
} catch (error: unknown) {
|
||
const msg = error instanceof Error ? error.message : 'Rollback failed';
|
||
toast.error(msg);
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||
await saveFile();
|
||
await deployStack(e);
|
||
};
|
||
|
||
const discardChanges = () => {
|
||
if (activeTab === 'compose') {
|
||
setContent(originalContent);
|
||
} else {
|
||
setEnvContent(originalEnvContent);
|
||
}
|
||
setIsEditing(false);
|
||
};
|
||
|
||
const enterEditMode = () => {
|
||
setIsEditing(true);
|
||
};
|
||
|
||
const scanStackConfig = async () => {
|
||
if (!selectedFile || stackMisconfigScanning) return;
|
||
const stackName = selectedFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackMisconfigScanning(true);
|
||
const loadingId = toast.loading(`Scanning ${stackName} configuration...`);
|
||
try {
|
||
const res = await apiFetch('/security/scan/stack', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ stackName }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data?.error || 'Failed to start scan');
|
||
if (data.status === 'failed') {
|
||
throw new Error(data.error || 'Scan failed');
|
||
}
|
||
toast.success(
|
||
`Config scan complete: ${data.misconfig_count ?? 0} misconfigurations found`,
|
||
);
|
||
setStackMisconfigScanId(data.id as number);
|
||
} catch (error) {
|
||
const err = error as { message?: string; error?: string; data?: { error?: string } };
|
||
toast.error(err?.message || err?.error || err?.data?.error || 'Config scan failed');
|
||
} finally {
|
||
toast.dismiss(loadingId);
|
||
setStackMisconfigScanning(false);
|
||
}
|
||
};
|
||
|
||
const deployStack = async (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||
const stackFile = selectedFile;
|
||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackAction(stackFile, 'deploy');
|
||
const previousStatus = stackStatuses[stackFile];
|
||
setOptimisticStatus(stackFile, 'running');
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackName}/deploy`, {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || 'Deploy failed');
|
||
}
|
||
toast.success("Stack deployed successfully!");
|
||
// Refresh containers after deploy
|
||
if (selectedFile === stackFile) {
|
||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
}
|
||
// Refresh backup info
|
||
if (isPaid) {
|
||
try {
|
||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||
} catch { /* ignore */ }
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to deploy:', error);
|
||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||
const msg = (error as Error).message || 'Failed to deploy stack';
|
||
toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const stopStack = async (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||
const stackFile = selectedFile;
|
||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackAction(stackFile, 'stop');
|
||
const previousStatus = stackStatuses[stackFile];
|
||
setOptimisticStatus(stackFile, 'exited');
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackName}/stop`, {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || 'Stop failed');
|
||
}
|
||
toast.success('Stack stopped successfully!');
|
||
// Refresh containers after stop
|
||
if (selectedFile === stackFile) {
|
||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to stop:', error);
|
||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||
toast.error((error as Error).message || 'Failed to stop stack');
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const restartStack = async (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||
const stackFile = selectedFile;
|
||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackAction(stackFile, 'restart');
|
||
const previousStatus = stackStatuses[stackFile];
|
||
setOptimisticStatus(stackFile, 'running');
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackName}/restart`, {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || 'Restart failed');
|
||
}
|
||
toast.success('Stack restarted successfully!');
|
||
// Refresh containers after restart
|
||
if (selectedFile === stackFile) {
|
||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to restart:', error);
|
||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||
toast.error((error as Error).message || 'Failed to restart stack');
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const updateStack = async (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (!selectedFile || isStackBusy(selectedFile)) return;
|
||
const stackFile = selectedFile;
|
||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackAction(stackFile, 'update');
|
||
const previousStatus = stackStatuses[stackFile];
|
||
setOptimisticStatus(stackFile, 'running');
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackName}/update`, {
|
||
method: 'POST',
|
||
});
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || 'Update failed');
|
||
}
|
||
toast.success('Stack updated successfully!');
|
||
// Refresh containers after update
|
||
if (selectedFile === stackFile) {
|
||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to update:', error);
|
||
if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited');
|
||
toast.error((error as Error).message || 'Failed to update stack');
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const deleteStack = async () => {
|
||
if (!stackToDelete) return;
|
||
// Find matching file entry for per-stack tracking
|
||
const deleteKey = files.find(f => f === stackToDelete || f.replace(/\.(yml|yaml)$/, '') === stackToDelete) ?? stackToDelete;
|
||
if (isStackBusy(deleteKey)) return;
|
||
setStackAction(deleteKey, 'delete');
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackToDelete}`, {
|
||
method: 'DELETE',
|
||
});
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || 'Failed to delete stack');
|
||
}
|
||
toast.success('Stack deleted successfully!');
|
||
setDeleteDialogOpen(false);
|
||
setStackToDelete(null);
|
||
if (selectedFile === stackToDelete) {
|
||
setSelectedFile(null);
|
||
setContent('');
|
||
setOriginalContent('');
|
||
setEnvContent('');
|
||
setOriginalEnvContent('');
|
||
setEnvExists(false);
|
||
setContainers([]);
|
||
setIsEditing(false);
|
||
}
|
||
await refreshStacks();
|
||
} catch (error) {
|
||
console.error('Failed to delete stack:', error);
|
||
toast.error((error as Error).message || 'Failed to delete stack');
|
||
} finally {
|
||
clearStackAction(deleteKey);
|
||
}
|
||
};
|
||
|
||
// Context-menu-friendly stack actions (accept file name directly)
|
||
const executeStackActionByFile = async (stackFile: string, action: StackAction, endpoint: string) => {
|
||
if (isStackBusy(stackFile)) return;
|
||
const stackName = stackFile.replace(/\.(yml|yaml)$/, '');
|
||
setStackAction(stackFile, action);
|
||
|
||
// Optimistic status update
|
||
if (action === 'stop') {
|
||
setOptimisticStatus(stackFile, 'exited');
|
||
} else if (action === 'deploy' || action === 'restart' || action === 'update') {
|
||
setOptimisticStatus(stackFile, 'running');
|
||
}
|
||
|
||
try {
|
||
const response = await apiFetch(`/stacks/${stackName}/${endpoint}`, { method: 'POST' });
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText || `${action} failed`);
|
||
}
|
||
toast.success(`Stack ${action}ed successfully!`);
|
||
if (selectedFile === stackFile) {
|
||
const containersRes = await apiFetch(`/stacks/${stackName}/containers`);
|
||
const conts = await containersRes.json();
|
||
setContainers(Array.isArray(conts) ? conts : []);
|
||
}
|
||
if (action === 'update') fetchImageUpdates();
|
||
if (action === 'deploy' && isPaid) {
|
||
try {
|
||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||
if (backupRes.ok) setBackupInfo(await backupRes.json());
|
||
} catch { /* ignore */ }
|
||
}
|
||
} catch (error) {
|
||
console.error(`Failed to ${action}:`, error);
|
||
const msg = (error as Error).message || `Failed to ${action} stack`;
|
||
toast.error(action === 'deploy' && isPaid ? `${msg} - automatically rolled back to previous version.` : msg);
|
||
} finally {
|
||
clearStackAction(stackFile);
|
||
refreshStacks(true);
|
||
}
|
||
};
|
||
|
||
const checkUpdatesForStack = async () => {
|
||
try {
|
||
const res = await apiFetch('/image-updates/refresh', { method: 'POST' });
|
||
if (res.ok) {
|
||
toast.success('Checking for image updates...');
|
||
// Poll until the background check completes instead of using a fixed timeout
|
||
let elapsed = 0;
|
||
const poll = setInterval(async () => {
|
||
elapsed += 2000;
|
||
try {
|
||
const statusRes = await apiFetch('/image-updates/status');
|
||
if (statusRes.ok) {
|
||
const { checking } = await statusRes.json();
|
||
if (!checking || elapsed >= 60000) {
|
||
clearInterval(poll);
|
||
await fetchImageUpdates();
|
||
if (!checking) toast.success('Image update check complete.');
|
||
}
|
||
}
|
||
} catch {
|
||
clearInterval(poll);
|
||
await fetchImageUpdates();
|
||
}
|
||
}, 2000);
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
toast.error(data.error || 'Failed to check for updates');
|
||
}
|
||
} catch {
|
||
toast.error('Failed to check for updates');
|
||
}
|
||
};
|
||
|
||
const handleCreateStack = async () => {
|
||
if (!newStackName.trim()) return;
|
||
// Send stackName directly (no .yml extension - backend creates directory)
|
||
const stackName = newStackName.trim();
|
||
try {
|
||
const response = await apiFetch('/stacks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ stackName }),
|
||
});
|
||
if (!response.ok) {
|
||
if (response.status === 409) {
|
||
throw new Error('Stack already exists');
|
||
} else if (response.status === 400) {
|
||
throw new Error('Invalid stack name (use alphanumeric characters and hyphens only)');
|
||
}
|
||
throw new Error('Failed to create stack');
|
||
}
|
||
setCreateDialogOpen(false);
|
||
setNewStackName('');
|
||
await refreshStacks();
|
||
// Auto-load the new stack in the editor pane
|
||
await loadFile(stackName);
|
||
} catch (error) {
|
||
console.error('Failed to create stack:', error);
|
||
toast.error((error as Error).message || 'Failed to create stack');
|
||
}
|
||
};
|
||
|
||
const resetCreateFromGitForm = () => {
|
||
setNewStackName('');
|
||
setGitRepoUrl('');
|
||
setGitBranch('main');
|
||
setGitComposePath('compose.yaml');
|
||
setGitSyncEnv(false);
|
||
setGitAuthType('none');
|
||
setGitToken('');
|
||
setGitApplyMode('review');
|
||
setGitDeployNow(false);
|
||
};
|
||
|
||
const handleCreateStackFromGit = async () => {
|
||
const stackName = newStackName.trim();
|
||
if (!stackName) {
|
||
toast.error('Stack name is required.');
|
||
return;
|
||
}
|
||
if (!gitRepoUrl.trim() || !gitBranch.trim() || !gitComposePath.trim()) {
|
||
toast.error('Repository URL, branch, and compose path are required.');
|
||
return;
|
||
}
|
||
if (!/^https:\/\//i.test(gitRepoUrl.trim())) {
|
||
toast.error('Only HTTPS repository URLs are supported.');
|
||
return;
|
||
}
|
||
setCreatingFromGit(true);
|
||
const loadingId = toast.loading(gitDeployNow ? 'Fetching, creating, and deploying...' : 'Fetching and creating stack...');
|
||
try {
|
||
const autoApply = gitApplyMode !== 'review';
|
||
const autoDeploy = gitApplyMode === 'auto-deploy';
|
||
const body: Record<string, unknown> = {
|
||
stack_name: stackName,
|
||
repo_url: gitRepoUrl.trim(),
|
||
branch: gitBranch.trim(),
|
||
compose_path: gitComposePath.trim(),
|
||
sync_env: gitSyncEnv,
|
||
auth_type: gitAuthType,
|
||
auto_apply_on_webhook: autoApply,
|
||
auto_deploy_on_apply: autoDeploy,
|
||
deploy_now: gitDeployNow,
|
||
};
|
||
if (gitAuthType === 'token' && gitToken !== '') {
|
||
body.token = gitToken;
|
||
}
|
||
const response = await apiFetch('/stacks/from-git', {
|
||
method: 'POST',
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!response.ok) {
|
||
const err = await response.json().catch(() => ({}));
|
||
if (response.status === 409) {
|
||
throw new Error(err?.error || 'Stack already exists.');
|
||
}
|
||
throw new Error(err?.error || 'Failed to create stack from Git.');
|
||
}
|
||
const data: {
|
||
deployed?: boolean;
|
||
deployError?: string;
|
||
commitSha?: string;
|
||
warnings?: string[];
|
||
} = await response.json();
|
||
const shortSha = typeof data.commitSha === 'string' ? data.commitSha.slice(0, 7) : '';
|
||
const shaSuffix = shortSha ? ` @ ${shortSha}` : '';
|
||
if (gitDeployNow && data.deployError) {
|
||
toast.warning(`Stack created${shaSuffix}, but deploy failed: ${data.deployError}`);
|
||
} else if (gitDeployNow && data.deployed) {
|
||
toast.success(`Stack created and deployed from Git${shaSuffix}.`);
|
||
} else {
|
||
toast.success(`Stack created from Git${shaSuffix}.`);
|
||
}
|
||
if (Array.isArray(data.warnings) && data.warnings.length > 0) {
|
||
toast.warning(data.warnings.join(' '));
|
||
}
|
||
setCreateDialogOpen(false);
|
||
resetCreateFromGitForm();
|
||
await refreshStacks();
|
||
await loadFile(stackName);
|
||
} catch (error) {
|
||
console.error('Failed to create stack from Git:', error);
|
||
toast.error((error as Error)?.message || 'Failed to create stack from Git.');
|
||
} finally {
|
||
toast.dismiss(loadingId);
|
||
setCreatingFromGit(false);
|
||
}
|
||
};
|
||
|
||
const resetCreateFromDockerRunForm = () => {
|
||
setDockerRunInput('');
|
||
setConvertedYaml(null);
|
||
setIsConverting(false);
|
||
setCreatingFromDockerRun(false);
|
||
};
|
||
|
||
const handleConvertDockerRun = async () => {
|
||
const command = dockerRunInput.trim();
|
||
if (!command) {
|
||
toast.error('Paste a docker run command first.');
|
||
return;
|
||
}
|
||
setIsConverting(true);
|
||
try {
|
||
const response = await apiFetch('/convert', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ dockerRun: command }),
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) {
|
||
throw new Error(data?.error || 'Could not parse command.');
|
||
}
|
||
if (typeof data?.yaml !== 'string' || data.yaml.length === 0) {
|
||
throw new Error('Converter returned an empty result.');
|
||
}
|
||
setConvertedYaml(data.yaml);
|
||
toast.success('Converted to compose YAML.');
|
||
} catch (error) {
|
||
setConvertedYaml(null);
|
||
const err = error as { message?: string; error?: string; data?: { error?: string } };
|
||
toast.error(
|
||
err?.message ||
|
||
err?.error ||
|
||
err?.data?.error ||
|
||
'Failed to convert docker run command.',
|
||
);
|
||
} finally {
|
||
setIsConverting(false);
|
||
}
|
||
};
|
||
|
||
const handleCreateStackFromDockerRun = async () => {
|
||
const stackName = newStackName.trim();
|
||
if (!stackName) {
|
||
toast.error('Stack name is required.');
|
||
return;
|
||
}
|
||
if (!convertedYaml) {
|
||
toast.error('Convert the command before creating the stack.');
|
||
return;
|
||
}
|
||
setCreatingFromDockerRun(true);
|
||
const loadingId = toast.loading('Creating stack from converted YAML...');
|
||
let createdStack = false;
|
||
try {
|
||
const createResponse = await apiFetch('/stacks', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ stackName }),
|
||
});
|
||
if (!createResponse.ok) {
|
||
if (createResponse.status === 409) {
|
||
throw new Error('Stack already exists.');
|
||
}
|
||
if (createResponse.status === 400) {
|
||
throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).');
|
||
}
|
||
throw new Error('Failed to create stack.');
|
||
}
|
||
createdStack = true;
|
||
|
||
const saveResponse = await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({ content: convertedYaml }),
|
||
});
|
||
if (!saveResponse.ok) {
|
||
// Roll back the empty stack we just created so we don't leave an orphan.
|
||
await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'DELETE' }).catch((cleanupError) => {
|
||
console.error('Failed to roll back orphan stack after save failure:', cleanupError);
|
||
});
|
||
createdStack = false;
|
||
throw new Error('Could not save the converted YAML. Please try again.');
|
||
}
|
||
|
||
toast.success(`Stack "${stackName}" created from docker run.`);
|
||
setCreateDialogOpen(false);
|
||
resetCreateFromDockerRunForm();
|
||
setNewStackName('');
|
||
await refreshStacks();
|
||
await loadFile(stackName);
|
||
} catch (error) {
|
||
console.error('Failed to create stack from docker run:', error);
|
||
const err = error as { message?: string; error?: string; data?: { error?: string } };
|
||
toast.error(
|
||
err?.message ||
|
||
err?.error ||
|
||
err?.data?.error ||
|
||
'Failed to create stack from docker run.',
|
||
);
|
||
// If we bailed before the createdStack flag got reset, surface that the stack still exists.
|
||
if (createdStack) {
|
||
await refreshStacks().catch(() => undefined);
|
||
}
|
||
} finally {
|
||
toast.dismiss(loadingId);
|
||
setCreatingFromDockerRun(false);
|
||
}
|
||
};
|
||
|
||
const openBashModal = (containerId: string, containerName: string) => {
|
||
setSelectedContainer({ id: containerId, name: containerName });
|
||
setBashModalOpen(true);
|
||
};
|
||
|
||
const closeBashModal = () => {
|
||
setBashModalOpen(false);
|
||
setSelectedContainer(null);
|
||
};
|
||
|
||
const openLogViewer = (containerId: string, containerName: string) => {
|
||
setLogContainer({ id: containerId, name: containerName });
|
||
setLogViewerOpen(true);
|
||
};
|
||
|
||
const closeLogViewer = () => {
|
||
setLogViewerOpen(false);
|
||
setLogContainer(null);
|
||
};
|
||
|
||
// Listen for topology click-to-logs events (ref avoids stale closure)
|
||
const openLogViewerRef = useRef(openLogViewer);
|
||
openLogViewerRef.current = openLogViewer;
|
||
useEffect(() => {
|
||
const handler = (e: Event) => {
|
||
const { containerId, containerName } = (e as CustomEvent<SenchoOpenLogsDetail>).detail;
|
||
openLogViewerRef.current(containerId, containerName);
|
||
};
|
||
window.addEventListener(SENCHO_OPEN_LOGS_EVENT, handler);
|
||
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 || '';
|
||
|
||
// Filter files based on search query
|
||
const filteredFiles = files.filter(file => {
|
||
if (!file.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||
if (activeLabelFilters.size > 0) {
|
||
const fileLabels = stackLabelMap[file] || [];
|
||
if (!fileLabels.some(l => activeLabelFilters.has(l.id))) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Get display name for stack (now just returns the name as-is since no extension)
|
||
const getDisplayName = (stackName: string) => {
|
||
return stackName;
|
||
};
|
||
|
||
return (
|
||
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
||
{/* Left Sidebar (Stacks) */}
|
||
<div className="w-64 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
|
||
{/* Branding Header */}
|
||
<div className="h-14 flex items-center justify-center px-4 border-b border-border">
|
||
<div className="flex items-center gap-2">
|
||
<img src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'} alt="Sencho Logo" className="w-10 h-10" />
|
||
<h1 className="text-2xl font-medium tracking-tight">Sencho</h1>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Node Switcher */}
|
||
{nodes.length > 1 && (
|
||
<div className="px-4 pt-2 pb-0">
|
||
<Select
|
||
value={activeNode?.id?.toString() || ''}
|
||
onValueChange={(val) => {
|
||
const node = nodes.find(n => n.id === parseInt(val));
|
||
if (node) setActiveNode(node);
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full h-9 text-sm">
|
||
<div className="flex items-center gap-2">
|
||
<Server className="w-3.5 h-3.5 text-muted-foreground" />
|
||
<SelectValue placeholder="Select node" />
|
||
</div>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{nodes.map(node => {
|
||
const meta = nodeMeta.get(node.id);
|
||
return (
|
||
<SelectItem key={node.id} value={node.id.toString()}>
|
||
<div className="flex items-center gap-2 w-full">
|
||
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
|
||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||
}`} />
|
||
<span>{node.name}</span>
|
||
{isValidVersion(meta?.version) && (
|
||
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/60 ml-auto">
|
||
v{meta.version}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Stack & Scan Buttons */}
|
||
{can('stack:create') && <div className="p-4 flex gap-2">
|
||
<Dialog open={createDialogOpen} onOpenChange={(o) => {
|
||
setCreateDialogOpen(o);
|
||
if (!o) {
|
||
setCreateMode('empty');
|
||
resetCreateFromGitForm();
|
||
resetCreateFromDockerRunForm();
|
||
}
|
||
}}>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline" className="flex-1 rounded-lg">
|
||
<Plus className="w-4 h-4 mr-2" />
|
||
Create Stack
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-w-xl w-[95vw] p-0 gap-0">
|
||
<DialogHeader className="px-6 pt-6 pb-3">
|
||
<DialogTitle>Create New Stack</DialogTitle>
|
||
<DialogDescription className="sr-only">
|
||
Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="px-6 pb-2">
|
||
<Tabs value={createMode} onValueChange={(v) => setCreateMode(v as 'empty' | 'git' | 'docker-run')}>
|
||
<TabsList>
|
||
<TabsHighlight className="rounded-md bg-glass-highlight" transition={springs.snappy}>
|
||
<TabsHighlightItem value="empty">
|
||
<TabsTrigger value="empty">
|
||
<Plus className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||
Empty
|
||
</TabsTrigger>
|
||
</TabsHighlightItem>
|
||
<TabsHighlightItem value="git">
|
||
<TabsTrigger value="git">
|
||
<GitBranch className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||
From Git
|
||
</TabsTrigger>
|
||
</TabsHighlightItem>
|
||
<TabsHighlightItem value="docker-run">
|
||
<TabsTrigger value="docker-run">
|
||
<FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />
|
||
From Docker Run
|
||
</TabsTrigger>
|
||
</TabsHighlightItem>
|
||
</TabsHighlight>
|
||
</TabsList>
|
||
</Tabs>
|
||
</div>
|
||
|
||
{createMode === 'empty' && (
|
||
<>
|
||
<div className="px-6 py-4 space-y-2">
|
||
<Label htmlFor="create-stack-name">Stack Name</Label>
|
||
<Input
|
||
id="create-stack-name"
|
||
placeholder="Stack name (e.g., myapp)"
|
||
value={newStackName}
|
||
onChange={(e) => setNewStackName(e.target.value)}
|
||
/>
|
||
</div>
|
||
<DialogFooter className="px-6 pb-6">
|
||
<Button onClick={handleCreateStack}>Create</Button>
|
||
</DialogFooter>
|
||
</>
|
||
)}
|
||
{createMode === 'git' && (
|
||
<>
|
||
<ScrollArea className="max-h-[70vh]">
|
||
<div className="px-6 py-4 space-y-5">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="create-git-stack-name">Stack Name</Label>
|
||
<Input
|
||
id="create-git-stack-name"
|
||
placeholder="Stack name (e.g., myapp)"
|
||
value={newStackName}
|
||
onChange={(e) => setNewStackName(e.target.value)}
|
||
disabled={creatingFromGit}
|
||
/>
|
||
</div>
|
||
|
||
<GitSourceFields
|
||
variant="create"
|
||
disabled={creatingFromGit}
|
||
repoUrl={gitRepoUrl}
|
||
branch={gitBranch}
|
||
composePath={gitComposePath}
|
||
syncEnv={gitSyncEnv}
|
||
authType={gitAuthType}
|
||
token={gitToken}
|
||
hasStoredToken={false}
|
||
applyMode={gitApplyMode}
|
||
onRepoUrlChange={setGitRepoUrl}
|
||
onBranchChange={setGitBranch}
|
||
onComposePathChange={setGitComposePath}
|
||
onSyncEnvChange={setGitSyncEnv}
|
||
onAuthTypeChange={setGitAuthType}
|
||
onTokenChange={setGitToken}
|
||
onApplyModeChange={setGitApplyMode}
|
||
/>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox
|
||
id="create-git-deploy-now"
|
||
checked={gitDeployNow}
|
||
onCheckedChange={(c) => setGitDeployNow(c === true)}
|
||
disabled={creatingFromGit}
|
||
/>
|
||
<Label htmlFor="create-git-deploy-now" className="text-xs cursor-pointer">
|
||
Deploy after create
|
||
</Label>
|
||
</div>
|
||
</div>
|
||
</ScrollArea>
|
||
<DialogFooter className="px-6 py-4 border-t border-glass-border">
|
||
<Button onClick={handleCreateStackFromGit} disabled={creatingFromGit}>
|
||
{creatingFromGit ? (
|
||
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
|
||
) : (
|
||
<><GitBranch className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create from Git</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</>
|
||
)}
|
||
{createMode === 'docker-run' && (
|
||
<>
|
||
<ScrollArea className="max-h-[70vh]">
|
||
<div className="px-6 py-4 space-y-5">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="create-dr-stack-name">Stack Name</Label>
|
||
<Input
|
||
id="create-dr-stack-name"
|
||
placeholder="Stack name (e.g., myapp)"
|
||
value={newStackName}
|
||
onChange={(e) => setNewStackName(e.target.value)}
|
||
disabled={creatingFromDockerRun}
|
||
/>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="create-dr-command">Paste your docker run command</Label>
|
||
<textarea
|
||
id="create-dr-command"
|
||
spellCheck={false}
|
||
className="flex w-full rounded-md border border-glass-border bg-input px-3 py-2 text-sm font-mono shadow-sm transition-colors placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 min-h-[120px] resize-y"
|
||
placeholder="docker run -d --name nginx -p 8080:80 nginx:latest"
|
||
value={dockerRunInput}
|
||
onChange={(e) => {
|
||
setDockerRunInput(e.target.value);
|
||
// The preview only reflects the previous command; clear it when
|
||
// the input changes so the user can't create a stack from stale YAML.
|
||
if (convertedYaml !== null) setConvertedYaml(null);
|
||
}}
|
||
disabled={creatingFromDockerRun}
|
||
/>
|
||
<div className="flex justify-end">
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={handleConvertDockerRun}
|
||
disabled={isConverting || creatingFromDockerRun || !dockerRunInput.trim()}
|
||
>
|
||
{isConverting ? (
|
||
<><Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" strokeWidth={1.5} />Converting</>
|
||
) : (
|
||
<><FileCode2 className="w-3.5 h-3.5 mr-1.5" strokeWidth={1.5} />Convert</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
{convertedYaml !== null && (
|
||
<div className="space-y-2">
|
||
<Label>compose.yaml preview</Label>
|
||
<ScrollArea className="max-h-[240px] rounded-md border border-card-border border-t-card-border-top bg-card shadow-card-bevel">
|
||
<pre className="px-3 py-2 text-xs font-mono whitespace-pre leading-relaxed">
|
||
{convertedYaml}
|
||
</pre>
|
||
</ScrollArea>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
<DialogFooter className="px-6 py-4 border-t border-glass-border">
|
||
<Button
|
||
onClick={handleCreateStackFromDockerRun}
|
||
disabled={creatingFromDockerRun || !convertedYaml || !newStackName.trim()}
|
||
>
|
||
{creatingFromDockerRun ? (
|
||
<><Loader2 className="w-4 h-4 mr-1.5 animate-spin" strokeWidth={1.5} />Creating</>
|
||
) : (
|
||
<><Plus className="w-4 h-4 mr-1.5" strokeWidth={1.5} />Create Stack</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</>
|
||
)}
|
||
</DialogContent>
|
||
</Dialog>
|
||
<TooltipProvider>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="rounded-lg shrink-0"
|
||
onClick={handleScanStacks}
|
||
disabled={isScanning}
|
||
>
|
||
{isScanning
|
||
? <Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.5} />
|
||
: <FolderSearch className="w-4 h-4" strokeWidth={1.5} />}
|
||
</Button>
|
||
</TooltipTrigger>
|
||
<TooltipContent side="bottom">
|
||
<p>Scan stacks folder</p>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</TooltipProvider>
|
||
</div>}
|
||
|
||
{/* Search Input & Stack List */}
|
||
{/* shouldFilter disabled: we do controlled filtering via filteredFiles. cmdk's
|
||
internal filter otherwise reconciles against items wrapped in ContextMenu and
|
||
throws "appendChild: parameter 1 is not of type 'Node'". */}
|
||
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
|
||
<div className="px-4 py-2 flex-none relative">
|
||
<CommandInput
|
||
placeholder="Search stacks..."
|
||
value={searchQuery}
|
||
onValueChange={setSearchQuery}
|
||
className="h-9 border-none"
|
||
/>
|
||
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
|
||
{typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘' : 'Ctrl+'}K
|
||
</kbd>
|
||
</div>
|
||
{isPaid && labels.length > 0 && (
|
||
<div className="flex gap-1 px-3 py-1.5 overflow-x-auto scrollbar-none flex-none">
|
||
{labels.map(label => (
|
||
<ContextMenu key={label.id}>
|
||
<ContextMenuTrigger asChild>
|
||
<div>
|
||
<LabelPill
|
||
label={label}
|
||
size="sm"
|
||
active={activeLabelFilters.has(label.id)}
|
||
onClick={() => {
|
||
setActiveLabelFilters(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(label.id)) next.delete(label.id);
|
||
else next.add(label.id);
|
||
return next;
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
</ContextMenuTrigger>
|
||
<ContextMenuContent>
|
||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('deploy'); setBulkActionOpen(true); }}>
|
||
<Play className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Deploy all
|
||
</ContextMenuItem>
|
||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('stop'); setBulkActionOpen(true); }}>
|
||
<Square className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Stop all
|
||
</ContextMenuItem>
|
||
<ContextMenuItem disabled={bulkActionRunning} onClick={() => { setBulkActionLabel(label); setBulkAction('restart'); setBulkActionOpen(true); }}>
|
||
<RotateCw className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Restart all
|
||
</ContextMenuItem>
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
))}
|
||
</div>
|
||
)}
|
||
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-icon px-4 py-2 mt-2 flex-none">STACKS</h3>
|
||
<ScrollArea className="flex-1 px-2 pb-2">
|
||
<div data-stacks-loaded={isLoading ? "false" : "true"}>
|
||
<CommandList className="max-h-none overflow-visible">
|
||
{isLoading ? (
|
||
<div className="space-y-2 px-2 mt-2">
|
||
<Skeleton className="h-12 w-full" />
|
||
<Skeleton className="h-12 w-full" />
|
||
<Skeleton className="h-12 w-full" />
|
||
</div>
|
||
) : (
|
||
(filteredFiles || []).map(file => (
|
||
<ContextMenu key={file}>
|
||
<ContextMenuTrigger asChild>
|
||
<div>
|
||
<CommandItem
|
||
value={file}
|
||
onSelect={() => loadFile(file)}
|
||
className={`justify-start rounded-lg mb-1 cursor-pointer hover:bg-glass-highlight group ${selectedFile === file ? '!bg-glass-highlight !text-foreground border border-glass-border' : ''}`}
|
||
>
|
||
<div className="flex items-center gap-2 w-full">
|
||
<span
|
||
className={`font-mono text-[10px] shrink-0 w-[18px] flex items-center ${
|
||
isStackBusy(file) ? 'text-muted-foreground' :
|
||
stackStatuses[file] === 'running' ? 'text-success' :
|
||
stackStatuses[file] === 'exited' ? 'text-destructive' : 'text-stat-icon'
|
||
}`}
|
||
>
|
||
{isStackBusy(file)
|
||
? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} />
|
||
: stackStatuses[file] === 'running' ? 'UP'
|
||
: stackStatuses[file] === 'exited' ? 'DN'
|
||
: '--'}
|
||
</span>
|
||
<span className="flex-1 truncate font-mono text-[13px]">{getDisplayName(file)}</span>
|
||
{isPaid && stackLabelMap[file]?.length > 0 && (
|
||
<span className="flex items-center gap-0.5 shrink-0 ml-1">
|
||
{stackLabelMap[file].map(l => (
|
||
<LabelDot key={l.id} color={l.color} />
|
||
))}
|
||
</span>
|
||
)}
|
||
|
||
{stackUpdates[file] && (
|
||
<CursorProvider>
|
||
<CursorContainer className="inline-flex items-center shrink-0">
|
||
<span className="w-2 h-2 rounded-full bg-info animate-pulse" />
|
||
</CursorContainer>
|
||
<Cursor>
|
||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||
</Cursor>
|
||
<CursorFollow
|
||
side="bottom"
|
||
sideOffset={4}
|
||
align="center"
|
||
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
|
||
>
|
||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||
<span className="font-mono text-xs tabular-nums text-stat-value">Update available</span>
|
||
</div>
|
||
</CursorFollow>
|
||
</CursorProvider>
|
||
)}
|
||
|
||
{gitSourcePendingMap[file] && (
|
||
<CursorProvider>
|
||
<CursorContainer className="inline-flex items-center shrink-0">
|
||
<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />
|
||
</CursorContainer>
|
||
<Cursor>
|
||
<div className="h-2 w-2 rounded-full bg-brand" />
|
||
</Cursor>
|
||
<CursorFollow
|
||
side="bottom"
|
||
sideOffset={4}
|
||
align="center"
|
||
transition={{ stiffness: 400, damping: 40, bounce: 0 }}
|
||
>
|
||
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
|
||
<span className="font-mono text-xs tabular-nums text-stat-value">Git source update pending</span>
|
||
</div>
|
||
</CursorFollow>
|
||
</CursorProvider>
|
||
)}
|
||
|
||
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||
<DropdownMenu>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||
<MoreVertical className="h-4 w-4" />
|
||
</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<DropdownMenuItem onClick={() => openAlertSheet(file)}>
|
||
<BellRing className="h-4 w-4 mr-2" />
|
||
Alerts
|
||
</DropdownMenuItem>
|
||
{isPaid && (
|
||
<LabelAssignPopover
|
||
stackName={file}
|
||
allLabels={labels}
|
||
assignedLabelIds={(stackLabelMap[file] || []).map(l => l.id)}
|
||
onLabelsChanged={refreshLabels}
|
||
>
|
||
<DropdownMenuItem onSelect={e => e.preventDefault()}>
|
||
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Labels
|
||
</DropdownMenuItem>
|
||
</LabelAssignPopover>
|
||
)}
|
||
<DropdownMenuItem onClick={() => checkUpdatesForStack()}>
|
||
<RefreshCw className="h-4 w-4 mr-2" />
|
||
Check for updates
|
||
</DropdownMenuItem>
|
||
{stackStatuses[file] === 'running' && stackPorts[file] && (
|
||
<DropdownMenuItem onClick={() => openStackApp(file)}>
|
||
<ExternalLink className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Open App
|
||
</DropdownMenuItem>
|
||
)}
|
||
<DropdownMenuSeparator />
|
||
{(() => {
|
||
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
|
||
const busy = isStackBusy(file);
|
||
return (
|
||
<>
|
||
{showDeploy && (
|
||
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||
<Play className="h-4 w-4 mr-2" />
|
||
Deploy
|
||
</DropdownMenuItem>
|
||
)}
|
||
{showStop && (
|
||
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||
<Square className="h-4 w-4 mr-2" />
|
||
Stop
|
||
</DropdownMenuItem>
|
||
)}
|
||
{showRestart && (
|
||
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||
<RotateCw className="h-4 w-4 mr-2" />
|
||
Restart
|
||
</DropdownMenuItem>
|
||
)}
|
||
{showUpdate && (
|
||
<DropdownMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
Update
|
||
</DropdownMenuItem>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
|
||
<>
|
||
<DropdownMenuSeparator />
|
||
<DropdownMenuItem
|
||
className="text-destructive focus:text-destructive"
|
||
onClick={() => {
|
||
setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
|
||
setDeleteDialogOpen(true);
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
Delete
|
||
</DropdownMenuItem>
|
||
</>
|
||
)}
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>
|
||
</div>
|
||
</div>
|
||
</CommandItem>
|
||
</div>
|
||
</ContextMenuTrigger>
|
||
<ContextMenuContent>
|
||
<ContextMenuItem onClick={() => openAlertSheet(file)}>
|
||
<BellRing className="h-4 w-4 mr-2" />
|
||
Alerts
|
||
</ContextMenuItem>
|
||
<ContextMenuItem onSelect={() => setAutoHealStackName(file)}>
|
||
<Activity className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Auto-Heal
|
||
</ContextMenuItem>
|
||
{isPaid && (
|
||
<ContextMenuSub>
|
||
<ContextMenuSubTrigger>
|
||
<Tag className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Labels
|
||
</ContextMenuSubTrigger>
|
||
<ContextMenuSubContent className="min-w-[180px]">
|
||
{labels.map(label => {
|
||
const assigned = (stackLabelMap[file] || []).some(l => l.id === label.id);
|
||
return (
|
||
<ContextMenuItem
|
||
key={label.id}
|
||
onClick={async () => {
|
||
const currentIds = (stackLabelMap[file] || []).map(l => l.id);
|
||
const newIds = assigned ? currentIds.filter(id => id !== label.id) : [...currentIds, label.id];
|
||
const loadingId = toast.loading('Updating labels...');
|
||
try {
|
||
const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { method: 'PUT', body: JSON.stringify({ labelIds: newIds }) });
|
||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data?.error || 'Failed to update labels.'); }
|
||
refreshLabels();
|
||
} catch (err: unknown) { toast.error((err as Error)?.message || 'Failed to update labels.'); } finally { toast.dismiss(loadingId); }
|
||
}}
|
||
>
|
||
<LabelDot color={label.color} />
|
||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||
</ContextMenuItem>
|
||
);
|
||
})}
|
||
{labels.length === 0 && (
|
||
<ContextMenuItem disabled>
|
||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||
</ContextMenuItem>
|
||
)}
|
||
<ContextMenuSeparator />
|
||
<ContextMenuItem onClick={() => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }}>
|
||
<Plus className="h-3.5 w-3.5 mr-2" strokeWidth={1.5} />
|
||
<span className="text-xs">Manage labels...</span>
|
||
</ContextMenuItem>
|
||
</ContextMenuSubContent>
|
||
</ContextMenuSub>
|
||
)}
|
||
<ContextMenuItem onClick={() => checkUpdatesForStack()}>
|
||
<RefreshCw className="h-4 w-4 mr-2" />
|
||
Check for updates
|
||
</ContextMenuItem>
|
||
{stackStatuses[file] === 'running' && stackPorts[file] && (
|
||
<ContextMenuItem onClick={() => openStackApp(file)}>
|
||
<ExternalLink className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||
Open App
|
||
</ContextMenuItem>
|
||
)}
|
||
<ContextMenuSeparator />
|
||
{(() => {
|
||
const { showDeploy, showStop, showRestart, showUpdate } = getStackMenuVisibility(file);
|
||
const busy = isStackBusy(file);
|
||
return (
|
||
<>
|
||
{showDeploy && (
|
||
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'deploy', 'deploy')}>
|
||
<Play className="h-4 w-4 mr-2" />
|
||
Deploy
|
||
</ContextMenuItem>
|
||
)}
|
||
{showStop && (
|
||
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'stop', 'stop')}>
|
||
<Square className="h-4 w-4 mr-2" />
|
||
Stop
|
||
</ContextMenuItem>
|
||
)}
|
||
{showRestart && (
|
||
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'restart', 'restart')}>
|
||
<RotateCw className="h-4 w-4 mr-2" />
|
||
Restart
|
||
</ContextMenuItem>
|
||
)}
|
||
{showUpdate && (
|
||
<ContextMenuItem disabled={busy} onClick={() => executeStackActionByFile(file, 'update', 'update')}>
|
||
<Download className="h-4 w-4 mr-2" />
|
||
Update
|
||
</ContextMenuItem>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
{can('stack:delete', 'stack', file.replace(/\.(yml|yaml)$/, '')) && (
|
||
<>
|
||
<ContextMenuSeparator />
|
||
<ContextMenuItem
|
||
className="text-destructive focus:text-destructive"
|
||
onClick={() => {
|
||
setStackToDelete(file.replace(/\.(yml|yaml)$/, ''));
|
||
setDeleteDialogOpen(true);
|
||
}}
|
||
>
|
||
<Trash2 className="h-4 w-4 mr-2" />
|
||
Delete
|
||
</ContextMenuItem>
|
||
</>
|
||
)}
|
||
</ContextMenuContent>
|
||
</ContextMenu>
|
||
))
|
||
)}
|
||
</CommandList>
|
||
|
||
{/* Remote node matches: surfaced only when the user is actively searching. */}
|
||
{searchQuery.trim() && (remoteSearchLoading || Object.keys(remoteStackResults).length > 0) && (
|
||
<div className="mt-3 pt-3 border-t border-glass-border">
|
||
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-subtitle px-4 pb-2 flex items-center gap-2">
|
||
Other nodes
|
||
{remoteSearchLoading && <Loader2 className="w-3 h-3 animate-spin text-stat-icon" strokeWidth={1.5} />}
|
||
</h3>
|
||
{Object.entries(remoteStackResults).map(([nodeIdStr, files]) => {
|
||
const node = nodes.find(n => n.id === Number(nodeIdStr));
|
||
if (!node || files.length === 0) return null;
|
||
return (
|
||
<div key={node.id} className="mb-2">
|
||
<div className="px-4 pb-1 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-stat-subtitle">
|
||
<span className="w-1.5 h-1.5 rounded-full bg-success" />
|
||
<span className="truncate">{node.name}</span>
|
||
</div>
|
||
{files.map(({ file, status }) => (
|
||
<button
|
||
key={`${node.id}:${file}`}
|
||
type="button"
|
||
onClick={() => loadFileOnNode(node, file)}
|
||
className="w-full text-left justify-start rounded-lg mb-1 cursor-pointer hover:bg-glass-highlight px-2 py-1.5 flex items-center gap-2"
|
||
>
|
||
<span
|
||
className={`font-mono text-[10px] shrink-0 w-5 ${
|
||
status === 'running' ? 'text-success' :
|
||
status === 'exited' ? 'text-destructive' : 'text-stat-icon'
|
||
}`}
|
||
>
|
||
{status === 'running' ? 'UP' : status === 'exited' ? 'DN' : '--'}
|
||
</span>
|
||
<span className="flex-1 truncate font-mono text-xs">{file}</span>
|
||
<ArrowUpRight className="w-3 h-3 text-stat-icon shrink-0" strokeWidth={1.5} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ScrollArea>
|
||
</Command>
|
||
</div>
|
||
|
||
{/* Main Content Area */}
|
||
<div className="flex-1 flex flex-col overflow-hidden">
|
||
{/* Top Header Bar — Three-zone layout: Node Pill | Navigation | Utilities */}
|
||
<div className="h-14 flex items-center px-4 border-b border-border gap-3">
|
||
{/* LEFT ZONE: Node Context Pill */}
|
||
<div className="flex-shrink-0">
|
||
{activeNode?.type === 'remote' ? (
|
||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-info-muted border border-info/20 text-info text-sm font-medium">
|
||
<span className="w-2 h-2 rounded-full bg-info animate-pulse shrink-0" />
|
||
{activeNode.name}
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border text-muted-foreground text-sm">
|
||
<span className="w-2 h-2 rounded-full bg-success animate-pulse shrink-0" />
|
||
{activeNode?.name ?? 'Local'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* CENTER ZONE: Navigation Group (hidden on mobile) */}
|
||
<div className="flex-1 hidden md:flex justify-center">
|
||
<Highlight
|
||
className="inset-0 rounded-md bg-accent"
|
||
value={navTabValue}
|
||
controlledItems
|
||
mode="children"
|
||
click={false}
|
||
transition={springs.snappy}
|
||
>
|
||
<div className="inline-flex items-center rounded-lg p-1 gap-0.5">
|
||
{navItems.map(({ value, label, icon: Icon }) => (
|
||
<HighlightItem key={value} value={value}>
|
||
<button
|
||
onClick={() => handleNavigate(value)}
|
||
className={cn(
|
||
'relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-md transition-colors',
|
||
activeView === value
|
||
? 'text-foreground after:absolute after:bottom-0 after:left-1/4 after:right-1/4 after:h-[2px] after:rounded-full after:bg-brand after:blur-[2px]'
|
||
: 'text-muted-foreground hover:text-foreground'
|
||
)}
|
||
>
|
||
<Icon className="w-4 h-4 shrink-0" />
|
||
<span className="hidden xl:inline">{label}</span>
|
||
</button>
|
||
</HighlightItem>
|
||
))}
|
||
</div>
|
||
</Highlight>
|
||
</div>
|
||
|
||
{/* Spacer for mobile (when center nav is hidden) */}
|
||
<div className="flex-1 md:hidden" />
|
||
|
||
{/* RIGHT ZONE: Utilities */}
|
||
<div className="flex-shrink-0 flex items-center gap-2">
|
||
{/* Notifications Popover */}
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg relative" title="Notifications">
|
||
<Bell className="w-4 h-4" />
|
||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||
<span className="absolute -top-1 -right-1 flex h-3 w-3">
|
||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||
</span>
|
||
)}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-80 p-0" align="end">
|
||
<div className="flex items-center justify-between p-4 border-b">
|
||
<h4 className="font-medium">Notifications</h4>
|
||
<div className="flex gap-2">
|
||
{notifications.filter(n => !n.is_read).length > 0 && (
|
||
<Button variant="ghost" size="sm" onClick={markAllRead} className="h-auto p-0 text-xs">
|
||
Mark all as read
|
||
</Button>
|
||
)}
|
||
{notifications.length > 0 && (
|
||
<Button variant="ghost" size="sm" onClick={clearAllNotifications} className="h-auto p-0 text-xs text-muted-foreground hover:text-destructive transition-colors">
|
||
<Trash2 className="w-3 h-3 mr-1" />
|
||
Clear all
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<ScrollArea className="h-80">
|
||
{notifications.length === 0 ? (
|
||
<div className="p-4 text-sm text-muted-foreground text-center">No notifications</div>
|
||
) : (
|
||
<div className="flex flex-col">
|
||
{notifications.map((notif) => (
|
||
<div key={`${notif.nodeId}-${notif.id}`} className={`p-4 border-b text-sm ${notif.is_read ? 'opacity-70' : 'bg-muted/50'} relative group`}>
|
||
<div className="flex items-center gap-2 mb-1 pr-6">
|
||
<Badge variant={notif.level === 'error' ? 'destructive' : notif.level === 'warning' ? 'secondary' : 'default'} className="text-[10px] uppercase">
|
||
{notif.level}
|
||
</Badge>
|
||
{nodesRef.current.find(n => n.id === notif.nodeId)?.type === 'remote' && (
|
||
<Badge variant="outline" className="text-[10px] font-normal">
|
||
{notif.nodeName}
|
||
</Badge>
|
||
)}
|
||
<span className="text-xs text-muted-foreground ml-auto">
|
||
{new Date(notif.timestamp).toLocaleString()}
|
||
</span>
|
||
</div>
|
||
<p className="font-medium pr-6">{notif.message}</p>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
deleteNotification(notif);
|
||
}}
|
||
>
|
||
<X className="w-3 h-3" />
|
||
</Button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</ScrollArea>
|
||
</PopoverContent>
|
||
</Popover>
|
||
|
||
{/* User Profile Dropdown */}
|
||
<UserProfileDropdown
|
||
theme={theme}
|
||
setTheme={setTheme}
|
||
onOpenSettings={() => setSettingsModalOpen(true)}
|
||
/>
|
||
|
||
{/* Mobile Navigation Trigger */}
|
||
<Sheet open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-lg md:hidden">
|
||
<Menu className="w-4 h-4" />
|
||
</Button>
|
||
</SheetTrigger>
|
||
<SheetContent side="right" className="w-64 p-0">
|
||
<div className="p-4 border-b">
|
||
<p className="text-sm font-medium">Navigation</p>
|
||
</div>
|
||
<nav className="flex flex-col p-2 gap-1">
|
||
{navItems.map(({ value, label, icon: Icon }) => (
|
||
<button
|
||
key={value}
|
||
onClick={() => { handleNavigate(value); setMobileNavOpen(false); }}
|
||
className={cn(
|
||
'flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors',
|
||
activeView === value
|
||
? 'bg-glass-highlight font-medium text-foreground'
|
||
: 'text-muted-foreground hover:bg-glass-highlight hover:text-foreground'
|
||
)}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
{label}
|
||
</button>
|
||
))}
|
||
</nav>
|
||
</SheetContent>
|
||
</Sheet>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Workspace */}
|
||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||
{activeView === 'templates' ? (
|
||
<AppStoreView onDeploySuccess={(stackName) => { refreshStacks(); loadFile(stackName); }} />
|
||
) : activeView === 'resources' ? (
|
||
<ResourcesView />
|
||
) : activeView === 'host-console' ? (
|
||
<AdmiralGate featureName="Host Console">
|
||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
|
||
</CapabilityGate>
|
||
</AdmiralGate>
|
||
) : !isLoading && selectedFile && activeView === 'editor' ? (
|
||
<ErrorBoundary>
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-stretch">
|
||
{/* Left Column (Command Center & Terminal) */}
|
||
<div className="flex flex-col h-full gap-6">
|
||
{/* Command Center Card */}
|
||
<Card className="rounded-xl border-muted bg-card">
|
||
<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 navigator.clipboard.writeText(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);
|
||
});
|
||
}}
|
||
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" 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" 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={() => {
|
||
setStackToDelete(selectedFile);
|
||
setDeleteDialogOpen(true);
|
||
}}
|
||
>
|
||
<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 isRunning = container.State === 'running';
|
||
const health = container.healthStatus;
|
||
const uptime = isRunning ? extractUptime(container.Status) : null;
|
||
const hcLabel = healthcheckLabel(health);
|
||
const stats = containerStats[container?.Id];
|
||
const history = stats?.history;
|
||
|
||
const badgeClass = health === 'unhealthy' || !isRunning
|
||
? 'bg-destructive text-destructive-foreground'
|
||
: health === 'starting'
|
||
? 'bg-warning text-warning-foreground'
|
||
: 'bg-success text-success-foreground';
|
||
const badgeGlyph = health === 'unhealthy' || !isRunning ? '✗' : 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-muted bg-muted/30 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={!isRunning}
|
||
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={!isRunning}
|
||
aria-label="Open bash shell"
|
||
>
|
||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{isRunning ? (
|
||
<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-[9px] uppercase tracking-wide 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-[9px] uppercase tracking-wide 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-[9px] uppercase tracking-wide 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 */}
|
||
<div className="flex-1 min-h-[320px] flex flex-col gap-2">
|
||
<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 (The Editor) */}
|
||
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] 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')}>
|
||
<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>
|
||
</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>
|
||
{can('stack:edit', 'stack', stackName) && (
|
||
<div className="flex items-center gap-2">
|
||
<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={handleSaveAndDeploy} 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={saveFile}>
|
||
<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>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="flex-1 min-h-0 flex flex-col">
|
||
{activeTab === 'env' && (
|
||
<div className="bg-info-muted border-b border-info/20 px-4 py-2 flex items-center gap-2 text-xs text-info">
|
||
<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 && (
|
||
<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),
|
||
}}
|
||
/>
|
||
)}
|
||
{isFileLoading && (
|
||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||
Loading...
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
</ErrorBoundary>
|
||
) : activeView === 'global-observability' ? (
|
||
<GlobalObservabilityView />
|
||
) : activeView === 'fleet' ? (
|
||
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||
<FleetView onNavigateToNode={(nodeId, stackName) => {
|
||
const node = nodes.find(n => n.id === nodeId);
|
||
if (node) {
|
||
if (activeNode?.id === nodeId) {
|
||
loadFile(stackName);
|
||
} else {
|
||
pendingStackLoadRef.current = stackName;
|
||
setActiveNode(node);
|
||
}
|
||
}
|
||
}} />
|
||
</CapabilityGate>
|
||
) : activeView === 'audit-log' ? (
|
||
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||
<AuditLogView />
|
||
</CapabilityGate>
|
||
) : activeView === 'auto-updates' ? (
|
||
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
||
<AutoUpdateReadinessView />
|
||
</CapabilityGate>
|
||
) : activeView === 'scheduled-ops' ? (
|
||
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||
</CapabilityGate>
|
||
) : activeView === 'security-history' ? (
|
||
<SecurityHistoryView />
|
||
) : (
|
||
<HomeDashboard
|
||
onNavigateToStack={(stackFile) => { loadFile(stackFile); }}
|
||
notifications={notifications}
|
||
onClearNotifications={clearAllNotifications}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Delete Confirmation Dialog */}
|
||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Delete Stack</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
Are you sure you want to delete {stackToDelete}? This action cannot be undone.
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel onClick={() => setDeleteDialogOpen(false)}>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction className="bg-destructive text-destructive-foreground hover:bg-destructive/90" onClick={deleteStack}>Delete</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<AlertDialog open={!!pendingUnsavedLoad} onOpenChange={(open) => { if (!open) { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); } }}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
You have unsaved changes. Switching stacks will discard them. Continue?
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel onClick={() => { setPendingUnsavedLoad(null); setPendingUnsavedNode(null); }}>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction onClick={() => {
|
||
const target = pendingUnsavedLoad;
|
||
const targetNode = pendingUnsavedNode;
|
||
// Reset content to original so the guard doesn't re-trigger
|
||
setContent(originalContent);
|
||
setEnvContent(originalEnvContent);
|
||
setPendingUnsavedLoad(null);
|
||
setPendingUnsavedNode(null);
|
||
if (target) {
|
||
if (targetNode) loadFileOnNode(targetNode, target);
|
||
else loadFile(target);
|
||
}
|
||
}}>Discard Changes</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
<AlertDialog open={bulkActionOpen} onOpenChange={setBulkActionOpen}>
|
||
<AlertDialogContent>
|
||
<AlertDialogHeader>
|
||
<AlertDialogTitle>
|
||
{bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} all “{bulkActionLabel?.name}” stacks?
|
||
</AlertDialogTitle>
|
||
<AlertDialogDescription>
|
||
This will {bulkAction} all stacks labeled “{bulkActionLabel?.name}”.
|
||
{stackLabelMap && bulkActionLabel && (
|
||
<span className="block mt-2 font-mono text-xs">
|
||
Affected: {Object.entries(stackLabelMap)
|
||
.filter(([, ls]) => ls.some(l => l.id === bulkActionLabel.id))
|
||
.map(([name]) => name)
|
||
.join(', ') || 'none'}
|
||
</span>
|
||
)}
|
||
</AlertDialogDescription>
|
||
</AlertDialogHeader>
|
||
<AlertDialogFooter>
|
||
<AlertDialogCancel disabled={bulkActionRunning}>Cancel</AlertDialogCancel>
|
||
<AlertDialogAction
|
||
disabled={bulkActionRunning}
|
||
onClick={async (e) => {
|
||
e.preventDefault();
|
||
if (!bulkActionLabel) return;
|
||
setBulkActionRunning(true);
|
||
try {
|
||
const res = await apiFetch(`/labels/${bulkActionLabel.id}/action`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ action: bulkAction }),
|
||
});
|
||
if (!res.ok) {
|
||
const data = await res.json().catch(() => ({}));
|
||
throw new Error(data?.error || `Bulk ${bulkAction} failed.`);
|
||
}
|
||
const data = await res.json();
|
||
const failed = (data.results ?? []).filter((r: BulkActionResult) => !r.success);
|
||
if (failed.length > 0) {
|
||
const failedNames = failed.map((r: BulkActionResult) => r.stackName).join(', ');
|
||
toast.error(`Failed to ${bulkAction}: ${failedNames}`);
|
||
} else {
|
||
toast.success(`All stacks ${bulkAction === 'deploy' ? 'deployed' : bulkAction === 'stop' ? 'stopped' : 'restarted'} successfully.`);
|
||
}
|
||
setBulkActionOpen(false);
|
||
refreshStacks(true);
|
||
} catch (err: unknown) {
|
||
toast.error((err as Error)?.message || 'Something went wrong.');
|
||
} finally {
|
||
setBulkActionRunning(false);
|
||
}
|
||
}}
|
||
>
|
||
{bulkActionRunning ? 'Running...' : `${bulkAction.charAt(0).toUpperCase() + bulkAction.slice(1)} All`}
|
||
</AlertDialogAction>
|
||
</AlertDialogFooter>
|
||
</AlertDialogContent>
|
||
</AlertDialog>
|
||
|
||
{/* Bash Exec Modal */}
|
||
{selectedContainer && (
|
||
<BashExecModal
|
||
isOpen={bashModalOpen}
|
||
onClose={closeBashModal}
|
||
containerId={selectedContainer.id}
|
||
containerName={selectedContainer.name}
|
||
/>
|
||
)}
|
||
|
||
{/* LogViewer Modal */}
|
||
{logContainer && (
|
||
<LogViewer
|
||
isOpen={logViewerOpen}
|
||
onClose={closeLogViewer}
|
||
containerId={logContainer.id}
|
||
containerName={logContainer.name}
|
||
/>
|
||
)}
|
||
|
||
|
||
{/* Settings Modal */}
|
||
<SettingsModal
|
||
isOpen={settingsModalOpen}
|
||
onClose={() => { setSettingsModalOpen(false); setSettingsInitialSection('account'); }}
|
||
initialSection={settingsInitialSection}
|
||
/>
|
||
|
||
{/* Stack Alert Sheet */}
|
||
<StackAlertSheet
|
||
isOpen={alertSheetOpen}
|
||
onClose={() => setAlertSheetOpen(false)}
|
||
stackName={alertSheetStack}
|
||
/>
|
||
|
||
{/* Stack Auto-Heal Sheet */}
|
||
<StackAutoHealSheet
|
||
stackName={autoHealStackName ?? ''}
|
||
open={autoHealStackName !== null}
|
||
onOpenChange={(open) => { if (!open) setAutoHealStackName(null); }}
|
||
/>
|
||
|
||
{/* Git Source Panel */}
|
||
{stackName && (
|
||
<GitSourcePanel
|
||
open={gitSourceOpen}
|
||
onOpenChange={setGitSourceOpen}
|
||
stackName={stackName}
|
||
canEdit={can('stack:edit', 'stack', stackName)}
|
||
isDarkMode={isDarkMode}
|
||
onSourceChanged={refreshGitSourcePending}
|
||
/>
|
||
)}
|
||
|
||
{/* Stack config misconfig scan results */}
|
||
<VulnerabilityScanSheet
|
||
scanId={stackMisconfigScanId}
|
||
onClose={() => setStackMisconfigScanId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|