import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; import { Button } from './ui/button'; import { Plus, Loader2, ChevronLeft } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog'; import { EditorView } from './EditorLayout/EditorView'; import { ShellOverlays } from './EditorLayout/ShellOverlays'; import { classifyFailedGate } from './EditorLayout/failed-gate-recovery'; 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, NODE_SWITCH_PENDING_TOKEN } from './EditorLayout/hooks/useStackActions'; import { useTheme } from '@/hooks/use-theme'; import { ThemeQuickSwitch } from './theme/ThemeQuickSwitch'; 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, SENCHO_OPEN_STACK_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail, SenchoOpenStackDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; 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 { useSidebarActivitySummary } from '@/components/sidebar/useSidebarActivitySummary'; import { useNextAutoUpdateRun } from '@/components/sidebar/useNextAutoUpdateRun'; import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionStartedAt'; import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { useTopNavLabels } from '@/hooks/use-top-nav-labels'; import { useTopNavAlign } from '@/hooks/use-top-nav-align'; import { useStackMuteActions } from '@/hooks/useMuteRuleActions'; import { toast } from '@/components/ui/toast-store'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { MobileTabBar } from './MobileTabBar'; import { MobileMoreMenu } from './MobileMoreMenu'; import { Masthead, type Tone } from './mobile/mobile-ui'; import { MobileDashboard } from './mobile/MobileDashboard'; import { MobileFleet } from './mobile/MobileFleet'; import { MobileSchedules } from './mobile/MobileSchedules'; import { MobileSettings } from './mobile/MobileSettings'; import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surface'; import { BESPOKE_MOBILE_VIEWS } from './EditorLayout/mobile-treatments'; import { CapabilityGate } from './CapabilityGate'; import { HubOnlyGate } from './HubOnlyGate'; import type { SectionId } from './settings/types'; import type { NotificationItem } from './dashboard/types'; // These bespoke phone screens reuse the desktop view's component (with a mobile // branch), code-split exactly like the desktop content path so the heavy chunks // stay out of the main shell bundle. const SecurityView = lazy(() => import('./SecurityView').then(m => ({ default: m.SecurityView }))); const AutoUpdateReadinessView = lazy(() => import('./AutoUpdateReadinessView')); const AppStoreView = lazy(() => import('./AppStoreView').then(m => ({ default: m.AppStoreView }))); const AuditLogView = lazy(() => import('./AuditLogView').then(m => ({ default: m.AuditLogView }))); const ResourcesView = lazy(() => import('./ResourcesView')); const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView }))); export default function EditorLayout() { const { isAdmin, can } = useAuth(); const { status: trivy } = useTrivyStatus(); const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback(); // The last live output line captured for a stack while its deploy-feedback // session is still streaming, used to enrich a failure record's diagnostics. // Guards on both the streaming status and an exact stack-name match, so // neither another stack's output nor a finished session's stale line leaks in. const getLastDeployOutputLine = useCallback( (forStack: string): string | undefined => panelState.status === 'streaming' && panelState.stackName === forStack ? logRows.at(-1)?.message : undefined, [panelState.status, panelState.stackName, logRows], ); const editorState = useEditorViewState(); const { stackMisconfigScanning, 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, stackSelfFlags, stackCounts, stackLabelMap, filterChip, setFilterChip, bulkMode, selectedFiles, filterCounts, chipFilteredFiles, remoteResults, isStackBusy, refreshStacks, handleScanStacks, scheduleStateInvalidateRefresh, toggleBulkMode, toggleSelect, clearSelection, handleBulkAction, stackUpdates, fetchImageUpdates, sidebarIndicators, sidebarStackUpdates, pinned, isCollapsed, toggleCollapse, remoteSearchLoading, remoteSearchFailedNodes, lastActionResult, clearActionRecords, dismissActionResult, } = stackListState; const { nodes, activeNode, setActiveNode, hasCapability } = useNodes(); // Mirror activeNode.id in a ref so async handlers (e.g. CreateStackDialog's // post-create handoff) can detect a node switch that happened mid-flight. // Closure capture of activeNode would always match the value at handler-creation // time and miss the switch. const activeNodeIdRef = useRef(activeNode?.id ?? null); useEffect(() => { activeNodeIdRef.current = activeNode?.id ?? null; }, [activeNode?.id]); const overlayState = useOverlayState(); const { createDialogOpen, setCreateDialogOpen, } = overlayState; // Which mode the create dialog opens on. The toolbar Create button opens on // 'empty'; the zero-stacks empty state opens on 'import'. const [createDialogInitialMode, setCreateDialogInitialMode] = useState('empty'); const openCreateDialog = useCallback((mode: CreateMode) => { setCreateDialogInitialMode(mode); setCreateDialogOpen(true); }, [setCreateDialogOpen]); const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); const [topNavLabels] = useTopNavLabels(); const [topNavAlign] = useTopNavAlign(); // 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, securityTab, setSecurityTab, fleetTab, setFleetTab, filterNodeId, setFilterNodeId, schedulePrefill, muteRulePrefill, mobileNavOpen, setMobileNavOpen, handleOpenSettings, handlePrefillConsumed, handleMutePrefillConsumed, handleNavigate, navItems, openMuteRulesWithPrefill, } = navState; const { notifications, tickerConnected, markAllRead, deleteNotification, clearAllNotifications, } = useNotifications({ nodes, onStateInvalidate: scheduleStateInvalidateRefresh, onImageUpdatesChange: fetchImageUpdates, }); const { stats: containerStats, error: containerStatsError } = useContainerStats( containers, activeNode?.id ?? null, ); const stackActions = useStackActions({ editorState, stackListState, navState, overlayState, activeNode, setActiveNode, nodes, runWithLog, getLastDeployOutputLine, diffPreviewEnabled, hasUpdateGuard: hasCapability('update-guard'), }); // Wire the ref now that stackActions is available resetEditorStateRef.current = stackActions.resetEditorState; // A failed health gate routes into the existing recovery affordance: record // a failure for the stack so RecoveryChip/RecoveryPanel offer the same // explicit, user-confirmed rollback as any failed operation. Keyed by gate // id so one verdict records exactly once. const handledGateRef = useRef(null); useEffect(() => { if (!healthGate || handledGateRef.current === healthGate.gateId) return; // Record only on the node the gate ran on. A wrong node or a not-yet-loaded // stack file leaves it unhandled (not marked), so the effect retries when the // active node returns or the files refresh, recording exactly once. const outcome = classifyFailedGate( healthGate, activeNodeIdRef.current, stackListState.filesNodeId, stackListState.files, ); if (outcome.kind === 'no-file') { console.warn('[HealthGate] no stack file matches failed gate for', healthGate.stackName); return; } if (outcome.kind === 'skip') return; handledGateRef.current = healthGate.gateId; stackListState.recordActionFailure(outcome.stackFile, { action: healthGate.trigger, rolledBack: false, errorMessage: `Health gate failed: ${healthGate.reason ?? 'containers did not stay healthy after the update'}`, startedAt: healthGate.startedAt ?? Date.now(), endedAt: Date.now(), failure: { reason: 'healthcheck_failed', label: 'Health gate failed', suggestion: 'Check the container logs; roll back if the previous version was healthy.', }, }); }, [healthGate, stackListState.files, stackListState.filesNodeId, stackListState.recordActionFailure]); const buildMenuCtx = useSidebarContextMenu({ stackListState, navState, overlayState, stackActions, activeNode, isAdmin, can, }); const { pendingStackLoadRef, pendingLogsRef, } = stackActions; const panelStartedAt = usePanelSessionStartedAt(panelState); const nextAutoUpdateRunAt = useNextAutoUpdateRun(); const activitySummary = useSidebarActivitySummary({ notifications, tickerConnected, panelState, panelStartedAt, nextAutoUpdateRunAt, }); const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; const stackName = selectedFile || ''; const stackDisplayName = selectedFile ? selectedFile.replace(/\.(yml|yaml)$/, '') : ''; const stackMuteActions = useStackMuteActions(stackDisplayName, openMuteRulesWithPrefill); const { isDarkMode } = useTheme(); // ---- Mobile shell (below md) --------------------------------------------- // Desktop renders the persistent sidebar + workspace untouched. On a phone we // show exactly one surface at a time: the stack list, a top-level view, or a // full-screen stack detail. `mobileView` is explicit state, decoupled from // `activeView`, so 'dashboard' still maps to HomeDashboard everywhere. const isMobile = useIsMobile(); const [mobileView, setMobileView] = useState('list'); // Optimistically flip to the detail surface the instant a row is tapped, // before loadFile's fetch resolves selectedFile; cleared once it settles. const [pendingDetailStack, setPendingDetailStack] = useState(null); const [fleetUpdatesIntent, setFleetUpdatesIntent] = useState<{ tab: 'nodes' | 'changelog' } | null>(null); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null); }, [isFileLoading, pendingDetailStack]); const handleFleetUpdatesIntentConsumed = useCallback(() => setFleetUpdatesIntent(null), []); const { surface: mobileSurface, detailReady, detailOpen } = deriveMobileSurface({ activeView, selectedFile, mobileView, pendingDetailStack, }); // A phone shows one surface at a time, so every mobile navigation tears down // the current detail and switches surfaces, guarding a dirty editor first. // `then` runs the destination-specific work (navigate to a view, open // settings) after the surface flips. const leaveToMobileSurface = (target: MobileView, then?: () => void) => { stackActions.attemptLeaveEditor(() => { stackActions.resetEditorState(); setPendingDetailStack(null); setMobileView(target); then?.(); }); }; const goToMobileList = () => leaveToMobileSurface('list'); const navigateMobileAware = (view: string) => leaveToMobileSurface('content', () => handleNavigate(view)); const openSettingsMobileAware = (section?: SectionId) => leaveToMobileSurface('content', () => handleOpenSettings(section)); // Settings navigation from outside the bottom bar (profile menu, node // switcher, dashboard config links). On mobile it flips to the content // surface so the section is actually shown instead of leaving the user on // the stack list; on desktop it is the plain open. const openSettings = (section?: SectionId) => (isMobile ? openSettingsMobileAware(section) : handleOpenSettings(section)); // Tapping a stack row on mobile flips to the detail surface immediately. const handleSelectStack = (file: string) => { if (isMobile) setPendingDetailStack(file); void stackActions.loadFile(file); }; // Open a specific stack on a node (from Fleet): load it directly if that node // is already active, else stash it and switch nodes (the node-switch effect // loads the pending stack once the registry settles). Mobile shows the // optimistic detail surface immediately. const handleFleetNavigateToNode = (nodeId: number, stackName: string) => { const node = nodes.find(n => n.id === nodeId); if (!node) return; if (isMobile) setPendingDetailStack(stackName); if (activeNode?.id === nodeId) { void stackActions.loadFile(stackName); } else { pendingStackLoadRef.current = stackName; setActiveNode(node); } }; // Open a stack from another surface (e.g. a Resources network card). Reuses // the Fleet navigation, which loads the stack on its node (switching nodes if // needed) and flips to the editor view. A latest-ref keeps the window handler // current without re-subscribing every render. const openStackFromEventRef = useRef(handleFleetNavigateToNode); useEffect(() => { openStackFromEventRef.current = handleFleetNavigateToNode; }); useEffect(() => { const handler = (e: Event) => { const detail = (e as CustomEvent).detail; if (detail) openStackFromEventRef.current(detail.nodeId, detail.stackName); }; window.addEventListener(SENCHO_OPEN_STACK_EVENT, handler); return () => window.removeEventListener(SENCHO_OPEN_STACK_EVENT, handler); }, []); // "Inspect" a node from the mobile Fleet screen: switch to it and land on its // stack list. const handleInspectNode = (nodeId: number) => { const node = nodes.find(n => n.id === nodeId); if (!node) return; if (activeNode?.id !== nodeId) setActiveNode(node); goToMobileList(); }; // Hamburger / command-palette navigation is mobile-aware so it collapses the // current surface and honors the unsaved-changes guard; desktop is untouched. const navHandler = isMobile ? navigateMobileAware : handleNavigate; // Sidebar activity actions navigate to top-level views. On mobile they must // flip the surface to content (otherwise the user stays on the stack list); // on desktop they set the view directly as before. const handleActivityAction = useCallback((action: SidebarActivityAction) => { switch (action.kind) { case 'open-stack-notification': stackActions.navigateToNotification(action.summary.notif); return; case 'open-auto-updates': if (isMobile) navigateMobileAware('auto-updates'); else setActiveView('auto-updates'); return; case 'open-activity': if (isMobile) navigateMobileAware('global-observability'); else setActiveView('global-observability'); return; case 'noop': return; } }, [stackActions, setActiveView, isMobile, navigateMobileAware]); // Notification navigation: node_update_available notifications route to the // Fleet view and open the Node Updates sheet (desktop only). The intent state // handles both cross-view navigation and same-view re-entry (handleNavigate // returns early when already on Fleet, but the state change triggers render). const handleNotificationNavigate = useCallback((notif: NotificationItem) => { if (notif.category === 'node_update_available') { if (isMobile) { navigateMobileAware('fleet'); } else { setFleetUpdatesIntent({ tab: 'nodes' }); handleNavigate('fleet'); } return; } stackActions.navigateToNotification(notif); }, [isMobile, navigateMobileAware, handleNavigate, stackActions]); const handleNotificationNavigateChangelog = useCallback(() => { if (isMobile) { navigateMobileAware('fleet'); } else { setFleetUpdatesIntent({ tab: 'changelog' }); handleNavigate('fleet'); } }, [isMobile, navigateMobileAware, handleNavigate]); const renderEditor = (headerActions?: ReactNode) => ( { if (!selectedFile) return; const name = selectedFile.replace(/\.(yml|yaml)$/, ''); const ok = await stackActions.refreshSelectedContainers(name, selectedFile); await refreshStacks(true); if (ok) toast.success('Refreshed container state.'); else toast.error('Could not refresh container state.'); }} onDismissRecovery={() => { if (selectedFile) dismissActionResult(selectedFile); }} panelStartedAt={panelStartedAt} onMobileBack={goToMobileList} onCloseEditor={() => stackActions.attemptLeaveEditor(() => setEditingCompose(false))} hasUnsavedChanges={stackActions.hasUnsavedChanges} stackMuteActions={selectedFile ? stackMuteActions : undefined} /> ); // Track the last "committed" node id so the node-switch dirty guard can // detect an actual switch (vs the initial mount or an internal revert). const lastSeenNodeIdRef = useRef(activeNode?.id ?? null); // Set true when we revert activeNode after stashing a pending switch, so the // re-fire of this effect on the reverted id is a no-op. const revertingNodeSwitchRef = useRef(false); // 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 (revertingNodeSwitchRef.current) { revertingNodeSwitchRef.current = false; return; } if (!activeNode) return; const previousId = lastSeenNodeIdRef.current; const isRealSwitch = previousId !== null && previousId !== activeNode.id; // A node-switch prompt is already open. Ignore any further activeNode // changes until the user resolves the current dialog; revert silently so // the dialog's pendingUnsavedNode stays anchored to the first attempt. if (isRealSwitch && overlayState.pendingUnsavedLoad === NODE_SWITCH_PENDING_TOKEN) { const previousNode = nodes.find(n => n.id === previousId); if (previousNode) { revertingNodeSwitchRef.current = true; setActiveNode(previousNode); } return; } if (isRealSwitch && stackActions.hasUnsavedChanges()) { const previousNode = nodes.find(n => n.id === previousId); if (previousNode) { // Stash the attempted node + open the unsaved-changes dialog via the // existing pendingUnsavedLoad/Node fields. Revert activeNode back to // the previous node; the revertingNodeSwitchRef makes the resulting // effect fire a no-op so dirty content survives. overlayState.setPendingUnsavedNode(activeNode); overlayState.setPendingUnsavedLoad(NODE_SWITCH_PENDING_TOKEN); revertingNodeSwitchRef.current = true; setActiveNode(previousNode); return; } // Previous node is no longer reachable from the nodes list (deleted or // dropped from the registry). We cannot revert, so the unsaved edits // will be lost. Warn the operator before the wipe so the loss is at // least visible. toast.warning('Unsaved changes were discarded: the previous node is no longer available.'); } lastSeenNodeIdRef.current = activeNode.id; const pendingStack = pendingStackLoadRef.current; pendingStackLoadRef.current = null; stackActions.resetEditorState(); // Stack filenames can repeat across nodes; drop the previous node's failure // records so a stale recovery panel cannot surface on the new node. clearActionRecords(); if (pendingStack) { void stackActions.loadFile(pendingStack); } else { setActiveView('dashboard'); } refreshStacks(); 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(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') ? ( <> { await refreshStacks(); // loadFile keeps its own unsaved-changes overlay (intentional safety, // shared with every other "switch to a different stack" code path). // Skip the load if the user switched nodes mid-create so we do not // 404 against a stack name that lives on the previous node. if (sourceNodeId != null && activeNodeIdRef.current !== sourceNodeId) { toast.info(`Stack "${sName}" created on the previous node.`); return; } await stackActions.loadFile(sName); }} onStacksChanged={async () => { await refreshStacks(); }} /> ) : null; return ( {(() => { const commandPaletteEl = ( ); const sidebarEl = ( openSettings('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, selectedFile, searchQuery, stackLabelMap, stackStatuses: stackStatuses as Record, stackCounts, stackUpdates: sidebarStackUpdates, gitSourcePendingMap, pinnedFiles: pinned, isCollapsed, toggleCollapse, isBusy: isStackBusy, getDisplayName: stackActions.getDisplayName, onSelectFile: handleSelectStack, buildMenuCtx, remoteResults, remoteLoading: remoteSearchLoading, remoteFailedNodes: remoteSearchFailedNodes, onSelectRemoteFile: (nodeId, file) => { const node = nodes.find(n => n.id === nodeId); if (node) void stackActions.loadFileOnNode(node, file); }, filterChip, onOpenCreate: can('stack:create') ? openCreateDialog : undefined, openMuteRulesWithPrefill, }} activitySummary={activitySummary} onActivityAction={handleActivityAction} bulkMode={bulkMode} selectedFiles={selectedFiles} onToggleBulkMode={toggleBulkMode} onToggleSelect={toggleSelect} onClearSelection={clearSelection} onBulkAction={handleBulkAction} showUpdatesChip={sidebarIndicators} /> ); const notificationsEl = ( ); const themeSwitchEl = openSettings('appearance')} />; const userMenuEl = openSettings('account')} />; const topBarEl = ( } themeSwitch={themeSwitchEl} notifications={notificationsEl} userMenu={userMenuEl} showLabels={topNavLabels} navAlign={topNavAlign} /> ); // On the bespoke mobile screens the TopBar is dropped, so notifications and // the secondary-destinations menu are rehomed into each screen's masthead // right slot. const mobileMastheadActions = (
{notificationsEl} {themeSwitchEl}{userMenuEl}} />
); const workspaceEl = (
{ refreshStacks(); void stackActions.loadFile(sName); }} onHostConsoleClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} onFleetNavigateToNode={handleFleetNavigateToNode} filterNodeId={filterNodeId} onClearScheduledOpsFilter={() => setFilterNodeId(null)} schedulePrefill={schedulePrefill} onPrefillConsumed={handlePrefillConsumed} muteRulePrefill={muteRulePrefill} onMutePrefillConsumed={handleMutePrefillConsumed} notifications={notifications} onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }} onOpenSettingsSection={(section) => openSettings(section)} onOpenMuteRulesWithPrefill={openMuteRulesWithPrefill} onClearNotifications={clearAllNotifications} fleetUpdatesIntent={fleetUpdatesIntent} onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed} securityTab={securityTab} onSecurityTabChange={setSecurityTab} fleetTab={fleetTab} onFleetTabConsumed={() => setFleetTab(null)} renderEditor={renderEditor} stackUpdates={stackUpdates} />
); const shellOverlaysEl = ( { if (isMobile) { navigateMobileAware('fleet'); } else { setFleetUpdatesIntent({ tab: 'nodes' }); handleNavigate('fleet'); } }} /> ); // Bespoke, masthead-led mobile screens. When showing one, the TopBar is // dropped and the screen renders its own masthead (with notifications + // more-menu rehomed into the right slot). const bespokeContent = mobileSurface === 'content' && BESPOKE_MOBILE_VIEWS.has(activeView); // Shared lazy-chunk fallback for the code-split bespoke phone screens. const lazyFallback = (
); const renderMobileBespoke = () => { switch (activeView) { case 'dashboard': return ( ); case 'fleet': // Never mount the mobile fleet view without node:read: the redirect // effect bounces the deep-link to the dashboard, but MobileFleet is a // static (non-lazy) render, so without this guard it would fire one // /fleet/overview (now 403) before the redirect unmounts it. if (!can('node:read')) return null; return ( ); case 'scheduled-ops': return ; case 'settings': return ; case 'security': return ( )} > ); case 'auto-updates': // Same gates as the desktop content path (ViewRouter): hub-only + // the auto-updates capability, preserved on the phone surface. return ( ); case 'templates': return ( { refreshStacks(); void stackActions.loadFile(sName); }} headerActions={mobileMastheadActions} /> ); case 'audit-log': return ( ); case 'resources': return ( ); case 'global-observability': // Hub-only, like the desktop content path (ViewRouter); no capability gate. return ( ); default: return workspaceEl; } }; // The mobile Stacks list leads with the status masthead (no TopBar): the // node switcher is its kicker chip, the serif word summarizes stack // health, and notifications + the more-menu sit in the right slot. // up counts 'running' and down counts 'exited'; any other status (or the // window before statuses load) is neither, so "All running" must require // every stack to be up rather than just no stack being down. const { all: stacksAll, up: stacksUp, down: stacksDown, updates: stacksUpdates } = filterCounts; let stacksState = 'All running'; let stacksTone: Tone = 'success'; if (stacksAll === 0) { stacksState = 'No stacks'; } else if (stacksDown > 0) { stacksState = `${stacksDown} down`; stacksTone = 'destructive'; } else if (stacksUpdates > 0) { stacksState = `${stacksUpdates} update${stacksUpdates === 1 ? '' : 's'}`; stacksTone = 'warning'; } else if (stacksUp !== stacksAll) { stacksState = `${stacksUp}/${stacksAll} up`; stacksTone = 'brand'; } const stacksMasthead = ( openSettings('nodes')} />} state={stacksState} stateTone={stacksTone} live={stacksDown > 0} meta={`${stacksAll} ${stacksAll === 1 ? 'stack' : 'stacks'} · ${stacksUp} up · ${stacksDown} down`} right={mobileMastheadActions} /> ); if (isMobile) { return ( // h-dvh tracks the visible viewport as the address bar shows and hides, so // the bottom tab bar stays on screen (100vh would push it behind the bar).
{commandPaletteEl} {mobileSurface === 'content' && !bespokeContent && topBarEl}
{mobileSurface === 'list' && ( <> {stacksMasthead} {sidebarEl} )} {mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)} {mobileSurface === 'detail' && ( detailReady ? (
{renderEditor(mobileMastheadActions)}
) : ( ) )}
navigateMobileAware('dashboard')} onStacks={goToMobileList} onNavigate={navigateMobileAware} onSettings={openSettingsMobileAware} /> {shellOverlaysEl}
); } return (
{commandPaletteEl} {/* Left Sidebar (Stacks) */} {sidebarEl} {/* Main Content Area */}
{topBarEl} {/* Main Workspace */} {workspaceEl}
{shellOverlaysEl}
); })()}
); } // Optimistic stack-detail placeholder shown on mobile the instant a row is // tapped, until loadFile resolves and the real EditorView mounts. Keeps the tap // feeling immediate on slow networks. function MobileDetailLoading({ name, onBack, headerActions }: { name: string; onBack: () => void; headerActions?: ReactNode }) { return (
{name.replace(/\.(ya?ml)$/, '')} {headerActions ?
{headerActions}
: null}
); }