diff --git a/.gitignore b/.gitignore index efbf61ae..df753c8e 100644 --- a/.gitignore +++ b/.gitignore @@ -49,6 +49,7 @@ CLAUDE.md MANUAL_STEPS.md docs/superpowers/ .worktrees/ +.superpowers/ # Design system (local-only; authoritative reference for agents + user) frontend/DESIGN.md diff --git a/docs/docs.json b/docs/docs.json index e8bbe188..d9b33c9d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -95,6 +95,7 @@ "pages": [ "features/overview", "features/dashboard", + "features/sidebar", "features/stack-management", "features/editor", "features/resources", diff --git a/docs/features/sidebar.mdx b/docs/features/sidebar.mdx new file mode 100644 index 00000000..217d5587 --- /dev/null +++ b/docs/features/sidebar.mdx @@ -0,0 +1,54 @@ +--- +title: Stack Sidebar +description: Manage, group, and pin your stacks from the primary sidebar. +--- + +The stack sidebar is your cockpit for every stack on the active node. Stacks are grouped by label, your most-used stacks can be pinned to the top, and a live activity footer keeps you aware of what just happened. + + + Stack sidebar overview + + +## Layout + +From top to bottom: + +1. **Branding header** shows your Sencho build version. +2. **Node switcher** selects which Sencho instance you are managing. +3. **Create Stack** opens the new-stack dialog. The folder icon scans your compose directory. +4. **Search** filters stacks by name. Press ⌘K (or Ctrl+K) to focus it. +5. **Stack list** shows your stacks grouped by label. +6. **Activity footer** shows the most recent stack event. + +## Groups + +Stacks are grouped by label. Stacks with multiple labels appear in each matching group so you always see the full fleet membership per label. Stacks without labels appear in an **Unlabeled** group at the bottom. Click a group header to collapse or expand it; the collapsed state is remembered per node. + +## Pinning + +Right-click a stack and choose **Pin to top**. Pinned stacks sit in a dedicated **PINNED** group at the top of the list. Up to 10 stacks can be pinned per node; pinning an 11th evicts the oldest. + + + Pinned stacks appear in a PINNED group at top + + +## Context menu + +Right-click any stack (or open the kebab that appears on hover) for its context menu, grouped by purpose: + +- **Inspect**: view alerts, open the auto-heal sheet, check for image updates, open the running app. +- **Organize**: assign labels, pin or unpin the stack. +- **Lifecycle**: deploy, stop, restart, or update the stack. +- **Destructive**: delete the stack. + + + Grouped stack context menu + + +## Activity footer + +The footer shows the most recent stack lifecycle event within the last hour. Click it to jump to the global activity log. When nothing has happened recently, the footer reads **IDLE**. + + + Activity footer ticker + diff --git a/docs/images/sidebar/sidebar-activity.png b/docs/images/sidebar/sidebar-activity.png new file mode 100644 index 00000000..9b575379 Binary files /dev/null and b/docs/images/sidebar/sidebar-activity.png differ diff --git a/docs/images/sidebar/sidebar-context-menu.png b/docs/images/sidebar/sidebar-context-menu.png new file mode 100644 index 00000000..97e2dd20 Binary files /dev/null and b/docs/images/sidebar/sidebar-context-menu.png differ diff --git a/docs/images/sidebar/sidebar-overview.png b/docs/images/sidebar/sidebar-overview.png new file mode 100644 index 00000000..c995ef73 Binary files /dev/null and b/docs/images/sidebar/sidebar-overview.png differ diff --git a/docs/images/sidebar/sidebar-pinned.png b/docs/images/sidebar/sidebar-pinned.png new file mode 100644 index 00000000..ed72f305 Binary files /dev/null and b/docs/images/sidebar/sidebar-pinned.png differ diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index ad05d93f..69974c0c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useMemo } from 'react'; +import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; type Theme = 'light' | 'dark' | 'auto'; import Editor from '@monaco-editor/react'; @@ -17,26 +17,19 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, Di import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from './ui/alert-dialog'; import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from './ui/tabs'; import { springs } from '@/lib/motion'; -import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Download, Clock, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, MoreVertical, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Clock, Loader2, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; -import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; -import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; -import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; import { ScrollArea } from './ui/scroll-area'; import { Checkbox } from './ui/checkbox'; import { GitSourceFields, type ApplyMode } from './stack/GitSourceFields'; -import { Skeleton } from './ui/skeleton'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from './ui/dropdown-menu'; -import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger } from './ui/context-menu'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { TopBar } from './TopBar'; import { cn } from '@/lib/utils'; @@ -66,6 +59,11 @@ import { useAuth } from '@/context/AuthContext'; import { useLicense } from '@/context/LicenseContext'; import { useTrivyStatus } from '@/hooks/useTrivyStatus'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; +import { StackSidebar } from '@/components/sidebar/StackSidebar'; +import { usePinnedStacks } from '@/hooks/usePinnedStacks'; +import { useSidebarGroupCollapse } from '@/hooks/useSidebarGroupCollapse'; +import type { StackRowStatus } from '@/components/sidebar/StackRow'; +import type { StackMenuCtx } from '@/components/sidebar/sidebar-types'; interface ContainerInfo { Id: string; @@ -322,9 +320,10 @@ export default function EditorLayout() { const [stackPorts, setStackPorts] = useState>({}); const [labels, setLabels] = useState([]); const [stackLabelMap, setStackLabelMap] = useState>({}); - const [activeLabelFilters, setActiveLabelFilters] = useState>(new Set()); - const [bulkActionLabel, setBulkActionLabel] = useState(null); - const [bulkAction, setBulkAction] = useState(''); + // Bulk-action dialog is retained as a safety fallback; the label pill entry + // point that drove the setters was removed alongside the sidebar rewrite. + const [bulkActionLabel] = useState(null); + const [bulkAction] = useState(''); const [bulkActionOpen, setBulkActionOpen] = useState(false); const [bulkActionRunning, setBulkActionRunning] = useState(false); @@ -342,6 +341,7 @@ export default function EditorLayout() { // Notifications & Settings state const [notifications, setNotifications] = useState([]); + const [tickerConnected, setTickerConnected] = useState(false); const [settingsModalOpen, setSettingsModalOpen] = useState(false); const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); @@ -679,6 +679,7 @@ export default function EditorLayout() { ws?.close(); return; } + setTickerConnected(true); retryCount = 0; // Reset backoff on successful connect }; @@ -700,6 +701,7 @@ export default function EditorLayout() { }; ws.onclose = (event) => { + setTickerConnected(false); if (!isMounted) return; // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s max const delay = Math.min(1000 * Math.pow(2, retryCount), MAX_RETRY_DELAY_MS); @@ -1830,651 +1832,329 @@ export default function EditorLayout() { const stackName = selectedFile || ''; // Filter files based on search query - const filteredFiles = files.filter(file => { - if (!file.toLowerCase().includes(searchQuery.toLowerCase())) return false; - if (activeLabelFilters.size > 0) { - const fileLabels = stackLabelMap[file] || []; - if (!fileLabels.some(l => activeLabelFilters.has(l.id))) return false; - } - return true; - }); + const filteredFiles = files.filter(file => + file.toLowerCase().includes(searchQuery.toLowerCase()) + ); // Get display name for stack (now just returns the name as-is since no extension) const getDisplayName = (stackName: string) => { return stackName; }; + const { pinned, pin, unpin, isPinned, evictedOldest } = usePinnedStacks(activeNode?.id); + + useEffect(() => { + if (evictedOldest) toast.info('Pinned. Unpinned oldest (max 10).'); + }, [evictedOldest]); + + const { isCollapsed, toggle: toggleCollapse } = useSidebarGroupCollapse(activeNode?.id); + + const remoteResults = useMemo(() => { + return Object.entries(remoteStackResults).flatMap(([nodeIdStr, remoteFiles]) => { + const node = nodes.find(n => n.id === Number(nodeIdStr)); + if (!node || remoteFiles.length === 0) return []; + return [{ + nodeId: node.id, + nodeName: node.name, + files: remoteFiles.map(({ file, status }) => ({ file, status: status as StackRowStatus })), + }]; + }); + }, [remoteStackResults, nodes]); + + const buildMenuCtx = useCallback((file: string): StackMenuCtx => { + const stackName = file.replace(/\.(yml|yaml)$/, ''); + return { + stackStatus: (stackStatuses[file] ?? 'unknown') as 'running' | 'exited' | 'unknown', + hasPort: Boolean(stackPorts[file]), + isBusy: isStackBusy(file), + isPaid, + canDelete: can('stack:delete', 'stack', stackName), + isPinned: isPinned(file), + labels, + assignedLabelIds: (stackLabelMap[file] ?? []).map(l => l.id), + menuVisibility: getStackMenuVisibility(file), + openAlertSheet: () => openAlertSheet(file), + openAutoHeal: () => setAutoHealStackName(file), + checkUpdates: () => checkUpdatesForStack(), + openStackApp: () => openStackApp(file), + deploy: () => executeStackActionByFile(file, 'deploy', 'deploy'), + stop: () => executeStackActionByFile(file, 'stop', 'stop'), + restart: () => executeStackActionByFile(file, 'restart', 'restart'), + update: () => executeStackActionByFile(file, 'update', 'update'), + remove: () => { setStackToDelete(stackName); setDeleteDialogOpen(true); }, + pin: () => pin(file), + unpin: () => unpin(file), + toggleLabel: async (labelId: number) => { + const currentIds = (stackLabelMap[file] ?? []).map(l => l.id); + const assigned = currentIds.includes(labelId); + const newIds = assigned ? currentIds.filter(id => id !== labelId) : [...currentIds, labelId]; + const loadingId = toast.loading('Updating labels...'); + try { + const res = await apiFetch(`/stacks/${encodeURIComponent(file)}/labels`, { + method: 'PUT', + body: JSON.stringify({ labelIds: newIds }), + }); + if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error((data as { error?: string })?.error || 'Failed to update labels.'); } + refreshLabels(); + } catch (err: unknown) { + toast.error((err as Error)?.message || 'Failed to update labels.'); + } finally { + toast.dismiss(loadingId); + } + }, + openLabelManager: () => { setSettingsInitialSection('labels'); setSettingsModalOpen(true); }, + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + stackStatuses, stackPorts, isPaid, isPinned, labels, stackLabelMap, + pin, unpin, + ]); + + const createStackSlot = can('stack:create') ? ( + { + setCreateDialogOpen(o); + if (!o) { + setCreateMode('empty'); + resetCreateFromGitForm(); + resetCreateFromDockerRunForm(); + } + }}> + + + + + + Create New Stack + + Create a new stack: empty, cloned from a Git repository, or converted from a docker run command. + + + +
+ setCreateMode(v as 'empty' | 'git' | 'docker-run')}> + + + + + + Empty + + + + + + From Git + + + + + + From Docker Run + + + + + +
+ + {createMode === 'empty' && ( + <> +
+ + setNewStackName(e.target.value)} + /> +
+ + + + + )} + {createMode === 'git' && ( + <> + +
+
+ + setNewStackName(e.target.value)} + disabled={creatingFromGit} + /> +
+ + + +
+ setGitDeployNow(c === true)} + disabled={creatingFromGit} + /> + +
+
+
+ + + + + )} + {createMode === 'docker-run' && ( + <> + +
+
+ + setNewStackName(e.target.value)} + disabled={creatingFromDockerRun} + /> +
+
+ +