diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d38cd5..4d905150 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* **stack-management:** scan stacks folder button to detect and import manually-placed compose files + ### Docs * **quickstart:** fix Cyrillic character in Docker image reference and correct registry from GHCR to Docker Hub (`saelix/sencho`) diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 86a8e8e4..51c4b097 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -75,6 +75,24 @@ Right-click or use the **⋮** button on any stack in the sidebar to access: - **Update** - pull latest images and recreate containers - **Delete** - stop and remove the stack (admin only) +## Scanning for stacks + +If you place Docker Compose files directly into the stacks directory (for example, via `scp`, a file manager, or the command line), you can import them without going through the full "Create Stack" flow. + +Click the **folder-search icon** next to the "Create Stack" button in the sidebar. Sencho scans the configured stacks directory and reports what it finds: + + + Scan stacks folder button in the sidebar + + +- **New stacks detected** — a success notification lists the newly discovered stack names, and they appear immediately in the sidebar +- **Stacks removed from disk** — an informational notification lists stacks that are no longer present in the directory +- **No changes** — a notification confirms no new stacks were found + + + Any subdirectory inside your `COMPOSE_DIR` that contains a `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml` file is recognized as a valid stack. + + ## Converting a `docker run` command If you have an existing `docker run` command and want to turn it into a Compose stack, go to the **Home** tab and paste it into the "Convert Docker Run to Compose" field. Sencho converts it to YAML that you can save as a new stack. diff --git a/docs/images/stack-scanning/scan-button-sidebar.png b/docs/images/stack-scanning/scan-button-sidebar.png new file mode 100644 index 00000000..b2e12a68 Binary files /dev/null and b/docs/images/stack-scanning/scan-button-sidebar.png differ diff --git a/docs/images/stack-scanning/scan-no-new-stacks.png b/docs/images/stack-scanning/scan-no-new-stacks.png new file mode 100644 index 00000000..f25e46ee Binary files /dev/null and b/docs/images/stack-scanning/scan-no-new-stacks.png differ diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index 6fe5d7d6..899d6b48 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -159,6 +159,28 @@ DELETE FROM global_settings WHERE key IN ('auth_username', 'auth_password_hash', --- +## Scan stacks folder doesn't find my stacks + +**Symptom:** You placed Docker Compose files in the stacks directory and clicked the scan button, but "No new stacks found" appears. + +**Checks in order:** + +1. **Is the compose file in a subdirectory?** Sencho only discovers stacks inside subdirectories of `COMPOSE_DIR`. A loose `compose.yaml` sitting directly in the root of `COMPOSE_DIR` is ignored — each stack must be in its own folder (e.g. `COMPOSE_DIR/my-app/compose.yaml`). + +2. **Is the compose file named correctly?** Sencho recognizes these filenames only: + - `compose.yaml` + - `compose.yml` + - `docker-compose.yaml` + - `docker-compose.yml` + + Other names (e.g. `docker-compose.prod.yaml`, `stack.yaml`) are not detected. + +3. **Is the directory empty or missing a compose file?** A subdirectory that exists but contains no recognized compose file will not appear. Add a valid compose file to the directory. + +4. **Is the stack already tracked?** Stacks that already appear in the sidebar are not reported again. The scan only reports *changes* since the last time the list was loaded. + +--- + ## Checking the health endpoint Sencho exposes a health endpoint for monitoring and container health checks: diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 860141ea..5b6256ca 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -18,7 +18,7 @@ import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { apiFetch, fetchForNode } from '@/lib/api'; @@ -116,6 +116,7 @@ export default function EditorLayout() { const [stackToDelete, setStackToDelete] = useState(null); const [isLoading, setIsLoading] = useState(false); const [loadingAction, setLoadingAction] = useState(null); + const [isScanning, setIsScanning] = useState(false); const [isFileLoading, setIsFileLoading] = useState(false); const [backupInfo, setBackupInfo] = useState<{ exists: boolean; timestamp: number | null }>({ exists: false, timestamp: null }); const [theme, setTheme] = useState(() => { @@ -239,13 +240,13 @@ export default function EditorLayout() { return () => cancelAnimationFrame(id); }, [activeTab]); - const refreshStacks = async (background = false) => { + const refreshStacks = async (background = false): Promise => { if (!background) setIsLoading(true); try { const res = await apiFetch('/stacks'); if (!res.ok) { setFiles([]); - return; + return []; } const data = await res.json(); const fileList: string[] = Array.isArray(data) ? data : []; @@ -267,14 +268,43 @@ export default function EditorLayout() { } } setStackStatuses(statuses); + return fileList; } catch (error) { console.error('Failed to refresh stacks:', error); setFiles([]); + return []; } finally { setIsLoading(false); } }; + const handleScanStacks = async () => { + if (isScanning) return; + setIsScanning(true); + const previousStacks = [...files]; + try { + const currentStacks = await refreshStacks(); + const added = currentStacks.filter(s => !previousStacks.includes(s)); + const removed = previousStacks.filter(s => !currentStacks.includes(s)); + + if (added.length > 0) { + toast.success(`Found ${added.length} new stack${added.length !== 1 ? 's' : ''}: ${added.join(', ')}`); + } + if (removed.length > 0) { + toast.info(`${removed.length} stack${removed.length !== 1 ? 's' : ''} no longer detected: ${removed.join(', ')}`); + } + if (added.length === 0 && removed.length === 0) { + toast.info('No new stacks found.'); + } + } catch (error: unknown) { + const err = error as Record; + const data = err?.data as Record | undefined; + toast.error((err?.message as string) || (err?.error as string) || (data?.error as string) || 'Something went wrong.'); + } finally { + setIsScanning(false); + } + }; + // Notification WS push - subscribe to local real-time alerts. // Initial history load is handled by the [nodes] effect below. useEffect(() => { @@ -1163,11 +1193,11 @@ export default function EditorLayout() { )} - {/* Create Stack Button */} - {can('stack:create') &&
+ {/* Create Stack & Scan Buttons */} + {can('stack:create') &&
- @@ -1190,6 +1220,26 @@ export default function EditorLayout() { + + + + + + +

Scan stacks folder

+
+
+
} {/* Search Input & Stack List */}