mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 05:28:28 +00:00
dd76b13d55
The fleet overview, configuration, dependency-map, and networking-summary reads were authentication-only, so a role without node:read (deployer) could read node names, host stats, and cross-node topology. They now require node:read, matching the role model where every role except deployer holds it. For parity, the Fleet nav entry is gated on node:read (hiding it from the top nav, mobile menu, and command palette), the Fleet view redirects to the dashboard when reached without it, and the dashboard fleet heartbeat falls back to the single-node restart map for a role that cannot read fleet data.
1008 lines
40 KiB
TypeScript
1008 lines
40 KiB
TypeScript
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 { 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,
|
|
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,
|
|
stackCounts,
|
|
stackLabelMap,
|
|
filterChip, setFilterChip,
|
|
bulkMode,
|
|
selectedFiles,
|
|
filterCounts,
|
|
chipFilteredFiles,
|
|
remoteResults,
|
|
isStackBusy,
|
|
refreshStacks,
|
|
handleScanStacks,
|
|
scheduleStateInvalidateRefresh,
|
|
toggleBulkMode, toggleSelect, clearSelection, handleBulkAction,
|
|
stackUpdates,
|
|
fetchImageUpdates,
|
|
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<number | null>(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<CreateMode>('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,
|
|
mobileNavOpen, setMobileNavOpen,
|
|
handleOpenSettings,
|
|
handlePrefillConsumed,
|
|
handleNavigate,
|
|
navItems,
|
|
} = 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<string | null>(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 { 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<MobileView>('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<string | null>(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<SenchoOpenStackDetail>).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) => (
|
|
<EditorView
|
|
headerActions={headerActions}
|
|
stackName={stackName}
|
|
isDarkMode={isDarkMode}
|
|
containers={containers}
|
|
containerStats={containerStats}
|
|
containerStatsError={containerStatsError}
|
|
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}
|
|
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}
|
|
recoveryResult={selectedFile ? lastActionResult[selectedFile] : undefined}
|
|
onRefreshState={async () => {
|
|
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}
|
|
/>
|
|
);
|
|
|
|
// 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<number | null>(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<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={() => openCreateDialog('empty')}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
Create Stack
|
|
</Button>
|
|
<CreateStackDialog
|
|
open={createDialogOpen}
|
|
onOpenChange={setCreateDialogOpen}
|
|
initialMode={createDialogInitialMode}
|
|
onStackCreated={async (sName, sourceNodeId) => {
|
|
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 (
|
|
<GlobalCommandPaletteProvider>
|
|
{(() => {
|
|
const commandPaletteEl = (
|
|
<GlobalCommandPalette
|
|
navItems={navItems}
|
|
onNavigate={navHandler}
|
|
onSelectStack={stackActions.loadFileOnNode}
|
|
/>
|
|
);
|
|
|
|
const sidebarEl = (
|
|
<StackSidebar
|
|
isDarkMode={isDarkMode}
|
|
nodeSwitcherSlot={
|
|
<NodeSwitcher
|
|
onManageNodes={() => 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<string, StackRowStatus | undefined>,
|
|
stackCounts,
|
|
stackUpdates,
|
|
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,
|
|
}}
|
|
activitySummary={activitySummary}
|
|
onActivityAction={handleActivityAction}
|
|
bulkMode={bulkMode}
|
|
selectedFiles={selectedFiles}
|
|
onToggleBulkMode={toggleBulkMode}
|
|
onToggleSelect={toggleSelect}
|
|
onClearSelection={clearSelection}
|
|
onBulkAction={handleBulkAction}
|
|
/>
|
|
);
|
|
|
|
const notificationsEl = (
|
|
<NotificationPanel
|
|
notifications={notifications}
|
|
nodes={nodes}
|
|
onMarkAllRead={markAllRead}
|
|
onClearAll={clearAllNotifications}
|
|
onDelete={deleteNotification}
|
|
onNavigate={handleNotificationNavigate}
|
|
onNavigateChangelog={handleNotificationNavigateChangelog}
|
|
/>
|
|
);
|
|
const themeSwitchEl = <ThemeQuickSwitch onOpenAppearance={() => openSettings('appearance')} />;
|
|
const userMenuEl = <UserProfileDropdown onOpenSettings={() => openSettings('account')} />;
|
|
|
|
const topBarEl = (
|
|
<TopBar
|
|
activeView={activeView}
|
|
navItems={navItems}
|
|
onNavigate={navHandler}
|
|
mobileNavOpen={mobileNavOpen}
|
|
onMobileNavOpenChange={setMobileNavOpen}
|
|
search={<GlobalCommandPaletteTrigger />}
|
|
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 = (
|
|
<div className="flex items-center gap-0.5">
|
|
{notificationsEl}
|
|
<MobileMoreMenu
|
|
navItems={navItems}
|
|
activeView={activeView}
|
|
onNavigate={navigateMobileAware}
|
|
footer={<>{themeSwitchEl}{userMenuEl}</>}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const workspaceEl = (
|
|
<div key={activeView} className="flex-1 overflow-y-auto p-6 max-md:p-4 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={handleFleetNavigateToNode}
|
|
filterNodeId={filterNodeId}
|
|
onClearScheduledOpsFilter={() => setFilterNodeId(null)}
|
|
schedulePrefill={schedulePrefill}
|
|
onPrefillConsumed={handlePrefillConsumed}
|
|
notifications={notifications}
|
|
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
|
onOpenSettingsSection={(section) => openSettings(section)}
|
|
onClearNotifications={clearAllNotifications}
|
|
fleetUpdatesIntent={fleetUpdatesIntent}
|
|
onFleetUpdatesIntentConsumed={handleFleetUpdatesIntentConsumed}
|
|
securityTab={securityTab}
|
|
onSecurityTabChange={setSecurityTab}
|
|
fleetTab={fleetTab}
|
|
onFleetTabConsumed={() => setFleetTab(null)}
|
|
renderEditor={renderEditor}
|
|
/>
|
|
</div>
|
|
);
|
|
|
|
const shellOverlaysEl = (
|
|
<ShellOverlays
|
|
overlayState={overlayState}
|
|
stackActions={stackActions}
|
|
isDarkMode={isDarkMode}
|
|
isAdmin={isAdmin}
|
|
can={can}
|
|
selectedFile={selectedFile}
|
|
stackName={stackName}
|
|
gitSourceOpen={gitSourceOpen}
|
|
setGitSourceOpen={setGitSourceOpen}
|
|
/>
|
|
);
|
|
|
|
// 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 = (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
|
|
</div>
|
|
);
|
|
const renderMobileBespoke = () => {
|
|
switch (activeView) {
|
|
case 'dashboard':
|
|
return (
|
|
<MobileDashboard
|
|
notifications={notifications}
|
|
headerActions={mobileMastheadActions}
|
|
onNavigateToStack={handleSelectStack}
|
|
onViewAllStacks={goToMobileList}
|
|
/>
|
|
);
|
|
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 (
|
|
<MobileFleet
|
|
headerActions={mobileMastheadActions}
|
|
onInspectNode={handleInspectNode}
|
|
onInspectStack={handleFleetNavigateToNode}
|
|
/>
|
|
);
|
|
case 'scheduled-ops':
|
|
return <MobileSchedules headerActions={mobileMastheadActions} />;
|
|
case 'settings':
|
|
return <MobileSettings headerActions={mobileMastheadActions} />;
|
|
case 'security':
|
|
return (
|
|
<Suspense
|
|
fallback={(
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-5 w-5 animate-spin text-stat-subtitle" strokeWidth={1.5} />
|
|
</div>
|
|
)}
|
|
>
|
|
<SecurityView
|
|
activeTab={securityTab}
|
|
onTabChange={setSecurityTab}
|
|
headerActions={mobileMastheadActions}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
case 'auto-updates':
|
|
// Same gates as the desktop content path (ViewRouter): hub-only +
|
|
// the auto-updates capability, preserved on the phone surface.
|
|
return (
|
|
<HubOnlyGate>
|
|
<CapabilityGate capability="auto-updates" featureName="Auto-Update Readiness">
|
|
<Suspense fallback={lazyFallback}>
|
|
<AutoUpdateReadinessView headerActions={mobileMastheadActions} />
|
|
</Suspense>
|
|
</CapabilityGate>
|
|
</HubOnlyGate>
|
|
);
|
|
case 'templates':
|
|
return (
|
|
<Suspense fallback={lazyFallback}>
|
|
<AppStoreView
|
|
onDeploySuccess={(sName) => { refreshStacks(); void stackActions.loadFile(sName); }}
|
|
headerActions={mobileMastheadActions}
|
|
/>
|
|
</Suspense>
|
|
);
|
|
case 'audit-log':
|
|
return (
|
|
<HubOnlyGate>
|
|
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
|
<Suspense fallback={lazyFallback}>
|
|
<AuditLogView headerActions={mobileMastheadActions} />
|
|
</Suspense>
|
|
</CapabilityGate>
|
|
</HubOnlyGate>
|
|
);
|
|
case 'resources':
|
|
return (
|
|
<Suspense fallback={lazyFallback}>
|
|
<ResourcesView headerActions={mobileMastheadActions} />
|
|
</Suspense>
|
|
);
|
|
case 'global-observability':
|
|
// Hub-only, like the desktop content path (ViewRouter); no capability gate.
|
|
return (
|
|
<HubOnlyGate>
|
|
<Suspense fallback={lazyFallback}>
|
|
<GlobalObservabilityView headerActions={mobileMastheadActions} />
|
|
</Suspense>
|
|
</HubOnlyGate>
|
|
);
|
|
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 = (
|
|
<Masthead
|
|
kickerSlot={<NodeSwitcher compact onManageNodes={() => 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).
|
|
<div className="flex h-dvh w-screen flex-col overflow-hidden app-canvas text-foreground">
|
|
{commandPaletteEl}
|
|
{mobileSurface === 'content' && !bespokeContent && topBarEl}
|
|
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
|
{mobileSurface === 'list' && (
|
|
<>
|
|
{stacksMasthead}
|
|
{sidebarEl}
|
|
</>
|
|
)}
|
|
{mobileSurface === 'content' && (bespokeContent ? renderMobileBespoke() : workspaceEl)}
|
|
{mobileSurface === 'detail' && (
|
|
detailReady ? (
|
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor(mobileMastheadActions)}</div>
|
|
) : (
|
|
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} headerActions={mobileMastheadActions} />
|
|
)
|
|
)}
|
|
</div>
|
|
<MobileTabBar
|
|
navItems={navItems}
|
|
activeView={activeView}
|
|
mobileView={mobileView}
|
|
detailOpen={detailOpen}
|
|
onHome={() => navigateMobileAware('dashboard')}
|
|
onStacks={goToMobileList}
|
|
onNavigate={navigateMobileAware}
|
|
onSettings={openSettingsMobileAware}
|
|
/>
|
|
{shellOverlaysEl}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
|
{commandPaletteEl}
|
|
{/* Left Sidebar (Stacks) */}
|
|
{sidebarEl}
|
|
{/* Main Content Area */}
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{topBarEl}
|
|
{/* Main Workspace */}
|
|
{workspaceEl}
|
|
</div>
|
|
{shellOverlaysEl}
|
|
</div>
|
|
);
|
|
})()}
|
|
</GlobalCommandPaletteProvider>
|
|
);
|
|
}
|
|
|
|
// 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 (
|
|
<div className="flex-1 min-h-0 flex flex-col">
|
|
<div className="flex items-center gap-1 border-b border-hairline px-3 py-2">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
aria-label="Back to stacks"
|
|
className="inline-flex min-h-11 items-center gap-1 pr-3 font-mono text-xs text-brand"
|
|
>
|
|
<ChevronLeft className="h-4 w-4" strokeWidth={1.6} />
|
|
Stacks
|
|
</button>
|
|
<span className="min-w-0 flex-1 truncate font-heading text-2xl text-stat-value">
|
|
{name.replace(/\.(ya?ml)$/, '')}
|
|
</span>
|
|
{headerActions ? <div className="shrink-0">{headerActions}</div> : null}
|
|
</div>
|
|
<div className="flex flex-1 items-center justify-center text-stat-subtitle">
|
|
<Loader2 className="h-5 w-5 animate-spin" strokeWidth={1.5} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|