mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
feat(ui): make the core stack flow usable on mobile (#1327)
* feat(ui): make the core stack flow usable on mobile Below the md breakpoint the app collapses to a single full-width column: the stack list is full-screen, tapping a stack opens a full-screen detail with a Health / Logs / Compose segmented control (Logs first) and a back button, and a bottom tab bar switches Stacks, Fleet, Schedules, and Settings. Compose is read-only on a phone with a prompt to edit on desktop. Desktop (md and up) is unchanged: the mobile shell is gated behind a useIsMobile hook plus max-md/md variants, and the stack-detail blocks are shared with the desktop two-pane view so it renders identically. Also generalizes the unsaved-changes guard so leaving a dirty editor (back, tab bar, hamburger) prompts before discarding; adds 44px touch targets on list rows, filter chips, and actions; makes log and shell modals full-screen on mobile; and offsets toasts and the deploy pill above the bottom tab bar. * fix(ui): keep mobile nav in sync when opening views from outside the bottom bar On a phone the sidebar activity actions, the node switcher's Manage Nodes, the profile Settings entry, and the dashboard configuration links set the active view without flipping the mobile surface to content, so the user stayed on the stack list and never saw the destination. Route these through the mobile-aware navigation and settings helpers (a no-op on desktop).
This commit is contained in:
@@ -214,7 +214,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={handleClose} className="max-w-4xl h-[600px] flex flex-col">
|
||||
<Modal open={isOpen} onOpenChange={handleClose} mobileFullScreen className="max-w-4xl h-[600px] flex flex-col">
|
||||
<ModalHeader
|
||||
kicker={`BASH · ${containerName.toUpperCase()}`}
|
||||
title={
|
||||
|
||||
@@ -40,7 +40,7 @@ function DeployFeedbackPillBase({ isVisible, onExpand }: DeployFeedbackPillProps
|
||||
data-testid="deploy-feedback-pill"
|
||||
onClick={onExpand}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[280px] bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-glass-border rounded-full px-3 py-1.5 shadow-lg cursor-pointer flex items-center gap-2"
|
||||
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 w-[280px] bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] border border-glass-border rounded-full px-3 py-1.5 shadow-lg cursor-pointer flex items-center gap-2 max-md:bottom-[calc(var(--sn-mobile-tabbar-h)_+_env(safe-area-inset-bottom)_+_0.75rem)]"
|
||||
>
|
||||
<span className={dotClass} />
|
||||
<span className={cn(textClass, 'flex items-center gap-1 min-w-0 flex-1 overflow-hidden')}>
|
||||
|
||||
@@ -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<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);
|
||||
|
||||
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 = () => (
|
||||
<EditorView
|
||||
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}
|
||||
onMobileBack={goToMobileList}
|
||||
/>
|
||||
);
|
||||
|
||||
// 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 (
|
||||
<GlobalCommandPaletteProvider>
|
||||
<div className="flex h-screen w-screen overflow-hidden app-canvas text-foreground">
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
onSelectStack={stackActions.loadFileOnNode}
|
||||
/>
|
||||
{/* Left Sidebar (Stacks) */}
|
||||
<StackSidebar
|
||||
isDarkMode={isDarkMode}
|
||||
nodeSwitcherSlot={
|
||||
<NodeSwitcher
|
||||
onManageNodes={() => handleOpenSettings('nodes')}
|
||||
/>
|
||||
}
|
||||
createStackSlot={createStackSlot}
|
||||
onScan={handleScanStacks}
|
||||
isScanning={isScanning}
|
||||
canCreate={can('stack:create')}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filterChip={filterChip}
|
||||
filterCounts={filterCounts}
|
||||
onFilterChipChange={setFilterChip}
|
||||
list={{
|
||||
files: chipFilteredFiles,
|
||||
isLoading,
|
||||
selectedFile,
|
||||
searchQuery,
|
||||
stackLabelMap,
|
||||
stackStatuses: stackStatuses as Record<string, StackRowStatus | undefined>,
|
||||
stackUpdates,
|
||||
gitSourcePendingMap,
|
||||
pinnedFiles: pinned,
|
||||
isCollapsed,
|
||||
toggleCollapse,
|
||||
isBusy: isStackBusy,
|
||||
getDisplayName: stackActions.getDisplayName,
|
||||
onSelectFile: stackActions.loadFile,
|
||||
buildMenuCtx,
|
||||
remoteResults,
|
||||
remoteLoading: remoteSearchLoading,
|
||||
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 = (
|
||||
<GlobalCommandPalette
|
||||
navItems={navItems}
|
||||
onNavigate={navHandler}
|
||||
onSelectStack={stackActions.loadFileOnNode}
|
||||
/>
|
||||
);
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
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>,
|
||||
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 = (
|
||||
<TopBar
|
||||
activeView={activeView}
|
||||
navItems={navItems}
|
||||
onNavigate={handleNavigate}
|
||||
onNavigate={navHandler}
|
||||
mobileNavOpen={mobileNavOpen}
|
||||
onMobileNavOpenChange={setMobileNavOpen}
|
||||
search={<GlobalCommandPaletteTrigger />}
|
||||
@@ -422,13 +546,14 @@ export default function EditorLayout() {
|
||||
}
|
||||
userMenu={
|
||||
<UserProfileDropdown
|
||||
onOpenSettings={() => handleOpenSettings('account')}
|
||||
onOpenSettings={() => openSettings('account')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
{/* Main Workspace */}
|
||||
<div key={activeView} className="flex-1 overflow-y-auto p-6 animate-fade-up">
|
||||
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}
|
||||
@@ -457,78 +582,101 @@ export default function EditorLayout() {
|
||||
onPrefillConsumed={handlePrefillConsumed}
|
||||
notifications={notifications}
|
||||
onNavigateToStack={(stackFile) => { void stackActions.loadFile(stackFile); }}
|
||||
onOpenSettingsSection={(section) => handleOpenSettings(section)}
|
||||
onOpenSettingsSection={(section) => openSettings(section)}
|
||||
onClearNotifications={clearAllNotifications}
|
||||
renderEditor={() => (
|
||||
<EditorView
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
renderEditor={renderEditor}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<ShellOverlays
|
||||
overlayState={overlayState}
|
||||
stackActions={stackActions}
|
||||
isDarkMode={isDarkMode}
|
||||
isAdmin={isAdmin}
|
||||
can={can}
|
||||
selectedFile={selectedFile}
|
||||
stackName={stackName}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
securityHistoryOpen={securityHistoryOpen}
|
||||
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
||||
/>
|
||||
</div>
|
||||
const shellOverlaysEl = (
|
||||
<ShellOverlays
|
||||
overlayState={overlayState}
|
||||
stackActions={stackActions}
|
||||
isDarkMode={isDarkMode}
|
||||
isAdmin={isAdmin}
|
||||
can={can}
|
||||
selectedFile={selectedFile}
|
||||
stackName={stackName}
|
||||
gitSourceOpen={gitSourceOpen}
|
||||
setGitSourceOpen={setGitSourceOpen}
|
||||
securityHistoryOpen={securityHistoryOpen}
|
||||
setSecurityHistoryOpen={setSecurityHistoryOpen}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col overflow-hidden app-canvas text-foreground">
|
||||
{commandPaletteEl}
|
||||
{mobileSurface !== 'detail' && topBarEl}
|
||||
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
|
||||
{mobileSurface === 'list' && sidebarEl}
|
||||
{mobileSurface === 'content' && workspaceEl}
|
||||
{mobileSurface === 'detail' && (
|
||||
detailReady ? (
|
||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">{renderEditor()}</div>
|
||||
) : (
|
||||
<MobileDetailLoading name={pendingDetailStack ?? ''} onBack={goToMobileList} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<MobileTabBar
|
||||
navItems={navItems}
|
||||
activeView={activeView}
|
||||
mobileView={mobileView}
|
||||
detailOpen={detailOpen}
|
||||
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 }: { name: string; onBack: () => void }) {
|
||||
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="truncate font-display text-2xl italic text-stat-value">
|
||||
{name.replace(/\.(ya?ml)$/, '')}
|
||||
</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<React.SetStateAction<string | null>>;
|
||||
|
||||
// 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<import('monaco-editor').editor.IStandaloneCodeEditor | null>(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 <MobileStackDetail {...props} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="grid gap-6 grid-cols-1 lg:grid-cols-2 min-h-[600px] h-[calc(100vh-160px)] max-h-[1040px]">
|
||||
@@ -333,374 +259,45 @@ export function EditorView({
|
||||
{/* Command Center Card (identity + health strip) */}
|
||||
<Card className="rounded-xl border-muted bg-card shrink-0">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Identity block */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60">›</span> stacks <span className="text-muted-foreground/60">›</span> {stackName}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
{(() => {
|
||||
const pill = getStackStatePill(safeContainers);
|
||||
if (!pill) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 ${pill.className}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${pill.dotClass} ${pill.pulse ? 'animate-[pulse_2.4s_ease-in-out_infinite]' : ''}`}
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">{pill.label}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const first = safeContainers[0];
|
||||
if (!first?.Image) return null;
|
||||
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
|
||||
{digest && first.ImageID && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>digest <span className="text-foreground/90">{digest}</span></span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
||||
onClick={() => {
|
||||
const id = first.ImageID as string;
|
||||
void copyToClipboard(id).then(() => {
|
||||
setCopiedDigest(id);
|
||||
if (copiedDigestTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedDigestTimerRef.current);
|
||||
}
|
||||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedDigest(prev => (prev === id ? null : prev));
|
||||
copiedDigestTimerRef.current = null;
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copiedDigest === first.ImageID ? (
|
||||
<Check className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Action Bar — 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 (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canDeploy && (
|
||||
<>
|
||||
{isRunning ? (
|
||||
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
|
||||
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
|
||||
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={stopStack} disabled={loadingAction !== null}>
|
||||
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg" onClick={updateStack} disabled={loadingAction !== null}>
|
||||
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasOverflow && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0" disabled={loadingAction !== null} aria-label="More actions">
|
||||
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{canRollback && (
|
||||
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null}>
|
||||
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
|
||||
{backupInfo.timestamp && (
|
||||
<span className="text-[10px] text-stat-subtitle font-mono">{new Date(backupInfo.timestamp).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canScan && (
|
||||
<DropdownMenuItem onClick={scanStackConfig} disabled={loadingAction !== null || stackMisconfigScanning}>
|
||||
{stackMisconfigScanning ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
)}
|
||||
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
disabled={loadingAction !== null}
|
||||
onClick={requestDeleteStack}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<StackIdentityHeader
|
||||
stackName={stackName}
|
||||
activeNode={activeNode}
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
copiedDigest={copiedDigest}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
deployStack={deployStack}
|
||||
restartStack={restartStack}
|
||||
stopStack={stopStack}
|
||||
updateStack={updateStack}
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2">
|
||||
{/* Per-container health strip */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">CONTAINERS</h4>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5"
|
||||
title={containerStatsError}
|
||||
>
|
||||
Stats unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
if (container.Ports && container.Ports.length > 0) {
|
||||
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
||||
const IGNORE_PORTS = [1900, 53, 22];
|
||||
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
|
||||
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
|
||||
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
|
||||
const chosen = match || container.Ports[0];
|
||||
mainPort = chosen.PublicPort;
|
||||
mainPortPrivate = chosen.PrivatePort;
|
||||
mainPortProto = 'tcp';
|
||||
}
|
||||
|
||||
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
|
||||
const 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 (
|
||||
<div key={container?.Id || Math.random()} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
|
||||
{badgeGlyph}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
|
||||
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
|
||||
{mainPort && mainPortPrivate ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{mainPort} → {mainPortPrivate}/{mainPortProto}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||
? new URL(activeNode.api_url).hostname
|
||||
: window.location.hostname;
|
||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">cpu</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">mem</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">net i/o</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
isAdmin={isAdmin}
|
||||
activeNode={activeNode}
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Logs Section (fills remaining left-column height) */}
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('structured')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Structured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('raw')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Raw terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{logsMode === 'structured' ? (
|
||||
<ErrorBoundary>
|
||||
<StructuredLogViewer stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
<div className="h-full">
|
||||
<ErrorBoundary>
|
||||
<TerminalComponent stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
|
||||
</div>
|
||||
|
||||
{/* Right column: anatomy panel by default, Monaco editor when editing */}
|
||||
|
||||
@@ -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: () => <div>identity-header</div>,
|
||||
ContainersHealth: () => <div>health-pane</div>,
|
||||
StackLogsSection: () => <div>logs-pane</div>,
|
||||
}));
|
||||
vi.mock('../StackAnatomyPanel', () => ({ default: () => <div>compose-pane</div> }));
|
||||
vi.mock('../ErrorBoundary', () => ({ default: ({ children }: { children: ReactNode }) => <>{children}</> }));
|
||||
|
||||
function makeProps(over: Partial<EditorViewProps> = {}): 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(<MobileStackDetail {...makeProps()} />);
|
||||
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(<MobileStackDetail {...makeProps()} />);
|
||||
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(<MobileStackDetail {...makeProps()} />);
|
||||
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(<MobileStackDetail {...makeProps({ onMobileBack })} />);
|
||||
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(<MobileStackDetail {...makeProps({ can: () => 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(<MobileStackDetail {...makeProps({ can: () => false })} />);
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Compose' }));
|
||||
expect(screen.queryByText(/Editing compose is available/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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<Segment>('logs');
|
||||
|
||||
const safeContainers = containers || [];
|
||||
const isRunning = safeContainers.some(c => c.State === 'running');
|
||||
const canEditStack = can('stack:edit', 'stack', stackName);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Detail header: back to list + identity + action bar */}
|
||||
<div className="shrink-0 border-b border-hairline px-4 pb-3 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMobileBack}
|
||||
aria-label="Back to stacks"
|
||||
className="mb-1 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>
|
||||
<StackIdentityHeader
|
||||
stackName={stackName}
|
||||
activeNode={activeNode}
|
||||
safeContainers={safeContainers}
|
||||
isRunning={isRunning}
|
||||
copiedDigest={copiedDigest}
|
||||
setCopiedDigest={setCopiedDigest}
|
||||
copiedDigestTimerRef={copiedDigestTimerRef}
|
||||
can={can}
|
||||
isAdmin={isAdmin}
|
||||
trivy={trivy}
|
||||
backupInfo={backupInfo}
|
||||
loadingAction={loadingAction}
|
||||
stackMisconfigScanning={stackMisconfigScanning}
|
||||
deployStack={deployStack}
|
||||
restartStack={restartStack}
|
||||
stopStack={stopStack}
|
||||
updateStack={updateStack}
|
||||
rollbackStack={rollbackStack}
|
||||
scanStackConfig={scanStackConfig}
|
||||
requestDeleteStack={requestDeleteStack}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Segmented control: Health · Logs · Compose */}
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Stack detail sections"
|
||||
className="flex gap-1 rounded-lg border border-card-border bg-well p-1 shadow-[var(--shadow-well)]"
|
||||
>
|
||||
{SEGMENTS.map(seg => {
|
||||
const on = seg.id === segment;
|
||||
return (
|
||||
<button
|
||||
key={seg.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={on}
|
||||
onClick={() => setSegment(seg.id)}
|
||||
className={cn(
|
||||
'flex-1 rounded-md py-2 font-mono text-[11px] uppercase tracking-[0.14em] transition-colors',
|
||||
on
|
||||
? 'bg-card text-stat-value shadow-card-bevel'
|
||||
: 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{seg.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active segment */}
|
||||
<div className="flex flex-1 min-h-0 flex-col overflow-hidden p-4">
|
||||
{segment === 'health' && (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<ContainersHealth
|
||||
safeContainers={safeContainers}
|
||||
containerStats={containerStats}
|
||||
containerStatsError={containerStatsError}
|
||||
isAdmin={isAdmin}
|
||||
activeNode={activeNode}
|
||||
openLogViewer={openLogViewer}
|
||||
openBashModal={openBashModal}
|
||||
serviceAction={serviceAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{segment === 'logs' && (
|
||||
<StackLogsSection stackName={stackName} logsMode={logsMode} setLogsMode={setLogsMode} />
|
||||
)}
|
||||
{segment === 'compose' && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<div className="min-h-0 flex-1">
|
||||
<StackAnatomyPanel
|
||||
stackName={stackName}
|
||||
content={content}
|
||||
envContent={envContent}
|
||||
selectedEnvFile={selectedEnvFile}
|
||||
gitSourcePending={Boolean(gitSourcePendingMap[stackName])}
|
||||
onEditCompose={() => {}}
|
||||
onOpenGitSource={() => setGitSourceOpen(true)}
|
||||
onApplyUpdate={() => { void updateStack(); }}
|
||||
applying={loadingAction === 'update'}
|
||||
canEdit={false}
|
||||
notifications={notifications}
|
||||
/>
|
||||
</div>
|
||||
{canEditStack && (
|
||||
<div className="shrink-0 rounded-lg border border-card-border bg-card px-3 py-2.5 font-mono text-[11px] text-stat-subtitle">
|
||||
Editing compose is available on a larger screen. Open this stack on desktop to edit the file.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
/>
|
||||
|
||||
<UnsavedChangesDialog
|
||||
open={!!pendingUnsavedLoad}
|
||||
open={!!pendingUnsavedLoad || !!pendingLeaveAction}
|
||||
onCancel={stackActions.cancelPendingUnsavedLoad}
|
||||
onConfirm={stackActions.discardAndLoadPending}
|
||||
/>
|
||||
|
||||
@@ -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<React.SetStateAction<string | null>>;
|
||||
copiedDigestTimerRef: React.MutableRefObject<number | null>;
|
||||
can: ReturnType<typeof useAuth>['can'];
|
||||
isAdmin: boolean;
|
||||
trivy: { available: boolean };
|
||||
backupInfo: { exists: boolean; timestamp: number | null };
|
||||
loadingAction: StackAction | null;
|
||||
stackMisconfigScanning: boolean;
|
||||
deployStack: (e: React.MouseEvent) => Promise<void>;
|
||||
restartStack: (e: React.MouseEvent) => Promise<void>;
|
||||
stopStack: (e: React.MouseEvent) => Promise<void>;
|
||||
updateStack: (e?: React.MouseEvent) => Promise<void>;
|
||||
rollbackStack: () => Promise<void>;
|
||||
scanStackConfig: () => Promise<void>;
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Identity block */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.22em] text-stat-subtitle">
|
||||
{(activeNode?.name || 'local')} <span className="text-muted-foreground/60">›</span> stacks <span className="text-muted-foreground/60">›</span> {stackName}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<CardTitle className="font-display italic text-3xl leading-none tracking-tight">{stackName}</CardTitle>
|
||||
{(() => {
|
||||
const pill = getStackStatePill(safeContainers);
|
||||
if (!pill) return null;
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 ${pill.className}`}>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`h-1.5 w-1.5 rounded-full ${pill.dotClass} ${pill.pulse ? 'animate-[pulse_2.4s_ease-in-out_infinite]' : ''}`}
|
||||
/>
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.18em]">{pill.label}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{(() => {
|
||||
const first = safeContainers[0];
|
||||
if (!first?.Image) return null;
|
||||
const digest = first.ImageID ? first.ImageID.replace(/^sha256:/, '').slice(0, 12) : '';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 font-mono text-[11px] text-stat-subtitle">
|
||||
<span>image <span className="text-muted-foreground/60">·</span> <span className="text-foreground/90">{first.Image}</span></span>
|
||||
{digest && first.ImageID && (
|
||||
<>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span>digest <span className="text-foreground/90">{digest}</span></span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={copiedDigest === first.ImageID ? 'Copied' : 'Copy digest'}
|
||||
onClick={() => {
|
||||
const id = first.ImageID as string;
|
||||
void copyToClipboard(id).then(() => {
|
||||
setCopiedDigest(id);
|
||||
if (copiedDigestTimerRef.current !== null) {
|
||||
window.clearTimeout(copiedDigestTimerRef.current);
|
||||
}
|
||||
copiedDigestTimerRef.current = window.setTimeout(() => {
|
||||
setCopiedDigest(prev => (prev === id ? null : prev));
|
||||
copiedDigestTimerRef.current = null;
|
||||
}, 1500);
|
||||
}).catch(() => { /* clipboard unavailable */ });
|
||||
}}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded text-stat-subtitle hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
{copiedDigest === first.ImageID ? (
|
||||
<Check className="h-3 w-3" strokeWidth={2} />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" strokeWidth={1.5} />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
{/* Action Bar: 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 (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{canDeploy && (
|
||||
<>
|
||||
{isRunning ? (
|
||||
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={restartStack} disabled={loadingAction !== null}>
|
||||
<RotateCw className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'restart' ? 'Restarting...' : 'Restart'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="button" size="sm" data-testid="stack-deploy-button" className="rounded-lg max-md:h-11 bg-brand text-brand-foreground hover:bg-brand/90" onClick={deployStack} disabled={loadingAction !== null}>
|
||||
<Play className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'deploy' ? 'Starting...' : 'Start'}
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={stopStack} disabled={loadingAction !== null}>
|
||||
<Square className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'stop' ? 'Stopping...' : 'Stop'}
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" size="sm" variant="outline" className="rounded-lg max-md:h-11" onClick={updateStack} disabled={loadingAction !== null}>
|
||||
<CloudDownload className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'update' ? 'Updating...' : 'Update'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{hasOverflow && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" size="sm" variant="ghost" className="rounded-lg h-8 w-8 p-0 max-md:h-11 max-md:w-11" disabled={loadingAction !== null} aria-label="More actions">
|
||||
<MoreVertical className="w-4 h-4" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{canRollback && (
|
||||
<DropdownMenuItem onClick={rollbackStack} disabled={loadingAction !== null}>
|
||||
<Undo2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{loadingAction === 'rollback' ? 'Rolling back...' : 'Rollback'}</span>
|
||||
{backupInfo.timestamp && (
|
||||
<span className="text-[10px] text-stat-subtitle font-mono">{new Date(backupInfo.timestamp).toLocaleString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canScan && (
|
||||
<DropdownMenuItem onClick={scanStackConfig} disabled={loadingAction !== null || stackMisconfigScanning}>
|
||||
{stackMisconfigScanning ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" strokeWidth={1.5} />
|
||||
) : (
|
||||
<ShieldCheck className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
)}
|
||||
{stackMisconfigScanning ? 'Scanning...' : 'Scan config'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{hasOverflowExtras && canDelete && <DropdownMenuSeparator />}
|
||||
{canDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive focus:bg-destructive/10"
|
||||
disabled={loadingAction !== null}
|
||||
onClick={requestDeleteStack}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" strokeWidth={1.5} />
|
||||
{loadingAction === 'delete' ? 'Deleting...' : 'Delete'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ContainersHealthProps {
|
||||
safeContainers: ContainerInfo[];
|
||||
containerStats: Record<string, ContainerStatsEntry>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">CONTAINERS</h4>
|
||||
{containerStatsError && safeContainers.length > 0 && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wider font-mono text-warning-foreground bg-warning/10 border border-warning/30 rounded-md px-2 py-0.5"
|
||||
title={containerStatsError}
|
||||
>
|
||||
Stats unavailable
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{safeContainers.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">No containers running for this stack.</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{safeContainers.map(container => {
|
||||
let mainPort: number | undefined;
|
||||
let mainPortPrivate: number | undefined;
|
||||
let mainPortProto: string | undefined;
|
||||
if (container.Ports && container.Ports.length > 0) {
|
||||
const WEB_UI_PORTS = [32400, 8989, 7878, 9696, 5055, 8080, 80, 443, 3000, 9000];
|
||||
const IGNORE_PORTS = [1900, 53, 22];
|
||||
let match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PrivatePort));
|
||||
if (!match) match = container.Ports.find(p => WEB_UI_PORTS.includes(p.PublicPort));
|
||||
if (!match) match = container.Ports.find(p => !IGNORE_PORTS.includes(p.PrivatePort) && !IGNORE_PORTS.includes(p.PublicPort));
|
||||
const chosen = match || container.Ports[0];
|
||||
mainPort = chosen.PublicPort;
|
||||
mainPortPrivate = chosen.PrivatePort;
|
||||
mainPortProto = 'tcp';
|
||||
}
|
||||
|
||||
const containerName = container?.Names?.[0]?.replace(/^\//, '') || container?.Id?.slice(0, 12) || 'container';
|
||||
const 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 (
|
||||
<div key={container?.Id || Math.random()} className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
<div className={cn('mt-1 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full text-[10px] font-bold', badgeClass)}>
|
||||
{badgeGlyph}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="truncate font-mono text-sm text-foreground">{containerName}</div>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 font-mono text-[11px] text-stat-subtitle">
|
||||
{uptime ? <span>{uptime}</span> : <span>{(container.State || 'unknown').toLowerCase()}</span>}
|
||||
{hcLabel ? <><span>·</span><span>{hcLabel}</span></> : null}
|
||||
{mainPort && mainPortPrivate ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{mainPort} → {mainPortPrivate}/{mainPortProto}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const host = activeNode?.type === 'remote' && activeNode?.api_url
|
||||
? new URL(activeNode.api_url).hostname
|
||||
: window.location.hostname;
|
||||
window.open(`http://${host}:${mainPort}`, '_blank');
|
||||
}}
|
||||
className="inline-flex items-center gap-1 text-brand hover:underline"
|
||||
>
|
||||
open <ArrowUpRight className="h-3 w-3" strokeWidth={1.5} />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
onClick={() => openLogViewer(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="View logs"
|
||||
>
|
||||
<ScrollText className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
onClick={() => openBashModal(container?.Id, containerName)}
|
||||
disabled={!isActive}
|
||||
aria-label="Open bash shell"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
)}
|
||||
{container.Service && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 rounded-md max-md:h-11 max-md:w-11"
|
||||
aria-label="Service actions"
|
||||
>
|
||||
<MoreVertical className="h-3.5 w-3.5" strokeWidth={1.5} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{isActive ? (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('restart', container.Service!)}>
|
||||
Restart service
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => serviceAction('stop', container.Service!)}>
|
||||
Stop service
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : (
|
||||
<DropdownMenuItem onSelect={() => serviceAction('start', container.Service!)}>
|
||||
Start service
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isActive ? (
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">cpu</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.cpu ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.cpu ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">mem</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.ram ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.mem ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/60 px-2 py-1.5">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-mono text-[10px] leading-3 uppercase tracking-[0.18em] text-stat-subtitle">net i/o</span>
|
||||
<span className="font-mono text-xs tabular-nums text-foreground">{stats?.net ?? '-'}</span>
|
||||
</div>
|
||||
<div className="ml-auto h-5 w-16">
|
||||
<Sparkline points={history?.netIn ?? []} stroke={sparkStroke} fill={sparkStroke} showPeak={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex-1 min-h-0 flex flex-col gap-2 overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium text-stat-subtitle">Logs</h3>
|
||||
<div className="inline-flex rounded-md border border-muted bg-muted/30 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('structured')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'structured' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Structured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLogsMode('raw')}
|
||||
className={cn(
|
||||
'rounded px-2 py-0.5 font-mono text-[10px] uppercase tracking-wide transition-colors',
|
||||
logsMode === 'raw' ? 'bg-brand/15 text-brand' : 'text-stat-subtitle hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Raw terminal
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{logsMode === 'structured' ? (
|
||||
<ErrorBoundary>
|
||||
<StructuredLogViewer stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<div className="flex-1 rounded-xl overflow-hidden border border-muted bg-black p-3 shadow-[inset_0_2px_4px_0_oklch(0_0_0/0.4)]">
|
||||
<div className="h-full">
|
||||
<ErrorBoundary>
|
||||
<TerminalComponent stackName={stackName} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,11 @@ export function useOverlayState() {
|
||||
|
||||
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
|
||||
const [pendingUnsavedNode, setPendingUnsavedNode] = useState<Node | null>(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<Container | null>(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,
|
||||
|
||||
@@ -70,8 +70,10 @@ function makeOverlay(over: Partial<OverlayState> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -56,7 +56,7 @@ export function LogViewer({ containerId, containerName, isOpen, onClose }: LogVi
|
||||
}, [isOpen, containerId]);
|
||||
|
||||
return (
|
||||
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()} className="max-w-4xl h-[80vh] flex flex-col">
|
||||
<Modal open={isOpen} onOpenChange={(open) => !open && onClose()} mobileFullScreen className="max-w-4xl h-[80vh] flex flex-col">
|
||||
<ModalHeader
|
||||
kicker={`LOGS · ${containerName.toUpperCase()}`}
|
||||
title={
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Home, Radar, Clock } from 'lucide-react';
|
||||
import { MobileTabBar } from './MobileTabBar';
|
||||
import type { NavItem } from './EditorLayout/hooks/useViewNavigationState';
|
||||
|
||||
const allItems: NavItem[] = [
|
||||
{ value: 'dashboard', label: 'Home', icon: Home },
|
||||
{ value: 'fleet', label: 'Fleet', icon: Radar },
|
||||
{ value: 'scheduled-ops', label: 'Schedules', icon: Clock },
|
||||
];
|
||||
|
||||
function renderBar(over: Partial<React.ComponentProps<typeof MobileTabBar>> = {}) {
|
||||
const props: React.ComponentProps<typeof MobileTabBar> = {
|
||||
navItems: allItems,
|
||||
activeView: 'dashboard',
|
||||
mobileView: 'list',
|
||||
detailOpen: false,
|
||||
onStacks: vi.fn(),
|
||||
onNavigate: vi.fn(),
|
||||
onSettings: vi.fn(),
|
||||
...over,
|
||||
};
|
||||
render(<MobileTabBar {...props} />);
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<nav
|
||||
aria-label="Primary mobile"
|
||||
className={cn(
|
||||
'md:hidden flex shrink-0 items-stretch',
|
||||
'border-t border-hairline',
|
||||
'bg-[color-mix(in_oklch,var(--card)_72%,transparent)] backdrop-blur-md backdrop-saturate-150',
|
||||
'pb-[env(safe-area-inset-bottom)]',
|
||||
)}
|
||||
>
|
||||
{tabs.map(tab => {
|
||||
const on = current === tab.id;
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => select(tab)}
|
||||
aria-current={on ? 'page' : undefined}
|
||||
aria-label={tab.label}
|
||||
className={cn(
|
||||
'flex flex-1 min-h-14 flex-col items-center justify-center gap-1 py-2',
|
||||
'transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/50',
|
||||
on ? 'text-brand' : 'text-stat-icon hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" strokeWidth={1.5} />
|
||||
<span className={cn('font-mono text-[9px] uppercase tracking-[0.14em]', on && 'font-medium')}>
|
||||
{tab.label}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="px-2 shrink-0 text-stat-icon hover:text-stat-title transition-colors duration-150"
|
||||
className="px-2 max-md:min-h-11 max-md:px-3 shrink-0 text-stat-icon hover:text-stat-title transition-colors duration-150"
|
||||
aria-label={visible ? 'Hide filters' : 'Show filters'}
|
||||
>
|
||||
{visible
|
||||
|
||||
@@ -126,9 +126,10 @@ export function StackRow(props: StackRowProps) {
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
{/* Kebab — always rightmost */}
|
||||
{/* Kebab: always rightmost. Hover-revealed on desktop; always visible on
|
||||
touch viewports where there is no hover. */}
|
||||
<div
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
|
||||
className="opacity-0 group-hover:opacity-100 max-md:opacity-100 transition-opacity flex-shrink-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{kebabSlot}
|
||||
|
||||
@@ -59,8 +59,12 @@ export function StackSidebar(props: StackSidebarProps) {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-64 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
|
||||
<SidebarBrand isDarkMode={isDarkMode} />
|
||||
<div className="w-64 max-md:w-full max-md:flex-1 max-md:min-h-0 max-md:border-r-0 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
|
||||
{/* The TopBar provides the global chrome on mobile, so the in-sidebar
|
||||
brand row is redundant there and hidden to save vertical space. */}
|
||||
<div className="max-md:hidden">
|
||||
<SidebarBrand isDarkMode={isDarkMode} />
|
||||
</div>
|
||||
<div className="px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
|
||||
{canCreate && createStackSlot !== null && (
|
||||
<SidebarActions
|
||||
|
||||
@@ -2,6 +2,8 @@ import { cn } from '@/lib/utils';
|
||||
|
||||
export const sidebarRowBase = cn(
|
||||
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
|
||||
// 44px tap target on touch viewports without changing desktop density.
|
||||
'max-md:min-h-11 max-md:py-2.5',
|
||||
'font-mono text-[13px] text-muted-foreground',
|
||||
'hover:bg-glass-highlight hover:text-foreground',
|
||||
'transition-colors group cursor-pointer',
|
||||
|
||||
@@ -34,11 +34,14 @@ interface ModalProps {
|
||||
children: React.ReactNode;
|
||||
size?: ModalSize;
|
||||
className?: string;
|
||||
/** Pass through to DialogContent — set to false when the modal renders its own close affordance. */
|
||||
/** Pass through to DialogContent. Set to false when the modal renders its own close affordance. */
|
||||
showClose?: boolean;
|
||||
/** Fill the viewport below md (for large overlays like logs / shell). Opt-in so
|
||||
* small confirm dialogs keep their compact centered size on a phone. */
|
||||
mobileFullScreen?: boolean;
|
||||
}
|
||||
|
||||
export function Modal({ open, onOpenChange, children, size = 'md', className, showClose }: ModalProps) {
|
||||
export function Modal({ open, onOpenChange, children, size = 'md', className, showClose, mobileFullScreen }: ModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
@@ -47,6 +50,7 @@ export function Modal({ open, onOpenChange, children, size = 'md', className, sh
|
||||
'p-0 gap-0 overflow-hidden grid-cols-1',
|
||||
SIZE_CLASS[size],
|
||||
className,
|
||||
mobileFullScreen && 'max-md:h-[100dvh] max-md:w-screen max-md:max-w-none max-md:rounded-none max-md:border-0',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -38,9 +38,9 @@ const sheetVariants = cva(
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 max-md:w-full max-md:max-w-none border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
"inset-y-0 right-0 h-full w-3/4 max-md:w-full max-md:max-w-none border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -204,7 +204,7 @@ export function ToastContainer() {
|
||||
const visible = toasts.slice(-MAX_VISIBLE);
|
||||
|
||||
return createPortal(
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[200] flex w-full max-w-sm flex-col gap-2 p-4">
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[200] flex w-full max-w-sm flex-col gap-2 p-4 max-md:bottom-[calc(var(--sn-mobile-tabbar-h)_+_env(safe-area-inset-bottom)_+_0.75rem)]">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{visible.map((t) => (
|
||||
<ToastItem key={t.id} {...t} />
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useIsMobile } from './use-is-mobile';
|
||||
|
||||
function installMatchMedia(initialMatches: boolean) {
|
||||
let listener: ((e: MediaQueryListEvent) => void) | null = null;
|
||||
const mql = {
|
||||
matches: initialMatches,
|
||||
media: '',
|
||||
onchange: null,
|
||||
addEventListener: (_type: string, cb: (e: MediaQueryListEvent) => void) => { listener = cb; },
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
window.matchMedia = vi.fn().mockReturnValue(mql) as unknown as typeof window.matchMedia;
|
||||
return {
|
||||
emit(matches: boolean) {
|
||||
mql.matches = matches;
|
||||
listener?.({ matches } as MediaQueryListEvent);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('useIsMobile', () => {
|
||||
const original = window.matchMedia;
|
||||
afterEach(() => { window.matchMedia = original; });
|
||||
|
||||
it('returns false at desktop widths', () => {
|
||||
installMatchMedia(false);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true below the breakpoint', () => {
|
||||
installMatchMedia(true);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('updates when the media query crosses the breakpoint', () => {
|
||||
const mm = installMatchMedia(false);
|
||||
const { result } = renderHook(() => useIsMobile());
|
||||
expect(result.current).toBe(false);
|
||||
act(() => mm.emit(true));
|
||||
expect(result.current).toBe(true);
|
||||
act(() => mm.emit(false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
// Matches Tailwind's `max-md` variant exactly (md starts at 768px), so a JS
|
||||
// branch keyed on this hook and a `max-md:` class always agree on which side
|
||||
// of the breakpoint we are. Below this width Sencho renders its mobile shell;
|
||||
// at or above it the desktop sidebar + workspace layout is untouched.
|
||||
const MOBILE_QUERY = '(max-width: 767.98px)';
|
||||
|
||||
/**
|
||||
* True when the viewport is narrower than the `md` breakpoint.
|
||||
*
|
||||
* This is a single-instance SPA (no SSR), so the initial state reads
|
||||
* `matchMedia` synchronously to avoid a desktop→mobile flash on first paint.
|
||||
* The `window`/`matchMedia` guards keep it safe under jsdom and any non-DOM
|
||||
* render path.
|
||||
*/
|
||||
export function useIsMobile(): boolean {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false;
|
||||
return window.matchMedia(MOBILE_QUERY).matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mq = window.matchMedia(MOBILE_QUERY);
|
||||
const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
|
||||
// Sync once in case the width changed between the initial render and effect.
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setIsMobile(mq.matches);
|
||||
mq.addEventListener('change', onChange);
|
||||
return () => mq.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
@@ -227,6 +227,12 @@
|
||||
/* Shape */
|
||||
--radius: 0.5rem;
|
||||
|
||||
/* Height of the mobile bottom tab bar's interactive area (excludes the
|
||||
safe-area inset, which is added where needed). Fixed-position overlays
|
||||
(toasts, the deploy pill) offset above it on mobile so they never sit
|
||||
behind the tab bar. */
|
||||
--sn-mobile-tabbar-h: 56px;
|
||||
|
||||
/* Motion timing */
|
||||
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
Reference in New Issue
Block a user