diff --git a/backend/src/__tests__/compose-discovery.test.ts b/backend/src/__tests__/compose-discovery.test.ts new file mode 100644 index 00000000..437254d8 --- /dev/null +++ b/backend/src/__tests__/compose-discovery.test.ts @@ -0,0 +1,80 @@ +/** + * Tests for ComposeDiscoveryService.probeComposeDiscovery and + * FileSystemService.countImportCandidates. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import { probeComposeDiscovery } from '../services/ComposeDiscoveryService'; +import { FileSystemService } from '../services/FileSystemService'; + +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-discovery-')); + return { tmpRoot }; +}); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getComposeDir: () => tmpRoot, + getDefaultNodeId: () => 1, + }), + }, +})); + +const COMPOSE = 'services:\n app:\n image: nginx:1.27\n'; + +describe('probeComposeDiscovery', () => { + beforeAll(() => { + fs.mkdirSync(path.join(tmpRoot, 'existing'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'existing', 'compose.yaml'), COMPOSE); + fs.writeFileSync(path.join(tmpRoot, 'docker-compose.yml'), COMPOSE); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it('returns readable discovery with stack and adopt counts', async () => { + const probe = await probeComposeDiscovery(1); + expect(probe.readable).toBe(true); + if (!probe.readable) return; + expect(probe.composeDir).toBe(tmpRoot); + expect(probe.discovery.stackCount).toBe(1); + expect(probe.discovery.adoptCandidateCount).toBe(1); + expect(probe.discovery.adoptCandidatesTruncated).toBe(false); + }); + +}); + +describe('FileSystemService.countImportCandidates', () => { + it('matches findImportCandidates length when under the cap', async () => { + const listed = await FileSystemService.getInstance().findImportCandidates(); + const counted = await FileSystemService.getInstance().countImportCandidates(100); + expect(counted.count).toBe(listed.length); + expect(counted.truncated).toBe(false); + }); + + it('sets truncated only when more than maxCandidates exist', async () => { + const wrap = path.join(tmpRoot, 'trunc-wrap'); + fs.mkdirSync(wrap, { recursive: true }); + try { + for (let i = 0; i < 101; i++) { + const dir = path.join(wrap, `c${i}`); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE); + } + const at100 = await FileSystemService.getInstance().countImportCandidates(100); + expect(at100.count).toBe(100); + expect(at100.truncated).toBe(true); + } finally { + fs.rmSync(wrap, { recursive: true, force: true }); + } + }); +}); diff --git a/backend/src/__tests__/diagnostics-route.test.ts b/backend/src/__tests__/diagnostics-route.test.ts index 2da73684..a8ed0af3 100644 --- a/backend/src/__tests__/diagnostics-route.test.ts +++ b/backend/src/__tests__/diagnostics-route.test.ts @@ -87,4 +87,17 @@ describe('GET /api/diagnostics/environment', () => { expect(typeof c.detail).toBe('string'); } }); + + it('still returns 200 when discovery probe succeeds on a readable compose dir', async () => { + const res = await request(app).get('/api/diagnostics/environment').set('Authorization', adminAuthHeader); + expect(res.status).toBe(200); + if (res.body.discovery) { + expect(res.body.discovery).toMatchObject({ + composeDir: expect.any(String), + stackCount: expect.any(Number), + adoptCandidateCount: expect.any(Number), + adoptCandidatesTruncated: expect.any(Boolean), + }); + } + }); }); diff --git a/backend/src/__tests__/import-move.test.ts b/backend/src/__tests__/import-move.test.ts index 66da4869..22586988 100644 --- a/backend/src/__tests__/import-move.test.ts +++ b/backend/src/__tests__/import-move.test.ts @@ -50,6 +50,43 @@ describe('FileSystemService.importCandidateIntoStack', () => { expect(await FileSystemService.getInstance().getStacks()).toContain('webapp'); }); + it('renames a non-canonical loose-root yaml to compose.yaml so the stack registers', async () => { + fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE); + try { + await FileSystemService.getInstance().importCandidateIntoStack( + { location: 'nginx.yml', composeFile: 'nginx.yml', status: 'loose-root' }, + 'nginx', + ); + expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'compose.yaml'))).toBe(true); + expect(fs.existsSync(path.join(tmpRoot, 'nginx', 'nginx.yml'))).toBe(false); + expect(fs.existsSync(path.join(tmpRoot, 'nginx.yml'))).toBe(false); + expect(await FileSystemService.getInstance().getStacks()).toContain('nginx'); + } finally { + fs.rmSync(path.join(tmpRoot, 'nginx'), { recursive: true, force: true }); + fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true }); + } + }); + + it('renames a nested non-canonical yaml to compose.yaml after promoting the directory', async () => { + fs.mkdirSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', 'plex.yml'), COMPOSE); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'plex', '.env'), 'TOKEN=1\n'); + try { + await FileSystemService.getInstance().importCandidateIntoStack( + { location: 'apps/plex/plex.yml', composeFile: 'plex.yml', status: 'nested' }, + 'plex', + ); + expect(fs.existsSync(path.join(tmpRoot, 'plex', 'compose.yaml'))).toBe(true); + expect(fs.existsSync(path.join(tmpRoot, 'plex', 'plex.yml'))).toBe(false); + expect(fs.existsSync(path.join(tmpRoot, 'plex', '.env'))).toBe(true); + expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plex'))).toBe(false); + expect(await FileSystemService.getInstance().getStacks()).toContain('plex'); + } finally { + fs.rmSync(path.join(tmpRoot, 'plex'), { recursive: true, force: true }); + fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true }); + } + }); + it('leaves sibling root files (a root .env) untouched when moving a loose-root file', async () => { fs.writeFileSync(path.join(tmpRoot, 'compose.yaml'), COMPOSE); fs.writeFileSync(path.join(tmpRoot, '.env'), 'TOKEN=keep-me\n'); @@ -79,6 +116,49 @@ describe('FileSystemService.importCandidateIntoStack', () => { expect(await FileSystemService.getInstance().getStacks()).toContain('vault'); }); + it('refuses nested non-canonical adopt when compose.yaml already exists in the source dir', async () => { + fs.mkdirSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'), COMPOSE); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'clash', 'compose.yaml'), 'services:\n other: {}\n'); + await expect( + FileSystemService.getInstance().importCandidateIntoStack( + { location: 'apps/clash/plex.yml', composeFile: 'plex.yml', status: 'nested' }, + 'clash', + ), + ).rejects.toMatchObject({ code: 'DEST_EXISTS' }); + expect(fs.existsSync(path.join(tmpRoot, 'apps', 'clash', 'plex.yml'))).toBe(true); + expect(fs.existsSync(path.join(tmpRoot, 'clash'))).toBe(false); + fs.rmSync(path.join(tmpRoot, 'apps', 'clash'), { recursive: true, force: true }); + }); + + it('rolls nested directory back when post-promote rename to compose.yaml fails', async () => { + fs.mkdirSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true }); + fs.writeFileSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'), COMPOSE); + const realRename = fsPromises.rename.bind(fsPromises); + let calls = 0; + const spy = vi.spyOn(fsPromises, 'rename').mockImplementation(async (...args: Parameters) => { + calls += 1; + if (calls === 2) { + throw Object.assign(new Error('rename failed'), { code: 'EIO' }); + } + return realRename(...args); + }); + try { + await expect( + FileSystemService.getInstance().importCandidateIntoStack( + { location: 'apps/plexrb/plex.yml', composeFile: 'plex.yml', status: 'nested' }, + 'plex-rb', + ), + ).rejects.toMatchObject({ code: 'EIO' }); + expect(fs.existsSync(path.join(tmpRoot, 'apps', 'plexrb', 'plex.yml'))).toBe(true); + expect(fs.existsSync(path.join(tmpRoot, 'plex-rb'))).toBe(false); + } finally { + spy.mockRestore(); + fs.rmSync(path.join(tmpRoot, 'apps', 'plexrb'), { recursive: true, force: true }); + fs.rmSync(path.join(tmpRoot, 'plex-rb'), { recursive: true, force: true }); + } + }); + it('honors a destination name different from the nested folder name', async () => { fs.mkdirSync(path.join(tmpRoot, 'group', 'api'), { recursive: true }); fs.writeFileSync(path.join(tmpRoot, 'group', 'api', 'compose.yaml'), COMPOSE); diff --git a/backend/src/__tests__/import-scan.test.ts b/backend/src/__tests__/import-scan.test.ts index 2c00f792..d2dfa562 100644 --- a/backend/src/__tests__/import-scan.test.ts +++ b/backend/src/__tests__/import-scan.test.ts @@ -151,6 +151,104 @@ describe('FileSystemService.findImportCandidates', () => { } }); + + it('surfaces non-canonical loose-root yaml (e.g. nginx.yml)', async () => { + fs.writeFileSync(path.join(tmpRoot, 'nginx.yml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const loose = candidates.find((c) => c.location === 'nginx.yml'); + expect(loose).toMatchObject({ + name: '', + composeFile: 'nginx.yml', + status: 'loose-root', + }); + expect(loose?.content).toContain('services:'); + } finally { + fs.rmSync(path.join(tmpRoot, 'nginx.yml'), { force: true }); + } + }); + + it('surfaces nested non-canonical yaml (e.g. apps/plex/plex.yml)', async () => { + const dir = path.join(tmpRoot, 'apps', 'plex'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const nested = candidates.find((c) => c.location === 'apps/plex/plex.yml'); + expect(nested).toMatchObject({ + name: 'plex', + composeFile: 'plex.yml', + status: 'nested', + }); + } finally { + fs.rmSync(path.join(tmpRoot, 'apps', 'plex'), { recursive: true, force: true }); + } + }); + + it('does not surface compose override filenames as adopt candidates', async () => { + fs.writeFileSync(path.join(tmpRoot, 'compose.override.yml'), COMPOSE); + const wrap = path.join(tmpRoot, 'ovr-wrap', 'ovr'); + fs.mkdirSync(wrap, { recursive: true }); + fs.writeFileSync(path.join(wrap, 'docker-compose.override.yaml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + expect(candidates.some((c) => c.composeFile.includes('override'))).toBe(false); + } finally { + fs.rmSync(path.join(tmpRoot, 'compose.override.yml'), { force: true }); + fs.rmSync(path.join(tmpRoot, 'ovr-wrap'), { recursive: true, force: true }); + } + }); + + it('does not surface non-canonical yaml inside a top-level folder (not a stack, not adoptable)', async () => { + // plex/plex.yml at the compose root is neither a stack (no canonical compose) + // nor an adopt candidate (promoting with destName === folder would conflict). + const dir = path.join(tmpRoot, 'plex-top'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'plex.yml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + expect(candidates.some((c) => c.location.includes('plex-top'))).toBe(false); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('picks the localeCompare-first non-canonical yaml when several exist', async () => { + const dir = path.join(tmpRoot, 'sort-wrap', 'svc'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'z.yml'), COMPOSE); + fs.writeFileSync(path.join(dir, 'a.yml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const nested = candidates.find((c) => c.name === 'svc'); + expect(nested).toMatchObject({ + composeFile: 'a.yml', + location: 'sort-wrap/svc/a.yml', + status: 'nested', + }); + } finally { + fs.rmSync(path.join(tmpRoot, 'sort-wrap'), { recursive: true, force: true }); + } + }); + + it('prefers a canonical compose filename over a sibling non-canonical yaml', async () => { + const dir = path.join(tmpRoot, 'pref-wrap', 'svc'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'compose.yaml'), COMPOSE); + fs.writeFileSync(path.join(dir, 'nginx.yml'), COMPOSE); + try { + const candidates = await FileSystemService.getInstance().findImportCandidates(); + const nested = candidates.find((c) => c.name === 'svc'); + expect(nested).toMatchObject({ + composeFile: 'compose.yaml', + location: 'pref-wrap/svc/compose.yaml', + status: 'nested', + }); + } finally { + fs.rmSync(path.join(tmpRoot, 'pref-wrap'), { recursive: true, force: true }); + } + }); + it('truncates at maxCandidates', async () => { // Base fixtures yield 2 candidates (loose-root + nested); add a third loose // file so a cap of 2 actually truncates rather than coincidentally matching. diff --git a/backend/src/__tests__/stacks-discovery-route.test.ts b/backend/src/__tests__/stacks-discovery-route.test.ts new file mode 100644 index 00000000..914c6289 --- /dev/null +++ b/backend/src/__tests__/stacks-discovery-route.test.ts @@ -0,0 +1,87 @@ +/** + * Route tests for GET /api/stacks/discovery: auth, contract, and route shadowing. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import fs from 'fs'; +import path from 'path'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let viewerCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + ({ DatabaseService } = await import('../services/DatabaseService')); + authCookie = await loginAsTestAdmin(app); + + const bcrypt = (await import('bcrypt')).default; + const viewerHash = await bcrypt.hash('viewerpass', 1); + DatabaseService.getInstance().addUser({ username: 'disc-viewer', password_hash: viewerHash, role: 'viewer' }); + const loginRes = await request(app) + .post('/api/auth/login') + .send({ username: 'disc-viewer', password: 'viewerpass' }); + const cookies = loginRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; + + const composeDir = process.env.COMPOSE_DIR!; + fs.mkdirSync(path.join(composeDir, 'stack-a'), { recursive: true }); + fs.writeFileSync(path.join(composeDir, 'stack-a', 'compose.yaml'), 'services:\n web:\n image: nginx\n'); + fs.writeFileSync(path.join(composeDir, 'docker-compose.yml'), 'services:\n loose:\n image: nginx\n'); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('GET /api/stacks/discovery', () => { + it('requires authentication', async () => { + const res = await request(app).get('/api/stacks/discovery'); + expect(res.status).toBe(401); + }); + + it('allows stack:read for a viewer', async () => { + const res = await request(app).get('/api/stacks/discovery').set('Cookie', viewerCookie); + expect(res.status).toBe(200); + expect(typeof res.body.composeDir).toBe('string'); + expect(res.body.readable).toBe(true); + expect(res.body.discovery).toBeDefined(); + }); + + it('returns the stable discovery contract for a readable compose dir', async () => { + const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + readable: true, + discovery: { + composeDir: expect.any(String), + stackCount: expect.any(Number), + adoptCandidateCount: expect.any(Number), + adoptCandidatesTruncated: expect.any(Boolean), + }, + }); + expect(res.body.composeDir).toBe(res.body.discovery.composeDir); + }); + + it('does not shadow GET /:stackName (discovery is not treated as a stack name)', async () => { + const res = await request(app).get('/api/stacks/discovery').set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toMatch(/json/); + expect(res.body).toHaveProperty('readable'); + expect(res.text).not.toMatch(/^services:/); + }); +}); + +describe('POST /api/stacks/import/move viewer denial', () => { + it('rejects move without stack:create', async () => { + const res = await request(app) + .post('/api/stacks/import/move') + .set('Cookie', viewerCookie) + .send({ location: 'docker-compose.yml', name: 'moved' }); + expect(res.status).toBe(403); + }); +}); diff --git a/backend/src/routes/diagnostics.ts b/backend/src/routes/diagnostics.ts index a29a1d6c..c026ea22 100644 --- a/backend/src/routes/diagnostics.ts +++ b/backend/src/routes/diagnostics.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'; import { requireAdmin, requireUserSession } from '../middleware/tierGates'; import { collectDiagnostics } from '../services/DiagnosticsService'; import { collectEnvironmentReport, buildRealProbes } from '../services/EnvironmentCheckService'; +import { probeComposeDiscovery } from '../services/ComposeDiscoveryService'; import DockerController from '../services/DockerController'; import { withTimeout } from '../utils/withTimeout'; @@ -50,6 +51,14 @@ diagnosticsRouter.get('/environment', async (req: Request, res: Response): Promi const proto = (req.get('x-forwarded-proto')?.split(',')[0].trim()) || req.protocol; const host = req.get('host') || ''; const report = await collectEnvironmentReport(buildRealProbes({ proto, host })); + try { + const probe = await probeComposeDiscovery(req.nodeId); + if (probe.readable) { + (report as typeof report & { discovery?: typeof probe.discovery }).discovery = probe.discovery; + } + } catch (discoveryErr) { + console.error('[diagnostics] compose discovery probe failed:', (discoveryErr as Error).message); + } res.json(report); } catch (err) { console.error('[diagnostics] failed to collect environment report:', (err as Error).message); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 764cea33..d6d24835 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -28,6 +28,7 @@ import { parseServiceImages, isPreflightAckActive } from '../utils/preflight-ack import type { PreflightAckExpiryMode } from '../services/DatabaseService'; import { buildStackNetworkFacts } from '../services/network/composeNetworkInspector'; import { buildStorageInventory } from '../services/storage/inventory'; +import { probeComposeDiscovery } from '../services/ComposeDiscoveryService'; import { buildEffectiveAnatomy } from '../services/effectiveAnatomy'; import { buildEnvInventory } from '../services/EnvInventoryService'; import { buildStackLabelInventory } from '../services/LabelInventoryService'; @@ -530,6 +531,32 @@ stacksRouter.post('/bulk', async (req: Request, res: Response) => { res.json({ action: typedAction, results }); }); +// Read-only compose discovery for the sidebar empty state. stack:read only; +// never runs full environment diagnostics. Must register before /:stackName. +stacksRouter.get('/discovery', async (req: Request, res: Response) => { + if (!requirePermission(req, res, 'stack:read')) return; + try { + const probe = await probeComposeDiscovery(req.nodeId); + if (probe.readable) { + res.json({ + composeDir: probe.composeDir, + readable: true, + discovery: probe.discovery, + }); + } else { + res.json({ + composeDir: probe.composeDir, + readable: false, + discovery: null, + error: probe.error, + }); + } + } catch (error) { + console.error('Failed to probe compose discovery:', error); + res.status(500).json({ error: 'Failed to probe compose discovery' }); + } +}); + stacksRouter.get('/:stackName', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; diff --git a/backend/src/services/ComposeDiscoveryService.ts b/backend/src/services/ComposeDiscoveryService.ts new file mode 100644 index 00000000..0d01d560 --- /dev/null +++ b/backend/src/services/ComposeDiscoveryService.ts @@ -0,0 +1,82 @@ +import fs from 'fs/promises'; +import { constants as fsConstants } from 'fs'; + +export interface ComposeDiscovery { + composeDir: string; + stackCount: number; + adoptCandidateCount: number; + adoptCandidatesTruncated: boolean; +} + +export type ComposeDiscoveryProbe = + | { + composeDir: string; + readable: true; + discovery: ComposeDiscovery; + } + | { + composeDir: string; + readable: false; + discovery: null; + error: string; + }; + +export async function probeComposeDiscovery(nodeId: number): Promise { + const { FileSystemService } = await import('./FileSystemService'); + const fsSvc = FileSystemService.getInstance(nodeId); + const composeDir = fsSvc.getBaseDir(); + + try { + const stat = await fs.stat(composeDir); + if (!stat.isDirectory()) { + return { + composeDir, + readable: false, + discovery: null, + error: 'Compose path is not a directory.', + }; + } + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code === 'ENOENT') { + return { + composeDir, + readable: false, + discovery: null, + error: 'Compose directory does not exist.', + }; + } + return { + composeDir, + readable: false, + discovery: null, + error: 'Compose directory is not readable.', + }; + } + + try { + await fs.access(composeDir, fsConstants.R_OK); + await fs.readdir(composeDir); + } catch { + return { + composeDir, + readable: false, + discovery: null, + error: 'Compose directory is not readable.', + }; + } + + const stackNames = await fsSvc.getStacks(); + const { count, truncated } = await fsSvc.countImportCandidates(100); + + return { + composeDir, + readable: true, + discovery: { + composeDir, + stackCount: stackNames.length, + adoptCandidateCount: count, + adoptCandidatesTruncated: truncated, + }, + }; +} diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index 9b8e3e3d..96420647 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -63,8 +63,8 @@ const PROTECTED_STACK_FILES = new Set([ // was taken; `.checksums` is the integrity manifest verified before a restore. const BACKUP_MARKER_FILES = new Set(['.timestamp', '.checksums']); -// Compose filenames Sencho recognizes, in resolution-priority order. Mirrors the -// list FileSystemService uses elsewhere; named here for the import scan. +// Compose filenames Sencho recognizes as a managed stack, in resolution-priority +// order. Used by getStacks / hasComposeFile / firstComposeFilename. 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); // Override filenames docker compose can auto-discover, listed in priority order (first @@ -77,6 +77,25 @@ const COMPOSE_OVERRIDE_FILENAMES = [ 'docker-compose.override.yaml', 'docker-compose.override.yml', ] as const; +const COMPOSE_OVERRIDE_FILENAME_SET = new Set(COMPOSE_OVERRIDE_FILENAMES); + +/** True for adopt-scan candidates: any .yml/.yaml that is not a compose override. */ +function isAdoptYamlFilename(name: string): boolean { + return !COMPOSE_OVERRIDE_FILENAME_SET.has(name) && (name.endsWith('.yml') || name.endsWith('.yaml')); +} + +function assertSafeComposeBasename(name: string): void { + // Basename only: reject path separators and the special entries . / .., but allow + // names that merely contain ".." as a substring (e.g. foo..bar.yml). + if (!name || name === '.' || name === '..' || name.includes('/') || name.includes('\\')) { + throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' }); + } +} + +/** Canonical names keep their basename; everything else lands as compose.yaml. */ +function adoptDestComposeBasename(name: string): string { + return IMPORT_COMPOSE_FILENAME_SET.has(name) ? name : 'compose.yaml'; +} // Skip reading compose files larger than this into the import preview. const IMPORT_MAX_PREVIEW_BYTES = 1_048_576; // 1 MiB @@ -104,6 +123,14 @@ export interface ImportCandidateRaw { */ export type MovableImportCandidate = Pick; +/** Placement metadata for a single import candidate (no file read). */ +export interface ImportCandidateMeta { + name: string; + composeFile: string; + location: string; + status: 'loose-root' | 'nested'; +} + // 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 @@ -576,6 +603,33 @@ export class FileSystemService { return null; } + /** + * Compose file to surface for adopt: prefer the four canonical names (same + * access probe as firstComposeFilename, including weird/unreadable entries), + * then any other regular .yml/.yaml file that is not a compose override. + */ + private async firstAdoptComposeFilename(dir: string): Promise { + const canonical = await this.firstComposeFilename(dir); + if (canonical) return canonical; + + this.assertWithinBase(dir); + let entries: Dirent[]; + try { + entries = await fsPromises.readdir(dir, { withFileTypes: true }); + } catch (error) { + console.warn( + '[FileSystemService] Failed to read directory during adopt scan:', + sanitizeForLog((error as Error)?.message ?? String(error)), + ); + return null; + } + const yamlFiles = entries + .filter((e) => e.isFile() && typeof e.name === 'string' && isAdoptYamlFilename(e.name)) + .map((e) => e.name) + .sort((a, b) => a.localeCompare(b)); + return yamlFiles[0] ?? null; + } + private async readComposeCandidate(filePath: string): Promise<{ content: string | null; oversized: boolean }> { this.assertWithinBase(filePath); let fh: import('fs/promises').FileHandle | null = null; @@ -612,48 +666,43 @@ export class FileSystemService { } /** - * Scan the compose directory for compose files that are not yet stacks: loose - * files at the root and compose files one directory too deep. A top-level - * subdirectory with a compose file is already a stack, so it is skipped, not - * surfaced. Read-only. Bounded by `maxCandidates` and by a single level of - * nesting so a deep tree cannot make this walk unbounded. + * Shared placement walk for import discovery and counting. Yields the same + * candidates findImportCandidates surfaces (including weird/unreadable files + * that readComposeCandidate returns content:null for). Loose-root and nested + * candidates accept any .yml/.yaml except compose override filenames; a + * top-level subdirectory is still a stack only when it has a canonical + * compose filename. Stops after `limit` yields. */ - async findImportCandidates(maxCandidates = 100): Promise { - const candidates: ImportCandidateRaw[] = []; + private async *enumerateImportCandidates(limit: number): AsyncGenerator { + let yielded = 0; 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; + return; } for (const entry of entries) { - if (candidates.length >= maxCandidates) break; + if (yielded >= limit) return; 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 }); + if (isAdoptYamlFilename(entry.name)) { + yielded++; + yield { name: '', composeFile: entry.name, location: entry.name, status: 'loose-root' }; } continue; } if (!entry.isDirectory()) continue; const dir = path.join(this.baseDir, entry.name); + // Canonical compose here means already a stack. Non-canonical yaml in this + // folder (e.g. plex/plex.yml) is neither a stack nor an adopt candidate: + // promoting with destName equal to the folder would conflict on disk. const topCompose = await this.firstComposeFilename(dir); - if (topCompose) { - // A top-level subdirectory with a compose file is already a stack (it - // shows in the sidebar), so it is not an import candidate. Skip it and do - // not descend: any compose files deeper inside belong to this stack. - continue; - } + if (topCompose) continue; - // No compose at the top level: peek exactly one level deeper. let children: Dirent[]; try { children = await fsPromises.readdir(dir, { withFileTypes: true }); @@ -662,23 +711,54 @@ export class FileSystemService { continue; } for (const child of children) { - if (candidates.length >= maxCandidates) break; + if (yielded >= limit) return; if (!child.isDirectory() || !child.name || typeof child.name !== 'string') continue; const childDir = path.join(dir, child.name); - const childCompose = await this.firstComposeFilename(childDir); + const childCompose = await this.firstAdoptComposeFilename(childDir); if (childCompose) { - const loaded = await this.readComposeCandidate(path.join(childDir, childCompose)); - candidates.push({ + yielded++; + yield { name: child.name, composeFile: childCompose, location: `${entry.name}/${child.name}/${childCompose}`, status: 'nested', - ...loaded, - }); + }; } } } + } + /** + * Count adopt candidates with 101st lookahead: truncated is true only when + * more than maxCandidates exist. + */ + async countImportCandidates(maxCandidates = 100): Promise<{ count: number; truncated: boolean }> { + let count = 0; + for await (const _ of this.enumerateImportCandidates(maxCandidates + 1)) { + count++; + } + if (count > maxCandidates) { + return { count: maxCandidates, truncated: true }; + } + return { count, truncated: false }; + } + + /** + * Scan the compose directory for compose files that are not yet stacks: loose + * files at the root and compose files one directory too deep. A top-level + * subdirectory with a compose file is already a stack, so it is skipped, not + * surfaced. 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[] = []; + for await (const meta of this.enumerateImportCandidates(maxCandidates)) { + const filePath = meta.status === 'loose-root' + ? path.join(this.baseDir, meta.composeFile) + : path.join(this.baseDir, ...meta.location.split('/')); + const loaded = await this.readComposeCandidate(filePath); + candidates.push({ ...meta, ...loaded }); + } return candidates; } @@ -687,11 +767,13 @@ export class FileSystemService { * auto-discovery (getStacks) picks it up. This is the single write path of the * guided import flow and only runs on an explicit, per-file user action. * - * A `loose-root` file is moved into //: only the - * chosen compose file moves, so sibling files referenced by a relative path + * A `loose-root` file is moved into //: only the chosen + * compose file moves, so sibling files referenced by a relative path * (e.g. a root .env) stay where they are. A `nested` stack directory * (/) is promoted whole to /, preserving its - * .env and any other files. + * .env and any other files. Non-canonical basenames (anything other than the + * four managed compose names) are renamed to compose.yaml so getStacks sees + * the new stack, matching migrateFlatToDirectory. * * Never overwrites: a pre-existing destination is a conflict. Source and * destination are both confirmed to resolve inside the compose directory @@ -732,9 +814,12 @@ export class FileSystemService { if (candidate.status === 'loose-root') { const realSource = await this.realPathWithinBase(source); // Build the relocated file path through the same inline containment barrier so - // the rename target is a credited safe path (candidate.composeFile is an - // allowlisted compose filename, but it is traced from the request). - const destComposePath = path.resolve(baseResolved, destName, candidate.composeFile); + // the rename target is a credited safe path. composeFile is a basename from + // the scan (any .yml/.yaml except overrides); containment is re-checked here. + assertSafeComposeBasename(candidate.composeFile); + // Non-canonical names (e.g. nginx.yml) become compose.yaml so getStacks picks + // them up, matching migrateFlatToDirectory. Canonical names keep their basename. + const destComposePath = path.resolve(baseResolved, destName, adoptDestComposeBasename(candidate.composeFile)); if (!destComposePath.startsWith(baseResolved + path.sep)) { throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' }); } @@ -770,7 +855,42 @@ export class FileSystemService { if (!isPathWithinBase(realCompose, realSourceDir)) { throw Object.assign(new Error('Compose file escapes the import directory'), { code: 'INVALID_PATH' }); } + // Non-canonical basenames become compose.yaml after promotion. Preflight so a + // sibling compose.yaml cannot strand the folder after the directory move. + const destBasename = adoptDestComposeBasename(candidate.composeFile); + const needsComposeRename = destBasename !== candidate.composeFile; + if (needsComposeRename) { + assertSafeComposeBasename(candidate.composeFile); + try { + await fsPromises.access(path.join(realSourceDir, destBasename)); + throw Object.assign( + new Error(`Cannot rename ${candidate.composeFile} to ${destBasename}: destination already exists`), + { code: 'DEST_EXISTS' }, + ); + } catch (error) { + const code = (error as NodeJS.ErrnoException)?.code; + if (code === 'DEST_EXISTS') throw error; + if (code !== 'ENOENT') throw error; + } + } await fsPromises.rename(realSourceDir, destDir); + if (needsComposeRename) { + const fromPath = path.resolve(destDir, candidate.composeFile); + const toPath = path.resolve(destDir, destBasename); + if (!fromPath.startsWith(destDir + path.sep) || !toPath.startsWith(destDir + path.sep)) { + // Directory already moved; roll it back before failing. + await fsPromises.rename(destDir, realSourceDir).catch(() => undefined); + throw Object.assign(new Error('Invalid path'), { code: 'INVALID_PATH' }); + } + try { + await fsPromises.rename(fromPath, toPath); + } catch (error) { + // Roll the directory back to its nested path so the candidate stays + // adoptable and a retry is not blocked by DEST_EXISTS on destName. + await fsPromises.rename(destDir, realSourceDir).catch(() => undefined); + throw error; + } + } return; } diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 0337efaf..c9ce8df4 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -13,12 +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. +To bring in compose files that are already on disk but not yet stacks, use **Adopt existing files** (from the empty sidebar, Create Stack footer, or after first-boot preflight). See [Adopt existing files](#adopt-existing-files) below. + +When you have no stacks yet, the sidebar shows what Sencho found under your compose directory and offers **Adopt existing files** and **New stack** when those actions apply. New stack dialog on the Empty tab with a stack name field @@ -31,11 +32,13 @@ When you have no stacks yet, the sidebar shows a short prompt with **Import exis 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 +### Adopt 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 for compose files that are not yet stacks and lists them with a dry preview of each file's services, ports, volumes, and env files. Scanning is read only: it never moves, writes, or changes anything. The only change to disk is the **Move into place** action, which you trigger per file and confirm before it runs. Stacks that already sit in their own subfolder are not listed here; they appear in the sidebar. +If you already have compose files on disk that are not yet stacks, **Adopt existing files** lists them with a dry preview of each file's services, ports, volumes, and env files. Scanning is read only: it never moves, writes, or changes anything. The only change to disk is the **Move into place** action, which you trigger per file and confirm before it runs. Stacks that already sit in their own subfolder with a standard compose filename (`compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`) are not listed here; they appear in the sidebar. -Each listed file is one that will not show up as a stack on its own, because it is loose at the root of the compose directory or one folder too deep. Expand it, give the stack a name, and click **Move into place** to relocate it into its own subfolder; confirm the move, and it appears as a stack in the sidebar. This only relocates the compose file on disk: it does not adopt or capture running containers, and containers start only when you deploy the stack. Moving a loose root file relocates only that file, so anything it references by a relative path (such as a root `.env`) stays where it is. You can also arrange the file by hand and click **Rescan** instead. +Adopt surfaces any `.yml` or `.yaml` file that is loose at the compose directory root, or one folder too deep (for example `apps/plex/plex.yml`). Compose override filenames such as `compose.override.yml` are skipped. A top-level folder that only has a non-standard name like `plex/plex.yml` is neither a stack nor an adopt candidate; put a standard compose filename in that folder, or nest one level deeper (for example `apps/plex/plex.yml`). Expand a candidate, give the stack a name, and click **Move into place** to relocate it into its own subfolder; confirm the move, and it appears as a stack in the sidebar. If the file was not already named `compose.yaml`, `compose.yml`, `docker-compose.yaml`, or `docker-compose.yml`, Sencho renames it to `compose.yaml` during the move so the stack registers. This only relocates files on disk: it does not adopt or capture running containers, and containers start only when you deploy the stack. Moving a loose root file relocates only that file, so anything it references by a relative path (such as a root `.env`) stays where it is. You can also arrange the file by hand and click **Rescan** instead. + +On startup, Sencho also auto-promotes every top-level `*.yml` / `*.yaml` file in `COMPOSE_DIR` into a `/compose.yaml` stack directory. That migration covers loose root files on the next restart, so adopting a root file is optional if you are about to restart. Nested placements (one directory too deep) are not touched by that migration; adopt is the guided path for those. 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. @@ -445,7 +448,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. 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. +If you place Docker Compose files directly into the stacks directory (for example, via `scp`, a file manager, or the command line), click **Scan stacks folder** to re-index subdirectories that already look like stacks. For compose files that are loose at the root or one folder too deep, use [Adopt existing files](#adopt-existing-files). Sidebar action row with Create Stack button, bulk mode toggle, and scan stacks folder icon button diff --git a/docs/getting-started/quickstart.mdx b/docs/getting-started/quickstart.mdx index e1daef33..7b12806a 100644 --- a/docs/getting-started/quickstart.mdx +++ b/docs/getting-started/quickstart.mdx @@ -109,9 +109,9 @@ Below the stack table, **Configuration Status** summarizes notifications, alerts On the local node, the top navigation includes **Home**, **Fleet**, **Resources**, **Security**, **App Store**, and **Logs**. Depending on license, role, and node context, it can also include **Update**, **Schedules**, **Console**, and **Audit**; hub-only views are hidden when a remote node is active. The right side of the top bar holds global search, notifications, and the profile menu entries **Settings**, **Documentation**, **Feedback**, **Appearance**, and **Log Out**. -The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for importing Compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install the stack list is empty until you create a stack or scan a populated `COMPOSE_DIR`. +The left sidebar is the stack workspace. Below the Sencho brand, it starts with the node switcher, then **Create Stack**, a bulk-mode toggle, and **Scan stacks folder** for re-indexing compose projects added outside Sencho. Use **Search stacks...** with the **All**, **Up**, **Down**, and **Updates** chips to narrow the list. On a fresh install with an empty stack list, Sencho scans your mounted compose directory automatically and shows what it found, including compose files that still need to be adopted into their own subfolder. -Click **Create Stack** in the sidebar and choose **Empty**, **From Git**, or **From Docker Run**. Open **App Store** for a template-driven deploy flow. Once a stack exists, select it in the sidebar or in **Stack health** to open the stack workspace with container health, logs, files, compose editing, and stack actions. +Click **Create Stack** in the sidebar and choose **Empty**, **From Git**, or **From Docker Run**. Use **Adopt existing files** when Sencho finds compose files that are loose at the compose directory root or one folder too deep (any `.yml` / `.yaml`, not only `compose.yaml`). Loose root files are also auto-promoted into stack folders on the next Sencho restart; adopt is the guided path especially for nested placements. Open **App Store** for a template-driven deploy flow. Once a stack exists, select it in the sidebar or in **Stack health** to open the stack workspace with container health, logs, files, compose editing, and stack actions. ## Where to next diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 057af619..1c53aeb5 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -6,6 +6,7 @@ import { NotificationPanel } from './NotificationPanel'; import { TopBar } from './TopBar'; import { ViewRouter } from './EditorLayout/ViewRouter'; import { CreateStackDialog, type CreateMode } from './EditorLayout/CreateStackDialog'; +import { AdoptExistingDialog } from './EditorLayout/AdoptExistingDialog'; import { EditorView } from './EditorLayout/EditorView'; import { ShellOverlays } from './EditorLayout/ShellOverlays'; import { classifyFailedGate } from './EditorLayout/failed-gate-recovery'; @@ -71,7 +72,7 @@ const ResourcesView = lazy(() => import('./ResourcesView')); const GlobalObservabilityView = lazy(() => import('./GlobalObservabilityView').then(m => ({ default: m.GlobalObservabilityView }))); export default function EditorLayout() { - const { isAdmin, can } = useAuth(); + const { isAdmin, can, permissions } = useAuth(); const { status: trivy } = useTrivyStatus(); const { runWithLog, panelState, logRows, healthGate } = useDeployFeedback(); @@ -164,14 +165,21 @@ 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'. + // Which mode the create dialog opens on (always empty after import tab removal). const [createDialogInitialMode, setCreateDialogInitialMode] = useState('empty'); - const openCreateDialog = useCallback((mode: CreateMode) => { + const [adoptDialogOpen, setAdoptDialogOpen] = useState(false); + const adoptOpenedFromSetupRef = useRef(false); + + const openCreateDialog = useCallback((mode: CreateMode = 'empty') => { setCreateDialogInitialMode(mode); setCreateDialogOpen(true); }, [setCreateDialogOpen]); + const openAdoptDialog = useCallback(() => { + setCreateDialogOpen(false); + setAdoptDialogOpen(true); + }, [setCreateDialogOpen]); + const [diffPreviewEnabled] = useComposeDiffPreviewEnabled(); const [topNavLabels] = useTopNavLabels(); const [topNavAlign] = useTopNavAlign(); @@ -677,6 +685,23 @@ export default function EditorLayout() { } }, [containers, selectedFile]); // eslint-disable-line react-hooks/exhaustive-deps + useEffect(() => { + if (permissions === null) return; + if (adoptOpenedFromSetupRef.current) return; + try { + const raw = sessionStorage.getItem('sencho:post-setup'); + if (!raw) return; + sessionStorage.removeItem('sencho:post-setup'); + const parsed = JSON.parse(raw) as { openAdopt?: boolean }; + if (parsed.openAdopt && can('stack:create')) { + adoptOpenedFromSetupRef.current = true; + setAdoptDialogOpen(true); + } + } catch { + // ignore malformed session flag + } + }, [permissions, can]); + const createStackSlot = can('stack:create') ? ( <> + + ) : null} ); } @@ -620,7 +622,7 @@ function ModeRail({
{MODES.map((m, i) => { diff --git a/frontend/src/components/EditorLayout/ImportStackPanel.tsx b/frontend/src/components/EditorLayout/ImportStackPanel.tsx index 5bf8d9f5..eb6400b9 100644 --- a/frontend/src/components/EditorLayout/ImportStackPanel.tsx +++ b/frontend/src/components/EditorLayout/ImportStackPanel.tsx @@ -16,6 +16,14 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useAuth } from '@/context/AuthContext'; +// Mirrors backend IMPORT_COMPOSE_FILENAMES: non-canonical basenames land as compose.yaml. +const CANONICAL_COMPOSE_FILENAMES = new Set([ + 'compose.yaml', + 'compose.yml', + 'docker-compose.yaml', + 'docker-compose.yml', +]); + // Mirrors backend isValidStackName so the move button stays disabled until the // name the backend would accept; the backend remains authoritative. const VALID_STACK_NAME = /^[a-zA-Z0-9_-]+$/; @@ -187,7 +195,7 @@ export function ImportStackPanel({ onClose, onImported }: ImportStackPanelProps) canCreate={canCreate} moving={movingLocation === c.location} onToggle={() => toggle(c.location)} - onMove={(name) => void move(c.location, name)} + onMove={(name) => move(c.location, name)} /> ))}
@@ -230,7 +238,7 @@ function CandidateCard({ canCreate: boolean; moving: boolean; onToggle: () => void; - onMove: (name: string) => void; + onMove: (name: string) => Promise; }) { const { name, composeFile, location, status, services, warnings, parseError } = candidate; // Prefill the destination name: a nested stack already has a folder name worth @@ -240,7 +248,9 @@ function CandidateCard({ const trimmedName = destName.trim(); const nameValid = VALID_STACK_NAME.test(trimmedName); const displayName = name || ''; - const target = joinPath(composeDir, trimmedName || displayName, composeFile); + // Match backend importCandidateIntoStack: non-canonical basenames land as compose.yaml. + const destComposeFile = CANONICAL_COMPOSE_FILENAMES.has(composeFile) ? composeFile : 'compose.yaml'; + const target = joinPath(composeDir, trimmedName || displayName, destComposeFile); return (
@@ -299,13 +309,25 @@ function CandidateCard({ put.

)} + {destComposeFile !== composeFile && ( +

+ Will be saved as compose.yaml so Sencho recognizes the stack. +

+ )} {confirming ? (
Move it on disk? -
@@ -222,4 +261,3 @@ function Field({ id, label, children }: { id: string; label: string; children: R ); } - diff --git a/frontend/src/components/__tests__/Setup.test.tsx b/frontend/src/components/__tests__/Setup.test.tsx new file mode 100644 index 00000000..1a2dcfd7 --- /dev/null +++ b/frontend/src/components/__tests__/Setup.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { Setup } from '../Setup'; + +const apiFetchMock = vi.fn(); +vi.mock('@/lib/api', () => ({ apiFetch: (...args: unknown[]) => apiFetchMock(...args) })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } })); + +const ENV_REPORT = { + checks: [ + { id: 'compose_dir', label: 'Compose dir', status: 'pass', detail: '/opt/compose' }, + { id: 'docker_socket', label: 'Docker', status: 'pass', detail: 'ok' }, + ], + generatedAt: 1, + discovery: { + composeDir: '/opt/compose', + stackCount: 1, + adoptCandidateCount: 2, + adoptCandidatesTruncated: false, + }, +}; + +function jsonRes(body: unknown, ok = true) { + return { ok, json: async () => body } as Response; +} + +describe('Setup preflight', () => { + beforeEach(() => { + apiFetchMock.mockReset(); + vi.stubGlobal('fetch', vi.fn()); + sessionStorage.clear(); + }); + + it('runs exactly one diagnostics fetch on the environment step', async () => { + vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true })); + + apiFetchMock.mockImplementation((path: string) => { + if (path === '/diagnostics/environment') { + return Promise.resolve(jsonRes(ENV_REPORT)); + } + return Promise.resolve(jsonRes({})); + }); + + render(); + + fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: /initialize console/i })); + + await waitFor(() => expect(screen.getByText('Preflight')).toBeTruthy()); + + await waitFor(() => { + const envCalls = apiFetchMock.mock.calls.filter((c) => c[0] === '/diagnostics/environment'); + expect(envCalls).toHaveLength(1); + }); + + expect(apiFetchMock).not.toHaveBeenCalledWith('/stacks/discovery', expect.anything()); + expect(screen.getByText('Compose discovery')).toBeTruthy(); + expect(screen.getByText(/Found 1 stack and 2 files to adopt in \/opt\/compose/i)).toBeTruthy(); + expect(screen.getByText(/Enter Sencho to review and adopt/i)).toBeTruthy(); + expect(screen.queryByRole('button', { name: /review discovered files/i })).toBeNull(); + expect(screen.getByText('ok')).toBeTruthy(); + }); + + it('sets post-setup adopt handoff when discovery has candidates', async () => { + vi.mocked(fetch).mockResolvedValue(jsonRes({ success: true })); + apiFetchMock.mockResolvedValue(jsonRes(ENV_REPORT)); + const onComplete = vi.fn(); + + render(); + + fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'admin' } }); + fireEvent.change(screen.getByLabelText(/^password$/i), { target: { value: 'password123' } }); + fireEvent.change(screen.getByLabelText(/confirm password/i), { target: { value: 'password123' } }); + fireEvent.click(screen.getByRole('button', { name: /initialize console/i })); + + await waitFor(() => expect(screen.getByText(/2 files to adopt/i)).toBeTruthy()); + fireEvent.click(screen.getByRole('button', { name: /enter sencho/i })); + + expect(onComplete).toHaveBeenCalledTimes(1); + expect(sessionStorage.getItem('sencho:post-setup')).toBe(JSON.stringify({ openAdopt: true })); + }); +}); diff --git a/frontend/src/components/settings/EnvironmentChecks.tsx b/frontend/src/components/settings/EnvironmentChecks.tsx index e5a315a8..06fa7504 100644 --- a/frontend/src/components/settings/EnvironmentChecks.tsx +++ b/frontend/src/components/settings/EnvironmentChecks.tsx @@ -12,7 +12,15 @@ import { SettingsActions, SettingsSecondaryButton } from './SettingsActions'; // reads checks, so `remediation` stays optional here even though the backend // models it as required on every warn / fail row. type CheckStatus = 'pass' | 'warn' | 'fail'; -type CheckId = 'docker_socket' | 'docker_compose' | 'compose_dir' | 'self_stack_location' | 'path_mapping' | 'tls' | 'disk_space'; +type CheckId = + | 'docker_socket' + | 'docker_compose' + | 'compose_dir' + | 'self_stack_location' + | 'path_mapping' + | 'tls' + | 'disk_space' + | 'compose_discovery'; interface EnvironmentCheck { id: CheckId; @@ -25,8 +33,56 @@ interface EnvironmentCheck { interface EnvironmentReport { checks: EnvironmentCheck[]; generatedAt: number; + discovery?: import('@/lib/discovery-types').ComposeDiscovery; } +export type { EnvironmentReport, EnvironmentCheck }; + +/** Frontend-only row derived from optional discovery on the environment report. */ +function discoveryCheckRow( + discovery: NonNullable, +): EnvironmentCheck | null { + const { stackCount, adoptCandidateCount, adoptCandidatesTruncated, composeDir } = discovery; + if (stackCount + adoptCandidateCount === 0) return null; + + const stackPart = + stackCount > 0 + ? `${stackCount} stack${stackCount === 1 ? '' : 's'}` + : null; + const adoptPart = + adoptCandidateCount > 0 + ? `${adoptCandidateCount}${adoptCandidatesTruncated ? '+' : ''} file${adoptCandidateCount === 1 && !adoptCandidatesTruncated ? '' : 's'} to adopt` + : null; + const summary = [stackPart, adoptPart].filter(Boolean).join(' and '); + + return { + id: 'compose_discovery', + label: 'Compose discovery', + status: 'pass', + detail: `Found ${summary} in ${composeDir}.`, + remediation: + adoptCandidateCount > 0 + ? 'Enter Sencho to review and adopt them into their own stack folders.' + : undefined, + }; +} + +type EnvironmentChecksControlledProps = { + report: EnvironmentReport | null; + isLoading: boolean; + onRerun: () => void | Promise; +}; + +type EnvironmentChecksUncontrolledProps = { + report?: undefined; + isLoading?: undefined; + onRerun?: undefined; +}; + +export type EnvironmentChecksProps = { + className?: string; +} & (EnvironmentChecksControlledProps | EnvironmentChecksUncontrolledProps); + const STATUS_WORD: Record = { pass: 'OK', warn: 'Warning', fail: 'Action needed' }; function StatusBadge({ status, children }: { status: CheckStatus; children: ReactNode }) { @@ -84,33 +140,53 @@ function ChecksSkeleton() { * a Re-run control. It never blocks; the caller decides what continue action, * if any, sits alongside it. */ -export function EnvironmentChecks({ className }: { className?: string }) { - const [report, setReport] = useState(null); - const [isLoading, setIsLoading] = useState(true); +export function EnvironmentChecks(props: EnvironmentChecksProps) { + const { className } = props; + const isControlled = props.onRerun !== undefined; + + const [internalReport, setInternalReport] = useState(null); + const [internalLoading, setInternalLoading] = useState(!isControlled); const load = useCallback(async () => { - setIsLoading(true); + if (isControlled) { + await props.onRerun(); + return; + } + setInternalLoading(true); try { const res = await apiFetch('/diagnostics/environment', { localOnly: true }); if (!res.ok) { const err = await res.json().catch(() => ({})); toast.error(err?.error || 'Failed to run environment checks.'); - setReport(null); + setInternalReport(null); return; } - setReport(await res.json() as EnvironmentReport); + setInternalReport(await res.json() as EnvironmentReport); } catch (e: unknown) { toast.error((e as Error)?.message || 'Failed to run environment checks.'); - setReport(null); + setInternalReport(null); } finally { - setIsLoading(false); + setInternalLoading(false); } - }, []); + }, [isControlled, isControlled ? props.onRerun : undefined]); useEffect(() => { + if (isControlled) return; // eslint-disable-next-line react-hooks/set-state-in-effect void load(); - }, [load]); + }, [isControlled, load]); + + const report = isControlled ? props.report : internalReport; + const isLoading = isControlled ? props.isLoading : internalLoading; + // Discovery is a Setup-only affordance (post-enter adopt handoff). Recovery + // keeps the host-readiness checklist without a compose-discovery row. + const discoveryRow = + isControlled && report?.discovery ? discoveryCheckRow(report.discovery) : null; + const rows = report + ? discoveryRow + ? [...report.checks, discoveryRow] + : report.checks + : []; return (
@@ -118,13 +194,13 @@ export function EnvironmentChecks({ className }: { className?: string }) { ) : report ? (
- {report.checks.map(check => )} + {rows.map(check => )}
) : (

Checks could not be run. Try again.

)} - void load()} disabled={isLoading}> + void (isControlled ? props.onRerun() : load())} disabled={isLoading}> Re-run diff --git a/frontend/src/components/settings/__tests__/EnvironmentChecks.test.tsx b/frontend/src/components/settings/__tests__/EnvironmentChecks.test.tsx new file mode 100644 index 00000000..6e8ba25c --- /dev/null +++ b/frontend/src/components/settings/__tests__/EnvironmentChecks.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { EnvironmentChecks } from '../EnvironmentChecks'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } })); + +import { apiFetch } from '@/lib/api'; + +const ENV_REPORT = { + checks: [ + { id: 'docker_socket' as const, label: 'Docker', status: 'pass' as const, detail: 'ok' }, + ], + generatedAt: 1, +}; + +describe('EnvironmentChecks', () => { + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ENV_REPORT, + } as Response); + }); + + it('self-fetches on mount when uncontrolled', async () => { + render(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + expect(apiFetch).toHaveBeenCalledWith('/diagnostics/environment', { localOnly: true }); + expect(screen.getByText('ok')).toBeTruthy(); + }); + + it('does not fetch on mount when controlled', async () => { + const onRerun = vi.fn(); + render( + , + ); + await waitFor(() => expect(screen.getByText('ok')).toBeTruthy()); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('calls onRerun when Re-run is clicked in controlled mode', async () => { + const onRerun = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /re-run/i })); + expect(onRerun).toHaveBeenCalledTimes(1); + expect(apiFetch).not.toHaveBeenCalled(); + }); + + it('shows error state when controlled with null report', () => { + render( + , + ); + expect(screen.getByText('Checks could not be run. Try again.')).toBeTruthy(); + }); + + it('appends a compose discovery row when discovery has counts', () => { + render( + , + ); + expect(screen.getByText('Compose discovery')).toBeTruthy(); + expect(screen.getByText(/Found 1 file to adopt in \/opt\/compose/i)).toBeTruthy(); + expect(screen.getByText(/Enter Sencho to review and adopt/i)).toBeTruthy(); + }); + + it('omits the discovery row when counts are zero', () => { + render( + , + ); + expect(screen.queryByText('Compose discovery')).toBeNull(); + }); + + it('omits the discovery row when uncontrolled even if the report includes discovery', async () => { + vi.mocked(apiFetch).mockResolvedValue({ + ok: true, + json: async () => ({ + ...ENV_REPORT, + discovery: { + composeDir: '/opt/compose', + stackCount: 0, + adoptCandidateCount: 1, + adoptCandidatesTruncated: false, + }, + }), + } as Response); + render(); + await waitFor(() => expect(screen.getByText('ok')).toBeTruthy()); + expect(screen.queryByText('Compose discovery')).toBeNull(); + }); +}); diff --git a/frontend/src/components/sidebar/DiscoveryEmptyState.tsx b/frontend/src/components/sidebar/DiscoveryEmptyState.tsx new file mode 100644 index 00000000..7165a243 --- /dev/null +++ b/frontend/src/components/sidebar/DiscoveryEmptyState.tsx @@ -0,0 +1,146 @@ +import { useCallback, useEffect, useState } from 'react'; +import { FolderSearch, Plus, Layers, RefreshCw, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { apiFetch } from '@/lib/api'; +import { toast } from '@/components/ui/toast-store'; +import type { StacksDiscoveryResponse } from '@/lib/discovery-types'; +import { Skeleton } from '@/components/ui/skeleton'; + +export interface DiscoveryEmptyStateProps { + onOpenAdopt?: () => void; + onOpenCreate?: () => void; + onScan?: () => void; + canCreate?: boolean; + activeNodeId?: number | null; +} + +export function DiscoveryEmptyState({ + onOpenAdopt, + onOpenCreate, + onScan, + canCreate = false, + activeNodeId, +}: DiscoveryEmptyStateProps) { + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + try { + const res = await apiFetch('/stacks/discovery'); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error((err as { error?: string })?.error || 'Failed to load compose discovery.'); + } + setData((await res.json()) as StacksDiscoveryResponse); + } catch (e: unknown) { + console.error('Failed to load compose discovery:', e); + toast.error((e as Error)?.message || 'Failed to load compose discovery.'); + setData(null); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load, activeNodeId]); + + if (loading && !data) { + return ( +
+ + + +
+ ); + } + + if (!data) { + return ( +
+ +

Discovery unavailable

+ +
+ ); + } + + const { composeDir, readable, discovery, error } = data; + + if (!readable) { + return ( +
+ +

Could not read compose directory

+

{composeDir}

+ {error ? ( +

{error}

+ ) : null} +
+ ); + } + + const stackCount = discovery?.stackCount ?? 0; + const adoptCount = discovery?.adoptCandidateCount ?? 0; + const truncated = discovery?.adoptCandidatesTruncated ?? false; + const hasAdopt = adoptCount > 0; + + if (hasAdopt) { + return ( +
+ +

+ {adoptCount} + {truncated ? '+' : ''} compose file{adoptCount === 1 && !truncated ? '' : 's'} to adopt +

+

{composeDir}

+ {stackCount > 0 ? ( +

+ {stackCount} stack{stackCount === 1 ? '' : 's'} already in place +

+ ) : null} + {onOpenAdopt ? ( + + ) : null} + {canCreate && onOpenCreate ? ( + + ) : null} +
+ ); + } + + return ( +
+ +

No compose projects yet

+

{composeDir}

+

+ Drop compose files here or create a stack from scratch. +

+
+ {canCreate && onOpenCreate ? ( + + ) : null} + {onScan ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend/src/components/sidebar/EmptyStackState.tsx b/frontend/src/components/sidebar/EmptyStackState.tsx deleted file mode 100644 index 5d31f616..00000000 --- a/frontend/src/components/sidebar/EmptyStackState.tsx +++ /dev/null @@ -1,32 +0,0 @@ -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 0abcd43f..892b3f87 100644 --- a/frontend/src/components/sidebar/StackList.tsx +++ b/frontend/src/components/sidebar/StackList.tsx @@ -11,7 +11,7 @@ import type { StackRowStatus } from './stack-status-utils'; import { StackGroup } from './StackGroup'; import { StackContextMenu } from './StackContextMenu'; import { StackKebabMenu } from './StackKebabMenu'; -import { EmptyStackState } from './EmptyStackState'; +import { DiscoveryEmptyState } from './DiscoveryEmptyState'; import type { StackMenuCtx, FilterChip } from './sidebar-types'; import type { MuteRuleDraft } from '@/lib/muteRules'; import type { StacksLoadStatus } from '@/components/EditorLayout/hooks/useStackListState'; @@ -56,9 +56,12 @@ export interface StackListProps { // 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; + // Open the create dialog. Present only when the user can create stacks. + onOpenCreate?: () => void; + onOpenAdopt?: () => void; + onScan?: () => void; + canCreate?: boolean; + activeNodeId?: number | null; openMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void; stacksLoadStatus?: StacksLoadStatus; stacksLoadError?: string | null; @@ -150,7 +153,7 @@ export function StackList(props: StackListProps & StackListBulkProps) { isBusy, getDisplayName, onSelectFile, buildMenuCtx, bulkMode, selectedFiles, onToggleSelect, remoteResults, remoteLoading, remoteFailedNodes, onSelectRemoteFile, - filterChip, onOpenCreate, + filterChip, onOpenCreate, onOpenAdopt, onScan, canCreate, activeNodeId, openMuteRulesWithPrefill, stacksLoadStatus, stacksLoadError, @@ -197,7 +200,15 @@ export function StackList(props: StackListProps & StackListBulkProps) { // 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 ( + + ); } return ( diff --git a/frontend/src/components/sidebar/__tests__/DiscoveryEmptyState.test.tsx b/frontend/src/components/sidebar/__tests__/DiscoveryEmptyState.test.tsx new file mode 100644 index 00000000..6dfd249c --- /dev/null +++ b/frontend/src/components/sidebar/__tests__/DiscoveryEmptyState.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { DiscoveryEmptyState } from '../DiscoveryEmptyState'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn() } })); + +import { apiFetch } from '@/lib/api'; + +function discoveryRes(body: unknown) { + return { ok: true, json: async () => body } as Response; +} + +describe('DiscoveryEmptyState', () => { + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + }); + + it('fetches /stacks/discovery only, never diagnostics', async () => { + vi.mocked(apiFetch).mockResolvedValue(discoveryRes({ + composeDir: '/opt/compose', + readable: true, + discovery: { + composeDir: '/opt/compose', + stackCount: 0, + adoptCandidateCount: 0, + adoptCandidatesTruncated: false, + }, + })); + + render(); + + await waitFor(() => expect(screen.getByText(/no compose projects yet/i)).toBeTruthy()); + expect(apiFetch).toHaveBeenCalledTimes(1); + expect(apiFetch).toHaveBeenCalledWith('/stacks/discovery'); + expect(apiFetch).not.toHaveBeenCalledWith('/diagnostics/environment', expect.anything()); + }); + + it('shows unreadable copy with composeDir and error', async () => { + vi.mocked(apiFetch).mockResolvedValue(discoveryRes({ + composeDir: '/missing/compose', + readable: false, + discovery: null, + error: 'Compose directory does not exist.', + })); + + render(); + + await waitFor(() => expect(screen.getByText(/could not read compose directory/i)).toBeTruthy()); + expect(screen.getByText('/missing/compose')).toBeTruthy(); + expect(screen.getByText('Compose directory does not exist.')).toBeTruthy(); + }); + + it('shows adopt CTA when candidates exist', async () => { + const onOpenAdopt = vi.fn(); + vi.mocked(apiFetch).mockResolvedValue(discoveryRes({ + composeDir: '/opt/compose', + readable: true, + discovery: { + composeDir: '/opt/compose', + stackCount: 2, + adoptCandidateCount: 3, + adoptCandidatesTruncated: false, + }, + })); + + render(); + + await waitFor(() => expect(screen.getByRole('button', { name: /adopt existing files/i })).toBeTruthy()); + expect(screen.queryByText(/no compose projects yet/i)).toBeNull(); + }); + + it('re-fetches when activeNodeId changes', async () => { + vi.mocked(apiFetch).mockResolvedValue(discoveryRes({ + composeDir: '/opt/compose', + readable: true, + discovery: { + composeDir: '/opt/compose', + stackCount: 0, + adoptCandidateCount: 0, + adoptCandidatesTruncated: false, + }, + })); + + const { rerender } = render(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(1)); + + rerender(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledTimes(2)); + }); +}); diff --git a/frontend/src/lib/discovery-types.ts b/frontend/src/lib/discovery-types.ts new file mode 100644 index 00000000..67b4bed5 --- /dev/null +++ b/frontend/src/lib/discovery-types.ts @@ -0,0 +1,13 @@ +export interface ComposeDiscovery { + composeDir: string; + stackCount: number; + adoptCandidateCount: number; + adoptCandidatesTruncated: boolean; +} + +export interface StacksDiscoveryResponse { + composeDir: string; + readable: boolean; + discovery: ComposeDiscovery | null; + error?: string; +}