diff --git a/backend/src/__tests__/composePreview.test.ts b/backend/src/__tests__/composePreview.test.ts new file mode 100644 index 00000000..b614e9a7 --- /dev/null +++ b/backend/src/__tests__/composePreview.test.ts @@ -0,0 +1,86 @@ +/** + * Unit tests for parseComposePreview: the pure compose-to-preview parser behind + * the guided import scan. Covers port/volume/env normalization, the relative- + * volume warning (1:1 path rule), and the non-throwing error paths. + */ +import { describe, it, expect } from 'vitest'; +import { parseComposePreview } from '../helpers/composePreview'; + +describe('parseComposePreview', () => { + it('extracts services, short-syntax ports, volumes, and env_file', () => { + const yaml = ` +services: + web: + image: nginx:1.27 + ports: + - "8080:80" + - "53:53/udp" + volumes: + - ./data:/usr/share/nginx/html + env_file: web.env +`; + const result = parseComposePreview(yaml); + expect(result.parseError).toBeUndefined(); + expect(result.services).toHaveLength(1); + const web = result.services[0]; + expect(web.name).toBe('web'); + expect(web.image).toBe('nginx:1.27'); + expect(web.ports).toEqual(['8080->80', '53->53']); + expect(web.volumes).toEqual(['./data:/usr/share/nginx/html']); + expect(web.envFiles).toEqual(['web.env']); + // Relative bind source triggers the 1:1 path-rule warning. + expect(result.warnings.some((w) => w.includes('1:1 path rule'))).toBe(true); + }); + + it('normalizes long-syntax ports and ip-prefixed short ports', () => { + const yaml = ` +services: + api: + ports: + - target: 3001 + published: 2283 + - "127.0.0.1:9000:9000" + - "5000" +`; + const result = parseComposePreview(yaml); + expect(result.services[0].ports).toEqual(['2283->3001', '9000->9000', '5000']); + }); + + it('handles env_file as an array of strings and {path} objects', () => { + const yaml = ` +services: + app: + env_file: + - common.env + - path: secrets.env +`; + const result = parseComposePreview(yaml); + expect(result.services[0].envFiles).toEqual(['common.env', 'secrets.env']); + }); + + it('does not warn for named or absolute volumes', () => { + const yaml = ` +services: + db: + volumes: + - pgdata:/var/lib/postgresql/data + - /opt/host/conf:/etc/conf +volumes: + pgdata: +`; + const result = parseComposePreview(yaml); + expect(result.services[0].volumes).toEqual(['pgdata:/var/lib/postgresql/data', '/opt/host/conf:/etc/conf']); + expect(result.warnings).toHaveLength(0); + }); + + it('reports a parseError for invalid YAML without throwing', () => { + const result = parseComposePreview('services: [unclosed'); + expect(result.parseError).toBeDefined(); + expect(result.services).toHaveLength(0); + }); + + it('reports a parseError when there are no services', () => { + const result = parseComposePreview("name: just-a-file\nversion: '3'"); + expect(result.parseError).toBe('No services found in this file.'); + }); +}); diff --git a/backend/src/__tests__/import-scan.test.ts b/backend/src/__tests__/import-scan.test.ts new file mode 100644 index 00000000..31c5e3a3 --- /dev/null +++ b/backend/src/__tests__/import-scan.test.ts @@ -0,0 +1,151 @@ +/** + * Tests for FileSystemService.findImportCandidates: the read-only compose-dir + * walk behind the guided import scan. Uses a real temp directory so the nesting + * and placement-status logic is exercised against the actual filesystem. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; + +const { tmpRoot } = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodeOs = require('os'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodePath = require('path'); + // eslint-disable-next-line @typescript-eslint/no-require-imports + const nodeFs = require('fs'); + const tmpRoot: string = nodeFs.mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'sencho-import-')); + return { tmpRoot }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getComposeDir: () => tmpRoot, + getDefaultNodeId: () => 1, + }), + }, +})); + +import { FileSystemService } from '../services/FileSystemService'; + +const COMPOSE = 'services:\n app:\n image: nginx:1.27\n'; + +describe('FileSystemService.findImportCandidates', () => { + beforeAll(() => { + // Already a stack: top-level subdir with a compose file. + fs.mkdirSync(path.join(tmpRoot, 'immich'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'immich', 'compose.yaml'), COMPOSE); + // Loose at the root: will not auto-register. + fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE); + // One directory too deep: apps/ has no compose, apps/vault/ does. + fs.mkdirSync(path.join(tmpRoot, 'apps', 'vault'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'vault', 'compose.yaml'), COMPOSE); + // A directory with no compose file at all: ignored. + fs.mkdirSync(path.join(tmpRoot, 'notes'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'notes', 'README.md'), '# notes'); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('classifies listed, loose-root, and nested compose files and ignores the rest', async () => { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + + const listed = candidates.find((c) => c.status === 'listed'); + expect(listed).toMatchObject({ name: 'immich', composeFile: 'compose.yaml', location: 'immich/compose.yaml' }); + expect(listed?.content).toContain('services:'); + + const loose = candidates.find((c) => c.status === 'loose-root'); + expect(loose).toMatchObject({ name: '', composeFile: 'docker-compose.yml', location: 'docker-compose.yml' }); + + const nested = candidates.find((c) => c.status === 'nested'); + expect(nested).toMatchObject({ name: 'vault', composeFile: 'compose.yaml', location: 'apps/vault/compose.yaml' }); + + // The directory with only a README produced no candidate. + expect(candidates.some((c) => c.name === 'notes')).toBe(false); + expect(candidates).toHaveLength(3); + }); + + it('flags oversized compose files instead of reading them', async () => { + const bigDir = path.join(tmpRoot, 'big'); + fs.mkdirSync(bigDir, { recursive: true }); + fs.writeFileSync(path.join(bigDir, 'compose.yaml'), 'x'.repeat(1_048_577)); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const big = candidates.find((c) => c.name === 'big'); + expect(big?.oversized).toBe(true); + expect(big?.content).toBeNull(); + } finally { + fs.rmSync(bigDir, { recursive: true, force: true }); + } + }); + + it('skips a non-regular file (a directory named compose.yaml) without reading it', async () => { + // A directory named compose.yaml passes the access() probe; the isFile() + // guard means it is reported as unreadable rather than read as content. + const weirdDir = path.join(tmpRoot, 'weird'); + fs.mkdirSync(path.join(weirdDir, 'compose.yaml'), { recursive: true }); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const weird = candidates.find((c) => c.name === 'weird'); + expect(weird).toBeDefined(); + expect(weird?.content).toBeNull(); + expect(weird?.oversized).toBe(false); + } finally { + fs.rmSync(weirdDir, { recursive: true, force: true }); + } + }); + + it('does not read a compose file that symlinks outside the compose directory', async () => { + // Sibling of the temp compose root, i.e. outside the base dir. + const outside = path.join(path.dirname(tmpRoot), `sencho-outside-${Date.now()}.yaml`); + fs.writeFileSync(outside, COMPOSE); + const escDir = path.join(tmpRoot, 'escape'); + fs.mkdirSync(escDir, { recursive: true }); + let linked = true; + try { + fs.symlinkSync(outside, path.join(escDir, 'compose.yaml')); + } catch { + // Creating symlinks needs privilege on some platforms; the assertion below + // runs for real on the Linux CI runners. + linked = false; + } + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + try { + if (!linked) return; + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const esc = candidates.find((c) => c.name === 'escape'); + expect(esc).toBeDefined(); + expect(esc?.content).toBeNull(); + expect(warnSpy).toHaveBeenCalled(); + } finally { + warnSpy.mockRestore(); + fs.rmSync(escDir, { recursive: true, force: true }); + fs.rmSync(outside, { force: true }); + } + }); + + it('keeps the top-level listing and does not also descend into it', async () => { + // A directory that is already a stack and also has a compose file one level + // deeper yields only the listed entry, never the nested child. + const dir = path.join(tmpRoot, 'both'); + fs.mkdirSync(path.join(dir, 'sub'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE); + fs.writeFileSync(path.join(dir, 'sub', 'compose.yaml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const fromBoth = candidates.filter((c) => c.location.startsWith('both/')); + expect(fromBoth).toHaveLength(1); + expect(fromBoth[0]).toMatchObject({ name: 'both', status: 'listed', location: 'both/compose.yaml' }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('truncates at maxCandidates', async () => { + const candidates = await FileSystemService.getInstance().findImportCandidates(2); + expect(candidates).toHaveLength(2); + }); +}); diff --git a/backend/src/helpers/composePreview.ts b/backend/src/helpers/composePreview.ts new file mode 100644 index 00000000..e42b7fb7 --- /dev/null +++ b/backend/src/helpers/composePreview.ts @@ -0,0 +1,152 @@ +import YAML from 'yaml'; + +// Refuse to parse anything beyond this bound so a malformed (or adversarial) +// compose file cannot exhaust heap while building an import preview. Mirrors the +// cap the stacks router applies for env_file resolution. +const MAX_COMPOSE_PARSE_BYTES = 1_048_576; // 1 MiB + +export interface ServicePreview { + name: string; + image?: string; + /** Port mappings normalized to "host->container" (container-only when unpublished). */ + ports: string[]; + /** Bind/named volumes normalized to "source:target" (target-only when anonymous). */ + volumes: string[]; + /** env_file paths exactly as declared in the compose file. */ + envFiles: string[]; +} + +export interface ComposePreview { + services: ServicePreview[]; + warnings: string[]; + /** Set when the file could not be turned into a usable preview; services is then empty. */ + parseError?: string; +} + +function asString(value: unknown): string | undefined { + if (typeof value === 'string') return value; + if (typeof value === 'number') return String(value); + return undefined; +} + +function normalizePort(entry: unknown): string | null { + // Short syntax: "8080:80", "8080:80/tcp", "127.0.0.1:8080:80", "80", or a bare number. + const short = asString(entry); + if (short !== undefined) { + const [hostPort, containerPort] = splitShortPort(short); + if (hostPort && containerPort) return `${hostPort}->${containerPort}`; + if (containerPort) return containerPort; // container-only + return hostPort ?? short; + } + // Long syntax: { target, published?, protocol? }. + if (entry && typeof entry === 'object') { + const obj = entry as Record; + const target = asString(obj.target); + const published = asString(obj.published); + if (target) return published ? `${published}->${target}` : target; + } + return null; +} + +function splitShortPort(value: string): [string | null, string | null] { + const spec = value.split('/')[0]; // drop "/tcp" | "/udp" + const parts = spec.split(':'); + if (parts.length >= 3) return [parts[1], parts[2]]; // ip:host:container + if (parts.length === 2) return [parts[0], parts[1]]; // host:container + return [null, parts[0]]; // container-only +} + +function isRelativeSource(source: string): boolean { + return source.startsWith('./') || source.startsWith('../'); +} + +function normalizeVolume(entry: unknown, relativeFlag: { hit: boolean }): string | null { + // Short syntax: "source:target", "source:target:ro", or "/anon-target". + const short = asString(entry); + if (short !== undefined) { + const parts = short.split(':'); + if (parts.length >= 2) { + const source = parts[0]; + if (isRelativeSource(source)) relativeFlag.hit = true; + return `${source}:${parts[1]}`; + } + return short; // anonymous volume (target only) + } + // Long syntax: { type, source?, target }. + if (entry && typeof entry === 'object') { + const obj = entry as Record; + const source = asString(obj.source); + const target = asString(obj.target); + if (target) { + if (source && isRelativeSource(source)) relativeFlag.hit = true; + return source ? `${source}:${target}` : target; + } + } + return null; +} + +function collectEnvFiles(envFile: unknown): string[] { + if (typeof envFile === 'string') return [envFile]; + if (Array.isArray(envFile)) { + const out: string[] = []; + for (const entry of envFile) { + const p = typeof entry === 'string' ? entry : asString((entry as Record)?.path); + if (p) out.push(p); + } + return out; + } + return []; +} + +/** + * Parse a compose file's text into a dry preview of its services, ports, + * volumes, and env files. Never throws: parse failures and non-compose files + * are reported through `parseError`. Used by the guided import scan to show + * the user what a detected file contains before they bring it in. + */ +export function parseComposePreview(content: string): ComposePreview { + if (content.length > MAX_COMPOSE_PARSE_BYTES) { + return { services: [], warnings: [], parseError: 'Compose file is too large to preview.' }; + } + + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch (error) { + return { services: [], warnings: [], parseError: `Could not parse compose file: ${(error as Error).message}` }; + } + + const services = (parsed as { services?: unknown })?.services; + if (!services || typeof services !== 'object') { + return { services: [], warnings: [], parseError: 'No services found in this file.' }; + } + + const relativeFlag = { hit: false }; + const previews: ServicePreview[] = []; + + for (const [name, raw] of Object.entries(services as Record)) { + const svc = (raw ?? {}) as Record; + const ports = Array.isArray(svc.ports) + ? svc.ports.map((p) => normalizePort(p)).filter((p): p is string => p !== null) + : []; + const volumes = Array.isArray(svc.volumes) + ? svc.volumes.map((v) => normalizeVolume(v, relativeFlag)).filter((v): v is string => v !== null) + : []; + previews.push({ + name, + image: asString(svc.image), + ports, + volumes, + envFiles: collectEnvFiles(svc.env_file), + }); + } + + const warnings: string[] = []; + if (relativeFlag.hit) { + warnings.push( + 'Uses relative volume paths. For these to resolve, your host mount path must match the container compose directory (the 1:1 path rule).', + ); + } + + return { services: previews, warnings }; +} diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 5388e075..fc577ca7 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -23,6 +23,7 @@ import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { sendGitSourceError } from '../utils/gitSourceHttp'; import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate'; +import { parseComposePreview, type ComposePreview } from '../helpers/composePreview'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { STACK_STATUSES_CACHE_TTL_MS } from '../helpers/constants'; import { getTerminalWs, DEPLOY_SESSION_HEADER } from '../websocket/generic'; @@ -255,6 +256,41 @@ stacksRouter.get('/statuses', async (req: Request, res: Response) => { } }); +// Read-only scan of the compose directory for the guided first-import flow. +// Surfaces compose files found on disk (loose at the root, already a stack, or +// one directory too deep) with a dry preview so a new user can land their first +// stack without reading the docs first. Performs no writes. +stacksRouter.get('/import/scan', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; + try { + const fsSvc = FileSystemService.getInstance(req.nodeId); + const raw = await fsSvc.findImportCandidates(); + const candidates = raw.map((c) => { + let preview: ComposePreview; + if (c.oversized) { + preview = { services: [], warnings: [], parseError: 'Compose file is too large to preview.' }; + } else if (c.content !== null) { + preview = parseComposePreview(c.content); + } else { + preview = { services: [], warnings: [], parseError: 'Could not read compose file.' }; + } + return { + name: c.name, + composeFile: c.composeFile, + location: c.location, + status: c.status, + services: preview.services, + warnings: preview.warnings, + parseError: preview.parseError, + }; + }); + res.json({ composeDir: fsSvc.getBaseDir(), candidates }); + } catch (error) { + console.error('Failed to scan compose directory:', error); + res.status(500).json({ error: 'Failed to scan compose directory' }); + } +}); + type BulkLifecycleAction = 'start' | 'stop' | 'restart' | 'update'; const VALID_BULK_ACTIONS: ReadonlySet = new Set(['start', 'stop', 'restart', 'update']); const BULK_PARALLELISM = 4; diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 09120d64..7915df16 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -2,6 +2,7 @@ import path from 'path'; import os from 'os'; import crypto from 'crypto'; import { promises as fsPromises, createReadStream } from 'fs'; +import type { Dirent } from 'fs'; import type { Readable } from 'stream'; import { NodeRegistry } from './NodeRegistry'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; @@ -36,6 +37,29 @@ const PROTECTED_STACK_FILES = new Set([ '.env', ]); +// Compose filenames Sencho recognizes, in resolution-priority order. Mirrors the +// list FileSystemService uses elsewhere; named here for the import scan. +const IMPORT_COMPOSE_FILENAMES = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'] as const; +const IMPORT_COMPOSE_FILENAME_SET = new Set(IMPORT_COMPOSE_FILENAMES); +// Skip reading compose files larger than this into the import preview. +const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB + +/** + * A compose file discovered on disk during the guided import scan. `status` + * records placement: a top-level subdirectory with a compose file is already a + * stack (`listed`); a compose file loose at the compose-dir root (`loose-root`) + * or one directory too deep (`nested`) will not auto-register and needs the user + * to move it. `content` is null when the file was oversized or unreadable. + */ +export interface ImportCandidateRaw { + name: string; + composeFile: string; + location: string; + status: 'listed' | 'loose-root' | 'nested'; + content: string | null; + oversized: boolean; +} + // Strips at most one trailing slash. The upstream validator // (isValidRelativeStackPath) rejects any '//' sequence, so a string reaching // this helper can carry at most one trailing slash, and a single slice is @@ -423,6 +447,123 @@ export class FileSystemService { return this.baseDir; } + private async firstComposeFilename(dir: string): Promise { + this.assertWithinBase(dir); + for (const file of IMPORT_COMPOSE_FILENAMES) { + try { + await fsPromises.access(path.join(dir, file)); + return file; + } catch { + // continue + } + } + return null; + } + + private async readComposeCandidate(filePath: string): Promise<{ content: string | null; oversized: boolean }> { + this.assertWithinBase(filePath); + let fh: import('fs/promises').FileHandle | null = null; + try { + // Resolve symlinks and confirm the real target is still inside the compose + // directory before reading (matches resolveSafeStackPath). A symlinked + // compose file or symlinked parent must not expose a file outside the + // compose dir through the preview. + const realPath = await fsPromises.realpath(filePath); + if (!isPathWithinBase(realPath, this.baseDir)) { + console.warn('[FileSystemService] Skipping import candidate that escapes the compose directory:', sanitizeForLog(filePath)); + return { content: null, oversized: false }; + } + // Open the canonical path once and stat/read on the same descriptor so the + // size check and the read observe the same inode (no time-of-check/use race). + fh = await fsPromises.open(realPath, 'r'); + const stat = await fh.stat(); + if (!stat.isFile()) return { content: null, oversized: false }; + if (stat.size > IMPORT_MAX_PREVIEW_BYTES) return { content: null, oversized: true }; + // Read at most stat.size (<= cap) bytes so a file that grows after the + // stat cannot push this buffer past the cap. + const buffer = Buffer.alloc(stat.size); + const { bytesRead } = await fh.read(buffer, 0, stat.size, 0); + return { content: buffer.subarray(0, bytesRead).toString('utf-8'), oversized: false }; + } catch (error) { + // The file existed at probe time, so a failure here (permission, I/O) is + // worth a server-side line even though the scan degrades gracefully and the + // route reports it to the user. + console.warn('[FileSystemService] Failed to read import candidate:', sanitizeForLog((error as Error)?.message ?? String(error))); + return { content: null, oversized: false }; + } finally { + if (fh) await fh.close(); + } + } + + /** + * Scan the compose directory for compose files to surface in the guided import + * flow: loose files at the root, top-level stack subdirectories, and compose + * files one directory too deep. Read-only. Bounded by `maxCandidates` and by a + * single level of nesting so a deep tree cannot make this walk unbounded. + */ + async findImportCandidates(maxCandidates = 100): Promise { + const candidates: ImportCandidateRaw[] = []; + let entries: Dirent[]; + try { + entries = await fsPromises.readdir(this.baseDir, { withFileTypes: true }); + } catch (error) { + // The compose dir itself is unreadable (missing, permissions). The scan + // degrades to an empty list, so log it rather than report "no files found" + // for what is really an access failure. + console.warn('[FileSystemService] Failed to scan compose directory for import:', sanitizeForLog((error as Error)?.message ?? String(error))); + return candidates; + } + + for (const entry of entries) { + if (candidates.length >= maxCandidates) break; + if (!entry.name || typeof entry.name !== 'string') continue; + + if (entry.isFile()) { + if (IMPORT_COMPOSE_FILENAME_SET.has(entry.name)) { + const loaded = await this.readComposeCandidate(path.join(this.baseDir, entry.name)); + candidates.push({ name: '', composeFile: entry.name, location: entry.name, status: 'loose-root', ...loaded }); + } + continue; + } + if (!entry.isDirectory()) continue; + + const dir = path.join(this.baseDir, entry.name); + const topCompose = await this.firstComposeFilename(dir); + if (topCompose) { + const loaded = await this.readComposeCandidate(path.join(dir, topCompose)); + candidates.push({ name: entry.name, composeFile: topCompose, location: `${entry.name}/${topCompose}`, status: 'listed', ...loaded }); + continue; + } + + // No compose at the top level: peek exactly one level deeper. + let children: Dirent[]; + try { + children = await fsPromises.readdir(dir, { withFileTypes: true }); + } catch (error) { + console.warn('[FileSystemService] Failed to read subdirectory during import scan:', sanitizeForLog((error as Error)?.message ?? String(error))); + continue; + } + for (const child of children) { + if (candidates.length >= maxCandidates) break; + if (!child.isDirectory() || !child.name || typeof child.name !== 'string') continue; + const childDir = path.join(dir, child.name); + const childCompose = await this.firstComposeFilename(childDir); + if (childCompose) { + const loaded = await this.readComposeCandidate(path.join(childDir, childCompose)); + candidates.push({ + name: child.name, + composeFile: childCompose, + location: `${entry.name}/${child.name}/${childCompose}`, + status: 'nested', + ...loaded, + }); + } + } + } + + return candidates; + } + async migrateFlatToDirectory(): Promise { try { try { diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 6e4fe7b7..c08645e5 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -13,10 +13,13 @@ A **stack** in Sencho is a Docker Compose project: a directory inside your `COMP Click **Create Stack** in the left sidebar to open the **New stack** dialog. Pick a source from the tabs at the top: +- **Import**: scan the compose directory for compose files you already have on disk and preview what they contain before you bring them in. See [Import existing files](#import-existing-files) below. - **Empty**: start with a minimal `compose.yaml` skeleton. The default service uses `nginx:latest` with the host port binding commented out, so a fresh deploy never collides with an existing service on the host. Uncomment and adjust the `ports:` block when you're ready to expose the container. - **From Git**: clone a Git repository so its compose file becomes the stack source. Future webhook pulls keep it in sync. See [Git Sources](/features/git-sources) for the full sync flow. - **From Docker Run**: paste a `docker run` command and let Sencho convert it to compose YAML. +When you have no stacks yet, the sidebar shows a short prompt with **Import existing** and **New stack** buttons that open this dialog on the matching tab. + New stack dialog on the Empty tab with a stack name field @@ -28,6 +31,19 @@ Click **Create Stack** in the left sidebar to open the **New stack** dialog. Pic Sencho creates a new directory inside `COMPOSE_DIR` with the chosen seed file. You land in the editor for the new stack and can deploy it from there. +### Import existing files + +If you already have compose files on disk, the **Import** tab helps you land your first stack without reading the rest of this page. It scans your compose directory and lists every compose file it finds, with a dry preview of each file's services, ports, volumes, and env files. The scan is read only: it never moves, writes, or changes any of your files. + +Each result shows where the file sits: + +- **In sidebar**: the file is in its own subfolder inside the compose directory, so it is already a stack. Expand it to preview its services, or open it directly in the sidebar. +- **Needs move**: the file is loose at the root of the compose directory, or one folder too deep, so it will not show up as a stack on its own. Sencho shows the exact path to move it to (its own subfolder inside the compose directory) and a **Rescan** button. Move the file there, click **Rescan**, and it appears. + +The top of the panel shows the directory Sencho is scanning and a reminder of the path rule: each stack lives in its own subfolder, and the host mount path must match the path inside the container so relative volume paths resolve. See [Configuration](/getting-started/configuration) for the full explanation. + +If a preview shows a relative volume path (for example `./data:/app/data`), Sencho flags it so you can double check your mount before deploying. + ### From Git The **From Git** tab clones a public or private repository and treats its compose file as the stack's source of truth. @@ -354,7 +370,7 @@ Items are disabled while another operation is in progress on that stack. ## 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. +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. For a guided version that previews each file and points out anything placed where it will not register, use the [Import tab](#import-existing-files) of the **Create Stack** dialog. Sidebar action row with Create Stack button, bulk mode toggle, and scan stacks folder icon button diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 6d885ba7..86c3363c 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -1,11 +1,11 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { Plus } from 'lucide-react'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; import { ViewRouter } from './EditorLayout/ViewRouter'; -import { CreateStackDialog } from './EditorLayout/CreateStackDialog'; +import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog'; import { EditorView } from './EditorLayout/EditorView'; import { ShellOverlays } from './EditorLayout/ShellOverlays'; import { useEditorViewState } from './EditorLayout/hooks/useEditorViewState'; @@ -110,6 +110,14 @@ export default function EditorLayout() { createDialogOpen, setCreateDialogOpen, } = overlayState; + // Which mode the create dialog opens on. The toolbar Create button opens on + // 'empty'; the zero-stacks empty state opens on 'import'. + const [createDialogInitialMode, setCreateDialogInitialMode] = useState('empty'); + const openCreateDialog = useCallback((mode: CreateMode) => { + setCreateDialogInitialMode(mode); + setCreateDialogOpen(true); + }, [setCreateDialogOpen]); + const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); // Use a ref to break the circular dependency: @@ -311,7 +319,7 @@ export default function EditorLayout() { + } + primary={ + + } + /> + + ); +} + +function CandidateCard({ + candidate, + composeDir, + expanded, + onToggle, + onOpenStack, +}: { + candidate: ImportCandidate; + composeDir: string; + expanded: boolean; + onToggle: () => void; + onOpenStack: (name: string) => void; +}) { + const { name, composeFile, location, status, services, warnings, parseError } = candidate; + const displayName = name || ''; + const target = joinPath(composeDir, displayName, composeFile); + + return ( +
+ + + {expanded && ( +
+ {status === 'listed' ? ( + + ) : ( +
+ +
+ Not in its own subfolder, so it will not show as a stack. Move it to{' '} + {target}, then rescan. +
+
+ )} + + {parseError ? ( +

{parseError}

+ ) : ( + + )} + + {warnings.map((w) => ( +

{w}

+ ))} +
+ )} +
+ ); +} + +function StatusBadge({ status }: { status: ImportCandidate['status'] }) { + if (status === 'listed') { + return ( + + + In sidebar + + ); + } + return ( + + Needs move + + ); +} + +function ServiceList({ services }: { services: ServicePreview[] }) { + if (services.length === 0) { + return

No services to preview.

; + } + return ( +
+ {services.map((svc) => ( +
+
+ {svc.name} + {svc.image ? · {svc.image} : null} +
+ + + +
+ ))} +
+ ); +} + +function MetaRow({ label, values }: { label: string; values: string[] }) { + if (values.length === 0) return null; + return ( +
+ {label} + {values.join(' · ')} +
+ ); +} diff --git a/frontend/src/components/sidebar/EmptyStackState.tsx b/frontend/src/components/sidebar/EmptyStackState.tsx new file mode 100644 index 00000000..5d31f616 --- /dev/null +++ b/frontend/src/components/sidebar/EmptyStackState.tsx @@ -0,0 +1,32 @@ +import { FolderSearch, Plus, Layers } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +export interface EmptyStackStateProps { + // Open the create dialog on a given starting mode. Provided only when the + // user has permission to create stacks; otherwise the buttons are hidden. + onOpenCreate?: (mode: 'import' | 'empty') => void; +} + +export function EmptyStackState({ onOpenCreate }: EmptyStackStateProps) { + return ( +
+ +

No stacks yet

+

+ Import compose files you already have, or create one from scratch. +

+ {onOpenCreate && ( +
+ + +
+ )} +
+ ); +} diff --git a/frontend/src/components/sidebar/StackList.tsx b/frontend/src/components/sidebar/StackList.tsx index a415254e..2614e65f 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -10,7 +10,8 @@ import type { StackRowStatus } from './stack-status-utils'; import { StackGroup } from './StackGroup'; import { StackContextMenu } from './StackContextMenu'; import { StackKebabMenu } from './StackKebabMenu'; -import type { StackMenuCtx } from './sidebar-types'; +import { EmptyStackState } from './EmptyStackState'; +import type { StackMenuCtx, FilterChip } from './sidebar-types'; interface RemoteNodeResult { nodeId: number; @@ -45,6 +46,13 @@ export interface StackListProps { remoteLoading: boolean; remoteFailedNodes: RemoteSearchFailure[]; onSelectRemoteFile: (nodeId: number, file: string) => void; + // Active filter chip. The first-run empty state only renders when no chip is + // applied ('all'), so a filter that matches nothing is not mistaken for "no + // stacks yet". + filterChip: FilterChip; + // Open the create dialog on a starting mode. Present only when the user can + // create stacks; drives the zero-stacks empty state. + onOpenCreate?: (mode: 'import' | 'empty') => void; } interface BuiltGroup { @@ -115,6 +123,7 @@ export function StackList(props: StackListProps & StackListBulkProps) { isBusy, getDisplayName, onSelectFile, buildMenuCtx, bulkMode, selectedFiles, onToggleSelect, remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile, + filterChip, onOpenCreate, } = props; const [failedNodesExpanded, setFailedNodesExpanded] = useState(false); @@ -136,6 +145,13 @@ export function StackList(props: StackListProps & StackListBulkProps) { ); } + // First-run prompt only when the node has no stacks at all: no search text and + // no active filter chip, so a filter that happens to match nothing does not + // masquerade as an empty fleet. + if (files.length === 0 && !searchQuery.trim() && filterChip === 'all') { + return ; + } + return ( {groups.map(g => (