diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx index b38d6971..480793ec 100644 --- a/frontend/src/components/BashExecModal.tsx +++ b/frontend/src/components/BashExecModal.tsx @@ -214,7 +214,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN }; return ( - + diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index cfbea43d..2be254a8 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; -import { Plus } from 'lucide-react'; +import { Plus, Loader2, ChevronLeft } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; @@ -38,6 +38,10 @@ import { usePanelSessionStartedAt } from '@/components/sidebar/usePanelSessionSt import type { SidebarActivityAction } from '@/components/sidebar/SidebarActivityTicker'; import { useComposeDiffPreviewEnabled } from '@/hooks/use-compose-diff-preview-enabled'; import { toast } from '@/components/ui/toast-store'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { MobileTabBar } from './MobileTabBar'; +import { deriveMobileSurface, type MobileView } from './EditorLayout/mobile-surface'; +import type { SectionId } from './settings/types'; export default function EditorLayout() { const { isAdmin, can } = useAuth(); @@ -198,26 +202,143 @@ export default function EditorLayout() { 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('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); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + if (!isFileLoading && pendingDetailStack) setPendingDetailStack(null); + }, [isFileLoading, pendingDetailStack]); + + 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); + }; + + // 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': - setActiveView('auto-updates'); + if (isMobile) navigateMobileAware('auto-updates'); + else setActiveView('auto-updates'); return; case 'open-activity': - setActiveView('global-observability'); + if (isMobile) navigateMobileAware('global-observability'); + else setActiveView('global-observability'); return; case 'noop': return; } - }, [stackActions, setActiveView]); + }, [stackActions, setActiveView, isMobile, navigateMobileAware]); - const loadingAction = selectedFile ? (stackActionMap[selectedFile] ?? null) : null; - const stackName = selectedFile || ''; - - const { isDarkMode } = useTheme(); + const renderEditor = () => ( + + ); // 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). @@ -341,71 +462,74 @@ export default function EditorLayout() { return ( -
- - {/* Left Sidebar (Stacks) */} - 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, - selectedFile, - searchQuery, - stackLabelMap, - stackStatuses: stackStatuses as Record, - stackUpdates, - gitSourcePendingMap, - pinnedFiles: pinned, - isCollapsed, - toggleCollapse, - isBusy: isStackBusy, - getDisplayName: stackActions.getDisplayName, - onSelectFile: stackActions.loadFile, - 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 commandPaletteEl = ( + + ); - {/* Main Content Area */} -
+ 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, + 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 topBarEl = ( } @@ -422,13 +546,14 @@ export default function EditorLayout() { } userMenu={ handleOpenSettings('account')} + onOpenSettings={() => openSettings('account')} /> } /> + ); - {/* Main Workspace */} -
+ const workspaceEl = ( +
{ void stackActions.loadFile(stackFile); }} - onOpenSettingsSection={(section) => handleOpenSettings(section)} + onOpenSettingsSection={(section) => openSettings(section)} onClearNotifications={clearAllNotifications} - renderEditor={() => ( - - )} + renderEditor={renderEditor} />
-
+ ); - -
+ const shellOverlaysEl = ( + + ); + + if (isMobile) { + return ( +
+ {commandPaletteEl} + {mobileSurface !== 'detail' && topBarEl} +
+ {mobileSurface === 'list' && sidebarEl} + {mobileSurface === 'content' && workspaceEl} + {mobileSurface === 'detail' && ( + detailReady ? ( +
{renderEditor()}
+ ) : ( + + ) + )} +
+ + {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 }: { name: string; onBack: () => void }) { + return ( +
+
+ + + {name.replace(/\.(ya?ml)$/, '')} + +
+
+ +
+
+ ); +} diff --git a/frontend/src/components/EditorLayout/EditorView.tsx b/frontend/src/components/EditorLayout/EditorView.tsx index d3412c8c..68e7727e 100644 --- a/frontend/src/components/EditorLayout/EditorView.tsx +++ b/frontend/src/components/EditorLayout/EditorView.tsx @@ -1,30 +1,16 @@ import { Suspense, useRef, useEffect } from 'react'; import { Editor } from '@/lib/monacoLoader'; import { - RotateCw, - Play, - Square, Save, - Terminal, - CloudDownload, Pencil, X, - MoreVertical, Rocket, - Trash2, - ScrollText, - Undo2, - Loader2, - Check, ChevronDown, GitBranch, - ShieldCheck, - ArrowUpRight, - Copy, FolderOpen, } from 'lucide-react'; import { Button } from '../ui/button'; -import { Card, CardContent, CardHeader, CardTitle } from '../ui/card'; +import { Card, CardContent, CardHeader } from '../ui/card'; import { Tabs, TabsList, @@ -36,7 +22,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from '../ui/dropdown-menu'; import { @@ -46,15 +31,13 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Sparkline } from '../ui/sparkline'; import { springs } from '@/lib/motion'; -import { cn } from '@/lib/utils'; -import { copyToClipboard } from '@/lib/clipboard'; import ErrorBoundary from '../ErrorBoundary'; -import TerminalComponent from '../Terminal'; -import StructuredLogViewer from '../StructuredLogViewer'; import StackAnatomyPanel from '../StackAnatomyPanel'; import { StackFileExplorer } from '@/components/files/StackFileExplorer'; +import { useIsMobile } from '@/hooks/use-is-mobile'; +import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks'; +import { MobileStackDetail } from './MobileStackDetail'; import type { NotificationItem } from '../dashboard/types'; import type { Node } from '@/context/NodeContext'; import type { useAuth } from '@/context/AuthContext'; @@ -88,75 +71,6 @@ export interface ContainerStatsEntry { history: { cpu: number[]; mem: number[]; netIn: number[]; netOut: number[] }; } -const extractUptime = (status: string | undefined): string | null => { - if (!status) return null; - const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); - if (!match) return null; - return `up ${match[1].trim()}`; -}; - -const healthcheckLabel = ( - health?: 'healthy' | 'unhealthy' | 'starting' | 'none', -): string | null => { - if (!health || health === 'none') return null; - if (health === 'healthy') return 'healthcheck passing'; - if (health === 'unhealthy') return 'healthcheck failing'; - return 'healthcheck starting'; -}; - -type StackPill = { - label: string; - dotClass: string; - className: string; - pulse: boolean; -}; - -const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { - if (!containers || containers.length === 0) return null; - const running = containers.some(c => c.State === 'running'); - if (!running) { - return { - label: 'exited', - dotClass: 'bg-destructive', - className: 'border-destructive/40 bg-destructive/10 text-destructive', - pulse: false, - }; - } - const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); - const anyStarting = containers.some(c => c.healthStatus === 'starting'); - const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); - if (anyUnhealthy) { - return { - label: 'running · unhealthy', - dotClass: 'bg-destructive', - className: 'border-destructive/40 bg-destructive/10 text-destructive', - pulse: true, - }; - } - if (anyStarting) { - return { - label: 'running · starting', - dotClass: 'bg-warning', - className: 'border-warning/40 bg-warning/10 text-warning', - pulse: true, - }; - } - if (anyHealthy) { - return { - label: 'running · healthy', - dotClass: 'bg-success', - className: 'border-success/40 bg-success/10 text-success', - pulse: true, - }; - } - return { - label: 'running', - dotClass: 'bg-success', - className: 'border-success/40 bg-success/10 text-success', - pulse: true, - }; -}; - export interface EditorViewProps { // Identity stackName: string; @@ -226,60 +140,64 @@ export interface EditorViewProps { setGitSourceOpen: (open: boolean) => void; setCopiedDigest: React.Dispatch>; - // Composed action — wraps setStackToDelete + setDeleteDialogOpen + // Composed action: wraps setStackToDelete + setDeleteDialogOpen requestDeleteStack: () => void; + + // Mobile-only: back affordance in the detail header returns to the stack list. + onMobileBack?: () => void; } -export function EditorView({ - stackName, - isDarkMode, - containers, - containerStats, - containerStatsError, - content, - envContent, - envExists, - envFiles, - selectedEnvFile, - isFileLoading, - backupInfo, - gitSourcePendingMap, - notifications, - activeTab, - isEditing, - editingCompose, - logsMode, - copiedDigest, - loadingAction, - stackMisconfigScanning, - can, - isAdmin, - trivy, - activeNode, - copiedDigestTimerRef, - deployStack, - restartStack, - stopStack, - updateStack, - rollbackStack, - scanStackConfig, - enterEditMode, - requestSave, - requestSaveAndDeploy, - discardChanges, - setContent, - setEnvContent, - changeEnvFile, - openLogViewer, - openBashModal, - serviceAction, - setActiveTab, - setLogsMode, - setEditingCompose, - setGitSourceOpen, - setCopiedDigest, - requestDeleteStack, -}: EditorViewProps) { +export function EditorView(props: EditorViewProps) { + const { + stackName, + isDarkMode, + containers, + containerStats, + containerStatsError, + content, + envContent, + envExists, + envFiles, + selectedEnvFile, + isFileLoading, + backupInfo, + gitSourcePendingMap, + notifications, + activeTab, + isEditing, + editingCompose, + logsMode, + copiedDigest, + loadingAction, + stackMisconfigScanning, + can, + isAdmin, + trivy, + activeNode, + copiedDigestTimerRef, + deployStack, + restartStack, + stopStack, + updateStack, + rollbackStack, + scanStackConfig, + enterEditMode, + requestSave, + requestSaveAndDeploy, + discardChanges, + setContent, + setEnvContent, + changeEnvFile, + openLogViewer, + openBashModal, + serviceAction, + setActiveTab, + setLogsMode, + setEditingCompose, + setGitSourceOpen, + setCopiedDigest, + requestDeleteStack, + } = props; const monacoEditorRef = useRef(null); // Dispose the underlying Monaco model when EditorView unmounts. The @@ -325,6 +243,14 @@ export function EditorView({ } }, [activeTab, canRead, setActiveTab]); + // Below md, render the segmented full-screen mobile detail instead of the + // desktop two-pane grid. All hooks above run unconditionally before this + // branch so hook order stays stable across breakpoints. + const isMobile = useIsMobile(); + if (isMobile) { + return ; + } + return (
@@ -333,374 +259,45 @@ export function EditorView({ {/* Command Center Card (identity + health strip) */} -
- {/* Identity block */} -
-
- {(activeNode?.name || 'local')} stacks {stackName} -
-
- {stackName} - {(() => { - const pill = getStackStatePill(safeContainers); - if (!pill) return null; - return ( - - - ); - })()} -
- {(() => { - const first = safeContainers[0]; - if (!first?.Image) return null; - const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; - return ( -
- image · {first.Image} - {digest && first.ImageID && ( - <> - · - digest {digest} - - - )} -
- ); - })()} -
- {/* Action Bar — deploy / delete affordances render against - their own backend permissions so a delete-only or - deploy-only persona sees exactly what they can act on. */} - {(() => { - const canDeploy = can('stack:deploy', 'stack', stackName); - const canDelete = can('stack:delete', 'stack', stackName); - const canRollback = canDeploy && backupInfo.exists; - const canScan = trivy.available && isAdmin; - const hasOverflowExtras = canRollback || canScan; - const hasOverflow = hasOverflowExtras || canDelete; - if (!canDeploy && !hasOverflow) return null; - return ( -
- {canDeploy && ( - <> - {isRunning ? ( - - ) : ( - - )} - {isRunning && ( - - )} - - - )} - {hasOverflow && ( - - - - - - {canRollback && ( - - -
- {loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} - {backupInfo.timestamp && ( - {new Date(backupInfo.timestamp).toLocaleString()} - )} -
-
- )} - {canScan && ( - - {stackMisconfigScanning ? ( - - ) : ( - - )} - {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} - - )} - {hasOverflowExtras && canDelete && } - {canDelete && ( - - - {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} - - )} -
-
- )} -
- ); - })()} -
+
- {/* Per-container health strip */} -
-
-

CONTAINERS

- {containerStatsError && safeContainers.length > 0 && ( - - Stats unavailable - - )} -
- {safeContainers.length === 0 ? ( -
No containers running for this stack.
- ) : ( -
- {safeContainers.map(container => { - let mainPort: number | undefined; - let mainPortPrivate: number | undefined; - let mainPortProto: string | undefined; - if (container.Ports && container.Ports.length > 0) { - const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; - const IGNORE_PORTS = [1900, 53, 22]; - let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); - if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); - if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); - const chosen = match || container.Ports[0]; - mainPort = chosen.PublicPort; - mainPortPrivate = chosen.PrivatePort; - mainPortProto = 'tcp'; - } - - const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; - const isActive = container.State === 'running' || container.State === 'paused'; - const health = container.healthStatus; - const uptime = isActive ? extractUptime(container.Status) : null; - const hcLabel = healthcheckLabel(health); - const stats = containerStats[container?.Id]; - const history = stats?.history; - - const badgeClass = health === 'unhealthy' || !isActive - ? 'bg-destructive text-destructive-foreground' - : health === 'starting' - ? 'bg-warning text-warning-foreground' - : 'bg-success text-success-foreground'; - const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓'; - const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; - - return ( -
-
-
-
- {badgeGlyph} -
-
-
{containerName}
-
- {uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} - {hcLabel ? <>·{hcLabel} : null} - {mainPort && mainPortPrivate ? ( - <> - · - {mainPort} → {mainPortPrivate}/{mainPortProto} - - - ) : null} -
-
-
-
- - {isAdmin && ( - - )} - {container.Service && ( - - - - - - {isActive ? ( - <> - serviceAction('restart', container.Service!)}> - Restart service - - serviceAction('stop', container.Service!)}> - Stop service - - - ) : ( - serviceAction('start', container.Service!)}> - Start service - - )} - - - )} -
-
- {isActive ? ( -
-
-
- cpu - {stats?.cpu ?? '-'} -
-
- -
-
-
-
- mem - {stats?.ram ?? '-'} -
-
- -
-
-
-
- net i/o - {stats?.net ?? '-'} -
-
- -
-
-
- ) : null} -
- ); - })} -
- )} -
+
{/* Logs Section (fills remaining left-column height) */} -
-
-

Logs

-
- - -
-
- {logsMode === 'structured' ? ( - - - - ) : ( -
-
- - - -
-
- )} -
+
{/* Right column: anatomy panel by default, Monaco editor when editing */} diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx new file mode 100644 index 00000000..c6344d21 --- /dev/null +++ b/frontend/src/components/EditorLayout/MobileStackDetail.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { MobileStackDetail } from './MobileStackDetail'; +import type { EditorViewProps } from './EditorView'; + +// The detail's heavy children stream logs, parse compose, and render container +// stats; stub them with markers so this test focuses on the segmented-control +// behavior (default segment + switching). +vi.mock('./editor-view-blocks', () => ({ + StackIdentityHeader: () =>
identity-header
, + ContainersHealth: () =>
health-pane
, + StackLogsSection: () =>
logs-pane
, +})); +vi.mock('../StackAnatomyPanel', () => ({ default: () =>
compose-pane
})); +vi.mock('../ErrorBoundary', () => ({ default: ({ children }: { children: ReactNode }) => <>{children} })); + +function makeProps(over: Partial = {}): EditorViewProps { + return { + stackName: 'web', + activeNode: null, + containers: [], + containerStats: {}, + containerStatsError: null, + content: '', + envContent: '', + selectedEnvFile: '', + gitSourcePendingMap: {}, + notifications: [], + copiedDigest: null, + loadingAction: null, + stackMisconfigScanning: false, + can: () => true, + isAdmin: false, + trivy: { available: false }, + backupInfo: { exists: false, timestamp: null }, + logsMode: 'structured', + copiedDigestTimerRef: { current: null }, + deployStack: vi.fn(), + restartStack: vi.fn(), + stopStack: vi.fn(), + updateStack: vi.fn(), + rollbackStack: vi.fn(), + scanStackConfig: vi.fn(), + openLogViewer: vi.fn(), + openBashModal: vi.fn(), + serviceAction: vi.fn(), + setLogsMode: vi.fn(), + setGitSourceOpen: vi.fn(), + setCopiedDigest: vi.fn(), + requestDeleteStack: vi.fn(), + onMobileBack: vi.fn(), + ...over, + } as unknown as EditorViewProps; +} + +describe('MobileStackDetail', () => { + it('defaults to the Logs segment', () => { + render(); + expect(screen.getByText('logs-pane')).toBeInTheDocument(); + expect(screen.queryByText('health-pane')).not.toBeInTheDocument(); + expect(screen.queryByText('compose-pane')).not.toBeInTheDocument(); + expect(screen.getByText('identity-header')).toBeInTheDocument(); + }); + + it('switches to Health and Compose segments', () => { + render(); + fireEvent.click(screen.getByRole('tab', { name: 'Health' })); + expect(screen.getByText('health-pane')).toBeInTheDocument(); + expect(screen.queryByText('logs-pane')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole('tab', { name: 'Compose' })); + expect(screen.getByText('compose-pane')).toBeInTheDocument(); + }); + + it('marks the active segment with aria-selected and round-trips back to Logs', () => { + render(); + expect(screen.getByRole('tab', { name: 'Logs' })).toHaveAttribute('aria-selected', 'true'); + fireEvent.click(screen.getByRole('tab', { name: 'Health' })); + expect(screen.getByRole('tab', { name: 'Health' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: 'Logs' })).toHaveAttribute('aria-selected', 'false'); + fireEvent.click(screen.getByRole('tab', { name: 'Logs' })); + expect(screen.getByRole('tab', { name: 'Logs' })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByText('logs-pane')).toBeInTheDocument(); + }); + + it('invokes the back handler', () => { + const onMobileBack = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Back to stacks' })); + expect(onMobileBack).toHaveBeenCalledTimes(1); + }); + + it('shows the edit-on-desktop nudge in Compose when the user can edit', () => { + render( true })} />); + fireEvent.click(screen.getByRole('tab', { name: 'Compose' })); + expect(screen.getByText(/Editing compose is available on a larger screen/i)).toBeInTheDocument(); + }); + + it('hides the nudge when the user cannot edit', () => { + render( false })} />); + fireEvent.click(screen.getByRole('tab', { name: 'Compose' })); + expect(screen.queryByText(/Editing compose is available/i)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/EditorLayout/MobileStackDetail.tsx b/frontend/src/components/EditorLayout/MobileStackDetail.tsx new file mode 100644 index 00000000..19339e68 --- /dev/null +++ b/frontend/src/components/EditorLayout/MobileStackDetail.tsx @@ -0,0 +1,180 @@ +import { useState } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import ErrorBoundary from '../ErrorBoundary'; +import StackAnatomyPanel from '../StackAnatomyPanel'; +import { StackIdentityHeader, ContainersHealth, StackLogsSection } from './editor-view-blocks'; +import type { EditorViewProps } from './EditorView'; + +const SEGMENTS = [ + { id: 'health', label: 'Health' }, + { id: 'logs', label: 'Logs' }, + { id: 'compose', label: 'Compose' }, +] as const; + +type Segment = (typeof SEGMENTS)[number]['id']; + +// Full-screen stack detail for the mobile shell (below md). The desktop +// two-pane grid does not fit a phone, so the same identity header, container +// health, logs, and anatomy are reorganized into a tracked-mono segmented +// control. Logs is the default segment (first-read, without copying Dockge's +// layout). Compose is read-only on mobile: full file editing stays on desktop. +export function MobileStackDetail(props: EditorViewProps) { + const { + stackName, + activeNode, + containers, + containerStats, + containerStatsError, + content, + envContent, + selectedEnvFile, + gitSourcePendingMap, + notifications, + copiedDigest, + loadingAction, + stackMisconfigScanning, + can, + isAdmin, + trivy, + backupInfo, + logsMode, + copiedDigestTimerRef, + deployStack, + restartStack, + stopStack, + updateStack, + rollbackStack, + scanStackConfig, + openLogViewer, + openBashModal, + serviceAction, + setLogsMode, + setGitSourceOpen, + setCopiedDigest, + requestDeleteStack, + onMobileBack, + } = props; + + const [segment, setSegment] = useState('logs'); + + const safeContainers = containers || []; + const isRunning = safeContainers.some(c => c.State === 'running'); + const canEditStack = can('stack:edit', 'stack', stackName); + + return ( + +
+ {/* Detail header: back to list + identity + action bar */} +
+ + +
+ + {/* Segmented control: Health · Logs · Compose */} +
+
+ {SEGMENTS.map(seg => { + const on = seg.id === segment; + return ( + + ); + })} +
+
+ + {/* Active segment */} +
+ {segment === 'health' && ( +
+ +
+ )} + {segment === 'logs' && ( + + )} + {segment === 'compose' && ( +
+
+ {}} + onOpenGitSource={() => setGitSourceOpen(true)} + onApplyUpdate={() => { void updateStack(); }} + applying={loadingAction === 'update'} + canEdit={false} + notifications={notifications} + /> +
+ {canEditStack && ( +
+ Editing compose is available on a larger screen. Open this stack on desktop to edit the file. +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 4fc5bd05..f6c5fa4b 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -50,7 +50,7 @@ export function ShellOverlays({ }: ShellOverlaysProps) { const { deleteDialogOpen, closeDeleteDialog, stackToDelete, - pendingUnsavedLoad, + pendingUnsavedLoad, pendingLeaveAction, bashModalOpen, selectedContainer, logViewerOpen, logContainer, stackMonitor, closeStackMonitor, @@ -69,7 +69,7 @@ export function ShellOverlays({ /> diff --git a/frontend/src/components/EditorLayout/editor-view-blocks.tsx b/frontend/src/components/EditorLayout/editor-view-blocks.tsx new file mode 100644 index 00000000..18236604 --- /dev/null +++ b/frontend/src/components/EditorLayout/editor-view-blocks.tsx @@ -0,0 +1,557 @@ +// Shared building blocks for the stack detail view. Extracted from EditorView so +// the desktop two-pane layout and the mobile segmented layout render the exact +// same identity header, container health list, and logs pane from one source. +import { + RotateCw, + Play, + Square, + Terminal, + MoreVertical, + Trash2, + ScrollText, + Undo2, + Loader2, + Check, + ShieldCheck, + ArrowUpRight, + Copy, + CloudDownload, +} from 'lucide-react'; +import { Button } from '../ui/button'; +import { CardTitle } from '../ui/card'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; +import { Sparkline } from '../ui/sparkline'; +import { cn } from '@/lib/utils'; +import { copyToClipboard } from '@/lib/clipboard'; +import ErrorBoundary from '../ErrorBoundary'; +import TerminalComponent from '../Terminal'; +import StructuredLogViewer from '../StructuredLogViewer'; +import type { Node } from '@/context/NodeContext'; +import type { useAuth } from '@/context/AuthContext'; +import type { ContainerInfo, ContainerStatsEntry, StackAction } from './EditorView'; + +const extractUptime = (status: string | undefined): string | null => { + if (!status) return null; + const match = status.match(/^\s*Up\s+(.+?)(?:\s*\(.*\))?\s*$/i); + if (!match) return null; + return `up ${match[1].trim()}`; +}; + +const healthcheckLabel = ( + health?: 'healthy' | 'unhealthy' | 'starting' | 'none', +): string | null => { + if (!health || health === 'none') return null; + if (health === 'healthy') return 'healthcheck passing'; + if (health === 'unhealthy') return 'healthcheck failing'; + return 'healthcheck starting'; +}; + +type StackPill = { + label: string; + dotClass: string; + className: string; + pulse: boolean; +}; + +const getStackStatePill = (containers: ContainerInfo[]): StackPill | null => { + if (!containers || containers.length === 0) return null; + const running = containers.some(c => c.State === 'running'); + if (!running) { + return { + label: 'exited', + dotClass: 'bg-destructive', + className: 'border-destructive/40 bg-destructive/10 text-destructive', + pulse: false, + }; + } + const anyUnhealthy = containers.some(c => c.healthStatus === 'unhealthy'); + const anyStarting = containers.some(c => c.healthStatus === 'starting'); + const anyHealthy = containers.some(c => c.healthStatus === 'healthy'); + if (anyUnhealthy) { + return { + label: 'running · unhealthy', + dotClass: 'bg-destructive', + className: 'border-destructive/40 bg-destructive/10 text-destructive', + pulse: true, + }; + } + if (anyStarting) { + return { + label: 'running · starting', + dotClass: 'bg-warning', + className: 'border-warning/40 bg-warning/10 text-warning', + pulse: true, + }; + } + if (anyHealthy) { + return { + label: 'running · healthy', + dotClass: 'bg-success', + className: 'border-success/40 bg-success/10 text-success', + pulse: true, + }; + } + return { + label: 'running', + dotClass: 'bg-success', + className: 'border-success/40 bg-success/10 text-success', + pulse: true, + }; +}; + +export interface StackIdentityHeaderProps { + stackName: string; + activeNode: Node | null; + safeContainers: ContainerInfo[]; + isRunning: boolean; + copiedDigest: string | null; + setCopiedDigest: React.Dispatch>; + copiedDigestTimerRef: React.MutableRefObject; + can: ReturnType['can']; + isAdmin: boolean; + trivy: { available: boolean }; + backupInfo: { exists: boolean; timestamp: number | null }; + loadingAction: StackAction | null; + stackMisconfigScanning: boolean; + deployStack: (e: React.MouseEvent) => Promise; + restartStack: (e: React.MouseEvent) => Promise; + stopStack: (e: React.MouseEvent) => Promise; + updateStack: (e?: React.MouseEvent) => Promise; + rollbackStack: () => Promise; + scanStackConfig: () => Promise; + requestDeleteStack: () => void; +} + +// Breadcrumb + serif title + state pill + image ref + action bar. The action +// buttons grow to a 44px touch target below md without changing desktop. +export function StackIdentityHeader({ + stackName, + activeNode, + safeContainers, + isRunning, + copiedDigest, + setCopiedDigest, + copiedDigestTimerRef, + can, + isAdmin, + trivy, + backupInfo, + loadingAction, + stackMisconfigScanning, + deployStack, + restartStack, + stopStack, + updateStack, + rollbackStack, + scanStackConfig, + requestDeleteStack, +}: StackIdentityHeaderProps) { + return ( +
+ {/* Identity block */} +
+
+ {(activeNode?.name || 'local')} stacks {stackName} +
+
+ {stackName} + {(() => { + const pill = getStackStatePill(safeContainers); + if (!pill) return null; + return ( + + + ); + })()} +
+ {(() => { + const first = safeContainers[0]; + if (!first?.Image) return null; + const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : ''; + return ( +
+ image · {first.Image} + {digest && first.ImageID && ( + <> + · + digest {digest} + + + )} +
+ ); + })()} +
+ {/* Action Bar: deploy and delete affordances render against their own + backend permissions so a delete-only or deploy-only persona sees + exactly what they can act on. */} + {(() => { + const canDeploy = can('stack:deploy', 'stack', stackName); + const canDelete = can('stack:delete', 'stack', stackName); + const canRollback = canDeploy && backupInfo.exists; + const canScan = trivy.available && isAdmin; + const hasOverflowExtras = canRollback || canScan; + const hasOverflow = hasOverflowExtras || canDelete; + if (!canDeploy && !hasOverflow) return null; + return ( +
+ {canDeploy && ( + <> + {isRunning ? ( + + ) : ( + + )} + {isRunning && ( + + )} + + + )} + {hasOverflow && ( + + + + + + {canRollback && ( + + +
+ {loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'} + {backupInfo.timestamp && ( + {new Date(backupInfo.timestamp).toLocaleString()} + )} +
+
+ )} + {canScan && ( + + {stackMisconfigScanning ? ( + + ) : ( + + )} + {stackMisconfigScanning ? 'Scanning...' : 'Scan config'} + + )} + {hasOverflowExtras && canDelete && } + {canDelete && ( + + + {loadingAction === 'delete' ? 'Deleting...' : 'Delete'} + + )} +
+
+ )} +
+ ); + })()} +
+ ); +} + +export interface ContainersHealthProps { + safeContainers: ContainerInfo[]; + containerStats: Record; + containerStatsError: string | null; + isAdmin: boolean; + activeNode: Node | null; + openLogViewer: (containerId: string, containerName: string) => void; + openBashModal: (containerId: string, containerName: string) => void; + serviceAction: (action: 'start' | 'stop' | 'restart', serviceName: string) => Promise; +} + +// Per-container health strip: status badge, uptime, ports, and CPU/Mem/Net +// sparklines. Row action buttons grow to a 44px touch target below md. +export function ContainersHealth({ + safeContainers, + containerStats, + containerStatsError, + isAdmin, + activeNode, + openLogViewer, + openBashModal, + serviceAction, +}: ContainersHealthProps) { + return ( +
+
+

CONTAINERS

+ {containerStatsError && safeContainers.length > 0 && ( + + Stats unavailable + + )} +
+ {safeContainers.length === 0 ? ( +
No containers running for this stack.
+ ) : ( +
+ {safeContainers.map(container => { + let mainPort: number | undefined; + let mainPortPrivate: number | undefined; + let mainPortProto: string | undefined; + if (container.Ports && container.Ports.length > 0) { + const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000]; + const IGNORE_PORTS = [1900, 53, 22]; + let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort)); + if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort)); + if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort)); + const chosen = match || container.Ports[0]; + mainPort = chosen.PublicPort; + mainPortPrivate = chosen.PrivatePort; + mainPortProto = 'tcp'; + } + + const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container'; + const isActive = container.State === 'running' || container.State === 'paused'; + const health = container.healthStatus; + const uptime = isActive ? extractUptime(container.Status) : null; + const hcLabel = healthcheckLabel(health); + const stats = containerStats[container?.Id]; + const history = stats?.history; + + const badgeClass = health === 'unhealthy' || !isActive + ? 'bg-destructive text-destructive-foreground' + : health === 'starting' + ? 'bg-warning text-warning-foreground' + : 'bg-success text-success-foreground'; + const badgeGlyph = health === 'unhealthy' || !isActive ? '✗' : health === 'starting' ? '…' : '✓'; + const sparkStroke = health === 'unhealthy' ? 'var(--destructive)' : health === 'starting' ? 'var(--warning)' : 'var(--chart-1)'; + + return ( +
+
+
+
+ {badgeGlyph} +
+
+
{containerName}
+
+ {uptime ? {uptime} : {(container.State || 'unknown').toLowerCase()}} + {hcLabel ? <>·{hcLabel} : null} + {mainPort && mainPortPrivate ? ( + <> + · + {mainPort} → {mainPortPrivate}/{mainPortProto} + + + ) : null} +
+
+
+
+ + {isAdmin && ( + + )} + {container.Service && ( + + + + + + {isActive ? ( + <> + serviceAction('restart', container.Service!)}> + Restart service + + serviceAction('stop', container.Service!)}> + Stop service + + + ) : ( + serviceAction('start', container.Service!)}> + Start service + + )} + + + )} +
+
+ {isActive ? ( +
+
+
+ cpu + {stats?.cpu ?? '-'} +
+
+ +
+
+
+
+ mem + {stats?.ram ?? '-'} +
+
+ +
+
+
+
+ net i/o + {stats?.net ?? '-'} +
+
+ +
+
+
+ ) : null} +
+ ); + })} +
+ )} +
+ ); +} + +export interface StackLogsSectionProps { + stackName: string; + logsMode: 'structured' | 'raw'; + setLogsMode: (mode: 'structured' | 'raw') => void; +} + +// Logs pane: structured / raw-terminal toggle + the live viewer. +export function StackLogsSection({ stackName, logsMode, setLogsMode }: StackLogsSectionProps) { + return ( +
+
+

Logs

+
+ + +
+
+ {logsMode === 'structured' ? ( + + + + ) : ( +
+
+ + + +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 17f28ece..e152b295 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -36,6 +36,11 @@ export function useOverlayState() { const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [pendingUnsavedNode, setPendingUnsavedNode] = useState(null); + // A deferred "leave the dirty editor" navigation (back to the list, Home, a + // bottom-tab / hamburger destination). Wrapped in an object so the state + // setter is not mistaken for a functional update. Runs after the user + // confirms the unsaved-changes dialog. See useStackActions.attemptLeaveEditor. + const [pendingLeaveAction, setPendingLeaveAction] = useState<{ run: () => void } | null>(null); const [bashModalOpen, setBashModalOpen] = useState(false); const [selectedContainer, setSelectedContainer] = useState(null); @@ -93,6 +98,7 @@ export function useOverlayState() { deleteDialogOpen, stackToDelete, openDeleteDialog, closeDeleteDialog, pendingUnsavedLoad, setPendingUnsavedLoad, pendingUnsavedNode, setPendingUnsavedNode, + pendingLeaveAction, setPendingLeaveAction, bashModalOpen, selectedContainer, openBashModal, closeBashModal, logViewerOpen, logContainer, openLogViewer, closeLogViewer, stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index e06beba6..31b5f201 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -70,8 +70,10 @@ function makeOverlay(over: Partial = {}): OverlayState { return { setPendingUnsavedLoad: vi.fn(), setPendingUnsavedNode: vi.fn(), + setPendingLeaveAction: vi.fn(), pendingUnsavedLoad: null, pendingUnsavedNode: null, + pendingLeaveAction: null, policyBlock: null, setPolicyBlock: vi.fn(), setPolicyBypassing: vi.fn(), @@ -300,3 +302,52 @@ describe('useStackActions.bypassPolicyAndRetry', () => { expect(apiFetch).not.toHaveBeenCalled(); }); }); + +describe('useStackActions.attemptLeaveEditor (mobile back / nav guard)', () => { + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + }); + + it('stashes the navigation when the editor is dirty instead of running it', () => { + const perform = vi.fn(); + // Default fixture: content !== originalContent and a stack is selected → dirty. + const { result, overlayState } = setup(); + result.current.attemptLeaveEditor(perform); + expect(perform).not.toHaveBeenCalled(); + expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith({ run: perform }); + }); + + it('runs the navigation immediately when the editor is clean', () => { + const perform = vi.fn(); + const { result, overlayState } = setup({ + editorState: { content: 'same', originalContent: 'same' }, + }); + result.current.attemptLeaveEditor(perform); + expect(perform).toHaveBeenCalledTimes(1); + expect(overlayState.setPendingLeaveAction).not.toHaveBeenCalled(); + }); + + it('runs the stashed leave action and clears it on discardAndLoadPending', () => { + const run = vi.fn(); + const { result, overlayState, editorState } = setup({ overlay: { pendingLeaveAction: { run } } }); + result.current.discardAndLoadPending(); + expect(run).toHaveBeenCalledTimes(1); + expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null); + expect(editorState.setContent).toHaveBeenCalledWith(editorState.originalContent); + }); + + it('gives a stashed leave action precedence over a coexisting pending load', () => { + const run = vi.fn(); + const { result } = setup({ overlay: { pendingLeaveAction: { run }, pendingUnsavedLoad: 'other.yml' } }); + result.current.discardAndLoadPending(); + expect(run).toHaveBeenCalledTimes(1); + // The leave branch returns before the load branch, so no stack fetch fires. + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('clears a stashed leave action on cancel', () => { + const { result, overlayState } = setup({ overlay: { pendingLeaveAction: { run: vi.fn() } } }); + result.current.cancelPendingUnsavedLoad(); + expect(overlayState.setPendingLeaveAction).toHaveBeenCalledWith(null); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index b36625f2..cee5214c 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -341,6 +341,10 @@ export function useStackActions(options: UseStackActionsOptions) { } catch (error) { if (isAbortError(error) || signal.aborted) return; console.error('Failed to load file:', error); + // Surface the failure so a tap that cannot load (offline, dead remote + // node, 5xx) is not a silent no-op, especially on mobile where the row + // tap optimistically opens the detail surface. + toast.error(`Could not open "${filename.replace(/\.(ya?ml)$/, '')}". Check your connection and try again.`); stackListState.setSelectedFile(null); editorState.setContent(''); editorState.setOriginalContent(''); @@ -890,18 +894,40 @@ export function useStackActions(options: UseStackActionsOptions) { } }; + // Guard a navigation that would leave (and discard) a dirty editor: back to + // the list, Home, or any bottom-tab / hamburger / command-palette + // destination. When the editor is dirty the navigation is stashed and the + // unsaved-changes dialog opens; discardAndLoadPending runs it on confirm. + // When clean it runs immediately. + const attemptLeaveEditor = (perform: () => void) => { + if (stackListState.selectedFile && hasUnsavedChanges()) { + overlayState.setPendingLeaveAction({ run: perform }); + return; + } + perform(); + }; + const cancelPendingUnsavedLoad = () => { overlayState.setPendingUnsavedLoad(null); overlayState.setPendingUnsavedNode(null); + overlayState.setPendingLeaveAction(null); }; const discardAndLoadPending = () => { + const leave = overlayState.pendingLeaveAction; const target = overlayState.pendingUnsavedLoad; const targetNode = overlayState.pendingUnsavedNode; editorState.setContent(editorState.originalContent); editorState.setEnvContent(editorState.originalEnvContent); overlayState.setPendingUnsavedLoad(null); overlayState.setPendingUnsavedNode(null); + overlayState.setPendingLeaveAction(null); + // A stashed "leave editor" navigation takes precedence; it already knows + // how to tear down editor state (resetEditorState) and move the surface. + if (leave) { + leave.run(); + return; + } if (target === NODE_SWITCH_PENDING_TOKEN) { if (targetNode) setActiveNode(targetNode); return; @@ -1060,6 +1086,7 @@ export function useStackActions(options: UseStackActionsOptions) { serviceAction, updateStack, deleteStack, + attemptLeaveEditor, cancelPendingUnsavedLoad, discardAndLoadPending, requestDeleteStack, diff --git a/frontend/src/components/EditorLayout/mobile-surface.test.ts b/frontend/src/components/EditorLayout/mobile-surface.test.ts new file mode 100644 index 00000000..7f29528a --- /dev/null +++ b/frontend/src/components/EditorLayout/mobile-surface.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { deriveMobileSurface, type MobileSurfaceInput } from './mobile-surface'; + +const base: MobileSurfaceInput = { + activeView: 'dashboard', + selectedFile: null, + mobileView: 'list', + pendingDetailStack: null, +}; + +describe('deriveMobileSurface', () => { + it('shows the list when mobileView is list and no detail is open', () => { + expect(deriveMobileSurface(base)).toEqual({ surface: 'list', detailReady: false, detailOpen: false }); + }); + + it('shows content when mobileView is content', () => { + expect(deriveMobileSurface({ ...base, mobileView: 'content', activeView: 'fleet' }).surface).toBe('content'); + }); + + it('shows a ready detail when a stack is selected in editor view', () => { + expect(deriveMobileSurface({ ...base, activeView: 'editor', selectedFile: 'web.yml' })).toEqual({ + surface: 'detail', + detailReady: true, + detailOpen: true, + }); + }); + + it('shows the detail optimistically while a tap is pending and not yet ready', () => { + const r = deriveMobileSurface({ ...base, pendingDetailStack: 'web.yml' }); + expect(r.surface).toBe('detail'); + expect(r.detailOpen).toBe(true); + expect(r.detailReady).toBe(false); + }); + + it('falls back to the list once a pending tap clears without a selection (load-failed path)', () => { + expect(deriveMobileSurface({ ...base, pendingDetailStack: null, selectedFile: null }).surface).toBe('list'); + }); + + it('gives the detail precedence over a content view', () => { + expect( + deriveMobileSurface({ ...base, mobileView: 'content', activeView: 'editor', selectedFile: 'web.yml' }).surface, + ).toBe('detail'); + }); +}); diff --git a/frontend/src/components/EditorLayout/mobile-surface.ts b/frontend/src/components/EditorLayout/mobile-surface.ts new file mode 100644 index 00000000..df5693fa --- /dev/null +++ b/frontend/src/components/EditorLayout/mobile-surface.ts @@ -0,0 +1,44 @@ +import type { ActiveView } from './hooks/useViewNavigationState'; + +// The top-level mobile surface when no stack detail is open. Kept distinct from +// `activeView` so `dashboard` still maps to HomeDashboard rather than being +// overloaded to mean "the stack list". +export type MobileView = 'list' | 'content'; + +// The single surface the mobile shell renders at a time. +export type MobileSurface = 'list' | 'content' | 'detail'; + +export interface MobileSurfaceInput { + activeView: ActiveView; + selectedFile: string | null; + mobileView: MobileView; + /** Set the instant a row is tapped, before loadFile resolves selectedFile. */ + pendingDetailStack: string | null; +} + +export interface MobileSurfaceState { + surface: MobileSurface; + /** The real EditorView can mount (a stack is selected and editor is active). */ + detailReady: boolean; + /** Detail surface should show, including the optimistic pre-fetch window. */ + detailOpen: boolean; +} + +/** + * Pure derivation of which mobile surface to show. Extracted so the state + * machine can be unit-tested independently of the context-heavy EditorLayout. + */ +export function deriveMobileSurface({ + activeView, + selectedFile, + mobileView, + pendingDetailStack, +}: MobileSurfaceInput): MobileSurfaceState { + const detailReady = activeView === 'editor' && !!selectedFile; + const detailOpen = detailReady || !!pendingDetailStack; + let surface: MobileSurface; + if (detailOpen) surface = 'detail'; + else if (mobileView === 'list') surface = 'list'; + else surface = 'content'; + return { surface, detailReady, detailOpen }; +} diff --git a/frontend/src/components/LogViewer.tsx b/frontend/src/components/LogViewer.tsx index bdc9f73a..d5f61e85 100644 --- a/frontend/src/components/LogViewer.tsx +++ b/frontend/src/components/LogViewer.tsx @@ -56,7 +56,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi }, [isOpen, containerId]); return ( - !open && onClose()} className="max-w-4xl h-[80vh] flex flex-col"> + !open && onClose()} mobileFullScreen className="max-w-4xl h-[80vh] flex flex-col"> > = {}) { + const props: React.ComponentProps = { + navItems: allItems, + activeView: 'dashboard', + mobileView: 'list', + detailOpen: false, + onStacks: vi.fn(), + onNavigate: vi.fn(), + onSettings: vi.fn(), + ...over, + }; + render(); + return props; +} + +describe('MobileTabBar', () => { + it('always renders Stacks and Settings', () => { + renderBar(); + expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('renders Fleet and Schedules only when present in the gated nav items', () => { + renderBar({ navItems: [{ value: 'dashboard', label: 'Home', icon: Home }] }); + expect(screen.queryByRole('button', { name: 'Fleet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Schedules' })).not.toBeInTheDocument(); + // Stacks + Settings remain. + expect(screen.getByRole('button', { name: 'Stacks' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Settings' })).toBeInTheDocument(); + }); + + it('routes each tab to its handler', () => { + const props = renderBar(); + fireEvent.click(screen.getByRole('button', { name: 'Stacks' })); + expect(props.onStacks).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole('button', { name: 'Fleet' })); + expect(props.onNavigate).toHaveBeenCalledWith('fleet'); + fireEvent.click(screen.getByRole('button', { name: 'Schedules' })); + expect(props.onNavigate).toHaveBeenCalledWith('scheduled-ops'); + fireEvent.click(screen.getByRole('button', { name: 'Settings' })); + expect(props.onSettings).toHaveBeenCalledTimes(1); + }); + + it('marks Stacks as current while a stack detail is open', () => { + renderBar({ detailOpen: true, mobileView: 'content', activeView: 'fleet' }); + expect(screen.getByRole('button', { name: 'Stacks' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Fleet' })).not.toHaveAttribute('aria-current'); + }); + + it('marks the active content view as current', () => { + renderBar({ mobileView: 'content', activeView: 'fleet' }); + expect(screen.getByRole('button', { name: 'Fleet' })).toHaveAttribute('aria-current', 'page'); + expect(screen.getByRole('button', { name: 'Stacks' })).not.toHaveAttribute('aria-current'); + }); +}); diff --git a/frontend/src/components/MobileTabBar.tsx b/frontend/src/components/MobileTabBar.tsx new file mode 100644 index 00000000..143567b4 --- /dev/null +++ b/frontend/src/components/MobileTabBar.tsx @@ -0,0 +1,106 @@ +import { Layers, Radar, Clock, Settings as SettingsIcon } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { NavItem, ActiveView } from './EditorLayout/hooks/useViewNavigationState'; +import type { MobileView } from './EditorLayout/mobile-surface'; + +type TabId = 'stacks' | 'fleet' | 'schedules' | 'settings'; + +interface MobileTabBarProps { + /** The already-gated nav items (admin / remote / paid filtering applied). */ + navItems: NavItem[]; + activeView: ActiveView; + /** Which top-level mobile surface is showing when no stack detail is open. */ + mobileView: MobileView; + /** True while a stack detail is open (keeps the Stacks tab marked current). */ + detailOpen: boolean; + onStacks: () => void; + onNavigate: (view: ActiveView) => void; + onSettings: () => void; +} + +interface Tab { + id: TabId; + label: string; + icon: LucideIcon; + view?: ActiveView; +} + +/** + * Bottom tab bar for the mobile shell (hidden at md+). The four primary + * destinations; everything else stays reachable through the TopBar hamburger. + * Fleet and Schedules only appear when present in the gated `navItems`, so the + * bar never exposes a flow the desktop nav would hide (admin-only Schedules, + * hub-only views on a remote node). + */ +export function MobileTabBar({ + navItems, + activeView, + mobileView, + detailOpen, + onStacks, + onNavigate, + onSettings, +}: MobileTabBarProps) { + const has = (value: ActiveView) => navItems.some(i => i.value === value); + + const tabs: Tab[] = [ + { id: 'stacks', label: 'Stacks', icon: Layers }, + ...(has('fleet') ? [{ id: 'fleet' as const, label: 'Fleet', icon: Radar, view: 'fleet' as const }] : []), + ...(has('scheduled-ops') + ? [{ id: 'schedules' as const, label: 'Schedules', icon: Clock, view: 'scheduled-ops' as const }] + : []), + { id: 'settings', label: 'Settings', icon: SettingsIcon }, + ]; + + const currentTab = (): TabId | null => { + if (detailOpen || mobileView === 'list') return 'stacks'; + if (activeView === 'fleet') return 'fleet'; + if (activeView === 'scheduled-ops') return 'schedules'; + if (activeView === 'settings') return 'settings'; + return null; + }; + const current = currentTab(); + + const select = (tab: Tab) => { + if (tab.id === 'stacks') onStacks(); + else if (tab.id === 'settings') onSettings(); + else if (tab.view) onNavigate(tab.view); + }; + + return ( + + ); +} diff --git a/frontend/src/components/sidebar/SidebarFilterChips.tsx b/frontend/src/components/sidebar/SidebarFilterChips.tsx index 16f47e6b..735f9484 100644 --- a/frontend/src/components/sidebar/SidebarFilterChips.tsx +++ b/frontend/src/components/sidebar/SidebarFilterChips.tsx @@ -42,7 +42,9 @@ export function SidebarFilterChips({ active, counts, onChange, visible, onToggle type="button" onClick={() => onChange(id)} className={cn( - 'flex items-center gap-1 min-w-0 overflow-hidden rounded px-1.5 py-0.5 whitespace-nowrap', + 'flex items-center justify-center gap-1 min-w-0 overflow-hidden rounded px-1.5 py-0.5 whitespace-nowrap', + // 44px tap target on touch viewports; desktop density unchanged. + 'max-md:min-h-11 max-md:py-2', 'font-mono text-[10px] tracking-[0.08em] uppercase leading-none', 'border transition-colors duration-150', isActive @@ -70,7 +72,7 @@ export function SidebarFilterChips({ active, counts, onChange, visible, onToggle