mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-07 01:14:14 +00:00
1cf996142b
* refactor(frontend): extract useOverlayState hook from EditorLayout * refactor(frontend): extract useStackActions hook and wire useOverlayState into EditorLayout * refactor(frontend): fix quality issues in useStackActions post-review * fix(frontend): fix interval leak, RunResult contract, yml hardcode, and loadFile length in useStackActions * refactor(frontend): extract useSidebarContextMenu hook from EditorLayout * refactor(frontend): extract ShellOverlays component from EditorLayout * refactor(frontend): relocate Monaco layout effect and log-viewer event listener out of EditorLayout The Monaco tab-switch layout effect is now self-contained in EditorView, alongside its monacoEditorRef. The SENCHO_OPEN_LOGS_EVENT listener moves into useOverlayState, where openLogViewer lives. EditorLayout is left with the two coordination effects that depend on cross-hook state.
431 lines
15 KiB
TypeScript
431 lines
15 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
import { Button } from './ui/button';
|
|
import { Plus } from 'lucide-react';
|
|
import { UserProfileDropdown } from './UserProfileDropdown';
|
|
import { NotificationPanel } from './NotificationPanel';
|
|
import { TopBar } from './TopBar';
|
|
import { ViewRouter } from './EditorLayout/ViewRouter';
|
|
import { CreateStackDialog } from './EditorLayout/CreateStackDialog';
|
|
import { EditorView } from './EditorLayout/EditorView';
|
|
import { ShellOverlays } from './EditorLayout/ShellOverlays';
|
|
import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState';
|
|
import { useStackListState } from './EditorLayout/hooks/useStackListState';
|
|
import { useViewNavigationState } from './EditorLayout/hooks/useViewNavigationState';
|
|
import { useOverlayState } from './EditorLayout/hooks/useOverlayState';
|
|
import { useStackActions } from './EditorLayout/hooks/useStackActions';
|
|
import { useTheme } from './EditorLayout/hooks/useTheme';
|
|
import { useNotifications } from './EditorLayout/hooks/useNotifications';
|
|
import { useContainerStats } from './EditorLayout/hooks/useContainerStats';
|
|
import { useSidebarContextMenu } from './EditorLayout/hooks/useSidebarContextMenu';
|
|
import { NodeSwitcher } from './NodeSwitcher';
|
|
import {
|
|
GlobalCommandPalette,
|
|
GlobalCommandPaletteProvider,
|
|
GlobalCommandPaletteTrigger,
|
|
} from './GlobalCommandPalette';
|
|
import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events';
|
|
import type { SenchoOpenLogsDetail } from '@/lib/events';
|
|
import { useNodes } from '@/context/NodeContext';
|
|
import { useAuth } from '@/context/AuthContext';
|
|
import { useLicense } from '@/context/LicenseContext';
|
|
import { useDeployFeedback } from '@/context/DeployFeedbackContext';
|
|
import { useTrivyStatus } from '@/hooks/useTrivyStatus';
|
|
import { StackSidebar } from '@/components/sidebar/StackSidebar';
|
|
import type { StackRowStatus } from '@/components/sidebar/stack-status-utils';
|
|
import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled';
|
|
|
|
export default function EditorLayout() {
|
|
const { isAdmin, can } = useAuth();
|
|
const { isPaid, license } = useLicense();
|
|
const { status: trivy } = useTrivyStatus();
|
|
const { runWithLog } = useDeployFeedback();
|
|
|
|
const editorState = useEditorViewState();
|
|
const {
|
|
stackMisconfigScanning,
|
|
copiedDigest, setCopiedDigest,
|
|
copiedDigestTimerRef,
|
|
content, setContent,
|
|
envContent, setEnvContent,
|
|
envExists,
|
|
envFiles,
|
|
selectedEnvFile,
|
|
containers,
|
|
activeTab, setActiveTab,
|
|
logsMode, setLogsMode,
|
|
gitSourceOpen, setGitSourceOpen,
|
|
gitSourcePendingMap,
|
|
isFileLoading,
|
|
backupInfo,
|
|
isEditing,
|
|
editingCompose, setEditingCompose,
|
|
} = editorState;
|
|
|
|
const stackListState = useStackListState();
|
|
const {
|
|
selectedFile,
|
|
isLoading,
|
|
stackActions: stackActionMap,
|
|
isScanning,
|
|
searchQuery, setSearchQuery,
|
|
stackStatuses,
|
|
stackLabelMap,
|
|
filterChip, setFilterChip,
|
|
bulkMode,
|
|
selectedFiles,
|
|
filterCounts,
|
|
chipFilteredFiles,
|
|
remoteResults,
|
|
isStackBusy,
|
|
refreshStacks,
|
|
fetchAutoUpdateSettings,
|
|
handleScanStacks,
|
|
scheduleStateInvalidateRefresh,
|
|
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
|
stackUpdates,
|
|
pinned,
|
|
isCollapsed, toggleCollapse,
|
|
remoteSearchLoading,
|
|
} = stackListState;
|
|
|
|
const { nodes, activeNode, setActiveNode } = useNodes();
|
|
|
|
const overlayState = useOverlayState();
|
|
const {
|
|
createDialogOpen, setCreateDialogOpen,
|
|
} = overlayState;
|
|
|
|
const [diffPreviewEnabled] = useComposeDiffPreviewEnabled();
|
|
|
|
// Use a ref to break the circular dependency:
|
|
// useViewNavigationState needs onNavigateToDashboard -> resetEditorState
|
|
// but stackActions isn't created until after navState
|
|
const resetEditorStateRef = useRef<() => void>(() => {});
|
|
|
|
const navState = useViewNavigationState({
|
|
onNavigateToDashboard: () => resetEditorStateRef.current(),
|
|
});
|
|
const {
|
|
activeView, setActiveView,
|
|
settingsSection, setSettingsSection,
|
|
securityHistoryOpen, setSecurityHistoryOpen,
|
|
filterNodeId, setFilterNodeId,
|
|
schedulePrefill,
|
|
mobileNavOpen, setMobileNavOpen,
|
|
handleOpenSettings,
|
|
handlePrefillConsumed,
|
|
handleNavigate,
|
|
navItems,
|
|
} = navState;
|
|
|
|
const isAdmiral = license?.variant === 'admiral';
|
|
|
|
const {
|
|
notifications,
|
|
tickerConnected,
|
|
markAllRead,
|
|
deleteNotification,
|
|
clearAllNotifications,
|
|
} = useNotifications({
|
|
nodes,
|
|
onStateInvalidate: scheduleStateInvalidateRefresh,
|
|
onAutoUpdateChange: fetchAutoUpdateSettings,
|
|
});
|
|
|
|
const containerStats = useContainerStats(containers);
|
|
|
|
const stackActions = useStackActions({
|
|
editorState,
|
|
stackListState,
|
|
navState,
|
|
overlayState,
|
|
activeNode,
|
|
setActiveNode,
|
|
nodes,
|
|
isPaid,
|
|
runWithLog,
|
|
diffPreviewEnabled,
|
|
});
|
|
|
|
// Wire the ref now that stackActions is available
|
|
resetEditorStateRef.current = stackActions.resetEditorState;
|
|
|
|
const buildMenuCtx = useSidebarContextMenu({
|
|
stackListState,
|
|
navState,
|
|
overlayState,
|
|
stackActions,
|
|
activeNode,
|
|
isPaid,
|
|
isAdmiral,
|
|
can,
|
|
});
|
|
|
|
const {
|
|
pendingStackLoadRef,
|
|
pendingLogsRef,
|
|
} = stackActions;
|
|
|
|
const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null;
|
|
const stackName = selectedFile || '';
|
|
|
|
const { theme, setTheme, isDarkMode } = useTheme();
|
|
|
|
// 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;
|
|
|
|
stackActions.resetEditorState();
|
|
|
|
if (pendingStack) {
|
|
void stackActions.loadFile(pendingStack);
|
|
} else {
|
|
setActiveView('dashboard');
|
|
}
|
|
|
|
refreshStacks();
|
|
fetchAutoUpdateSettings();
|
|
void stackActions.refreshGitSourcePending();
|
|
}, [activeNode?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Resolve a pending container name (from notification click) to a live
|
|
// container id once the target stack's container list loads, then dispatch
|
|
// the logs event. Only consume when the current stack matches the pending
|
|
// target — prevents a canceled unsaved-load from leaking the pending name
|
|
// into an unrelated container refresh. Container ids churn across
|
|
// recreations, so we store the name and resolve here instead of storing an
|
|
// id at dispatch time.
|
|
useEffect(() => {
|
|
const pending = pendingLogsRef.current;
|
|
if (!pending || selectedFile !== pending.stackName || containers.length === 0) return;
|
|
pendingLogsRef.current = null;
|
|
const match = containers.find(c =>
|
|
(c.Names ?? []).some(n => n.replace(/^\//, '') === pending.containerName),
|
|
);
|
|
if (match) {
|
|
window.dispatchEvent(new CustomEvent<SenchoOpenLogsDetail>(SENCHO_OPEN_LOGS_EVENT, {
|
|
detail: { containerId: match.Id, containerName: pending.containerName },
|
|
}));
|
|
}
|
|
}, [containers, selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const createStackSlot = can('stack:create') ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
className="rounded-lg w-full"
|
|
onClick={() => setCreateDialogOpen(true)}
|
|
>
|
|
<Plus className="w-4 h-4 mr-2" />
|
|
Create Stack
|
|
</Button>
|
|
<CreateStackDialog
|
|
open={createDialogOpen}
|
|
onOpenChange={setCreateDialogOpen}
|
|
onStackCreated={async (sName) => {
|
|
await refreshStacks();
|
|
await stackActions.loadFile(sName);
|
|
}}
|
|
onStacksChanged={async () => { await refreshStacks(); }}
|
|
/>
|
|
</>
|
|
) : null;
|
|
|
|
return (
|
|
<GlobalCommandPaletteProvider>
|
|
<div className="flex h-screen w-screen overflow-hidden bg-background text-foreground">
|
|
<GlobalCommandPalette
|
|
navItems={navItems}
|
|
onNavigate={handleNavigate}
|
|
onSelectStack={stackActions.loadFileOnNode}
|
|
/>
|
|
{/* Left Sidebar (Stacks) */}
|
|
<StackSidebar
|
|
isDarkMode={isDarkMode}
|
|
nodeSwitcherSlot={
|
|
<NodeSwitcher
|
|
onManageNodes={() => handleOpenSettings('nodes')}
|
|
/>
|
|
}
|
|
createStackSlot={createStackSlot}
|
|
onScan={handleScanStacks}
|
|
isScanning={isScanning}
|
|
canCreate={can('stack:create')}
|
|
searchQuery={searchQuery}
|
|
onSearchChange={setSearchQuery}
|
|
filterChip={filterChip}
|
|
filterCounts={filterCounts}
|
|
onFilterChipChange={setFilterChip}
|
|
list={{
|
|
files: chipFilteredFiles,
|
|
isLoading,
|
|
isPaid,
|
|
selectedFile,
|
|
searchQuery,
|
|
stackLabelMap,
|
|
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
|
stackUpdates,
|
|
gitSourcePendingMap,
|
|
pinnedFiles: pinned,
|
|
isCollapsed,
|
|
toggleCollapse,
|
|
isBusy: isStackBusy,
|
|
getDisplayName: stackActions.getDisplayName,
|
|
onSelectFile: stackActions.loadFile,
|
|
buildMenuCtx,
|
|
remoteResults,
|
|
remoteLoading: remoteSearchLoading,
|
|
onSelectRemoteFile: (nodeId, file) => {
|
|
const node = nodes.find(n => n.id === nodeId);
|
|
if (node) void stackActions.loadFileOnNode(node, file);
|
|
},
|
|
}}
|
|
notifications={notifications}
|
|
tickerConnected={tickerConnected}
|
|
onOpenActivity={() => setActiveView('global-observability')}
|
|
bulkMode={bulkMode}
|
|
selectedFiles={selectedFiles}
|
|
isPaid={isPaid}
|
|
onToggleBulkMode={toggleBulkMode}
|
|
onToggleSelect={toggleSelect}
|
|
onClearSelection={clearSelection}
|
|
onBulkAction={handleBulkAction}
|
|
/>
|
|
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
<TopBar
|
|
activeView={activeView}
|
|
navItems={navItems}
|
|
onNavigate={handleNavigate}
|
|
mobileNavOpen={mobileNavOpen}
|
|
onMobileNavOpenChange={setMobileNavOpen}
|
|
search={<GlobalCommandPaletteTrigger />}
|
|
notifications={
|
|
<NotificationPanel
|
|
notifications={notifications}
|
|
nodes={nodes}
|
|
onMarkAllRead={markAllRead}
|
|
onClearAll={clearAllNotifications}
|
|
onDelete={deleteNotification}
|
|
onNavigate={stackActions.navigateToNotification}
|
|
/>
|
|
}
|
|
userMenu={
|
|
<UserProfileDropdown
|
|
theme={theme}
|
|
setTheme={setTheme}
|
|
onOpenSettings={() => handleOpenSettings('account')}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{/* Main Workspace */}
|
|
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
|
<ViewRouter
|
|
activeView={activeView}
|
|
selectedFile={selectedFile}
|
|
isLoading={isLoading}
|
|
settingsSection={settingsSection}
|
|
onSettingsSectionChange={setSettingsSection}
|
|
onTemplateDeploySuccess={(sName) => {
|
|
refreshStacks();
|
|
void stackActions.loadFile(sName);
|
|
}}
|
|
onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')}
|
|
onFleetNavigateToNode={(nodeId, sName) => {
|
|
const node = nodes.find(n => n.id === nodeId);
|
|
if (node) {
|
|
if (activeNode?.id === nodeId) {
|
|
void stackActions.loadFile(sName);
|
|
} else {
|
|
pendingStackLoadRef.current = sName;
|
|
setActiveNode(node);
|
|
}
|
|
}
|
|
}}
|
|
filterNodeId={filterNodeId}
|
|
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
|
schedulePrefill={schedulePrefill}
|
|
onPrefillConsumed={handlePrefillConsumed}
|
|
notifications={notifications}
|
|
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
|
onOpenSettingsSection={(section) => handleOpenSettings(section)}
|
|
onClearNotifications={clearAllNotifications}
|
|
renderEditor={() => (
|
|
<EditorView
|
|
stackName={stackName}
|
|
isDarkMode={isDarkMode}
|
|
containers={containers}
|
|
containerStats={containerStats}
|
|
content={content}
|
|
envContent={envContent}
|
|
envExists={envExists}
|
|
envFiles={envFiles}
|
|
selectedEnvFile={selectedEnvFile}
|
|
isFileLoading={isFileLoading}
|
|
backupInfo={backupInfo}
|
|
gitSourcePendingMap={gitSourcePendingMap}
|
|
notifications={notifications}
|
|
activeTab={activeTab}
|
|
isEditing={isEditing}
|
|
editingCompose={editingCompose}
|
|
logsMode={logsMode}
|
|
copiedDigest={copiedDigest}
|
|
loadingAction={loadingAction}
|
|
stackMisconfigScanning={stackMisconfigScanning}
|
|
can={can}
|
|
isAdmin={isAdmin}
|
|
isPaid={isPaid}
|
|
trivy={trivy}
|
|
activeNode={activeNode}
|
|
copiedDigestTimerRef={copiedDigestTimerRef}
|
|
deployStack={stackActions.deployStack}
|
|
restartStack={stackActions.restartStack}
|
|
stopStack={stackActions.stopStack}
|
|
updateStack={stackActions.updateStack}
|
|
rollbackStack={stackActions.rollbackStack}
|
|
scanStackConfig={stackActions.scanStackConfig}
|
|
enterEditMode={stackActions.enterEditMode}
|
|
requestSave={stackActions.requestSave}
|
|
requestSaveAndDeploy={stackActions.requestSaveAndDeploy}
|
|
discardChanges={stackActions.discardChanges}
|
|
setContent={setContent}
|
|
setEnvContent={setEnvContent}
|
|
changeEnvFile={stackActions.changeEnvFile}
|
|
openLogViewer={stackActions.openLogViewer}
|
|
openBashModal={stackActions.openBashModal}
|
|
serviceAction={stackActions.serviceAction}
|
|
setActiveTab={setActiveTab}
|
|
setLogsMode={setLogsMode}
|
|
setEditingCompose={setEditingCompose}
|
|
setGitSourceOpen={setGitSourceOpen}
|
|
setCopiedDigest={setCopiedDigest}
|
|
requestDeleteStack={stackActions.requestDeleteStack}
|
|
/>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<ShellOverlays
|
|
overlayState={overlayState}
|
|
stackActions={stackActions}
|
|
isDarkMode={isDarkMode}
|
|
isAdmin={isAdmin}
|
|
can={can}
|
|
selectedFile={selectedFile}
|
|
stackName={stackName}
|
|
gitSourceOpen={gitSourceOpen}
|
|
setGitSourceOpen={setGitSourceOpen}
|
|
securityHistoryOpen={securityHistoryOpen}
|
|
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
|
/>
|
|
</div>
|
|
</GlobalCommandPaletteProvider>
|
|
);
|
|
}
|