diff --git a/backend/src/index.ts b/backend/src/index.ts index b154f3c4..d01f0851 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -362,8 +362,8 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { } }); -// Helper: resolve the env file path dynamically from compose.yaml's env_file field -async function resolveEnvFilePath(stackName: string): Promise { +// Helper: resolve all env file paths dynamically from compose.yaml's env_file field +async function resolveAllEnvFilePaths(stackName: string): Promise { const stackDir = path.join(fileSystemService.getBaseDir(), stackName); const defaultEnvPath = path.join(stackDir, '.env'); @@ -381,47 +381,74 @@ async function resolveEnvFilePath(stackName: string): Promise { } } - if (!composeContent) return defaultEnvPath; + if (!composeContent) return [defaultEnvPath]; const parsed = YAML.parse(composeContent); - if (!parsed?.services) return defaultEnvPath; + if (!parsed?.services) return [defaultEnvPath]; - // Iterate through services, looking for the first env_file declaration + const envFiles = new Set(); + + // Iterate through all services and collect every env_file declaration for (const serviceName of Object.keys(parsed.services)) { const service = parsed.services[serviceName]; if (!service?.env_file) continue; - let envFilePath: string; - if (typeof service.env_file === 'string') { - envFilePath = service.env_file; - } else if (Array.isArray(service.env_file) && service.env_file.length > 0) { - // Handle array format: take the first entry - const first = service.env_file[0]; - envFilePath = typeof first === 'string' ? first : (first?.path || ''); - } else { - continue; + const resolvedPath = path.isAbsolute(service.env_file) + ? service.env_file + : path.resolve(stackDir, service.env_file); + envFiles.add(resolvedPath); + } else if (Array.isArray(service.env_file)) { + for (const entry of service.env_file) { + const entryPath = typeof entry === 'string' ? entry : (entry?.path || ''); + if (entryPath) { + const resolvedPath = path.isAbsolute(entryPath) + ? entryPath + : path.resolve(stackDir, entryPath); + envFiles.add(resolvedPath); + } + } } - - if (!envFilePath) continue; - - // Resolve: absolute path stays as-is, relative path resolves against stackDir - if (path.isAbsolute(envFilePath)) { - return envFilePath; - } - return path.resolve(stackDir, envFilePath); } + + if (envFiles.size === 0) { + return [defaultEnvPath]; + } + + return Array.from(envFiles); } catch (error) { console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error); } - return defaultEnvPath; + return [defaultEnvPath]; } +app.get('/api/stacks/:stackName/envs', async (req: Request, res: Response) => { + try { + const stackName = req.params.stackName as string; + const envPaths = await resolveAllEnvFilePaths(stackName); + res.json({ envFiles: envPaths }); + } catch (error) { + res.status(500).json({ error: 'Failed to resolve env files' }); + } +}); + app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { const stackName = req.params.stackName as string; - const envPath = await resolveEnvFilePath(stackName); + const requestedFile = req.query.file as string | undefined; + const envPaths = await resolveAllEnvFilePaths(stackName); + + let envPath = envPaths[0]; // Fallback to the first + + if (requestedFile) { + // Validate that the requested file exists in the allowed resolved list + if (envPaths.includes(requestedFile)) { + envPath = requestedFile; + } else { + return res.status(400).json({ error: 'Requested env file not allowed' }); + } + } try { await fsPromises.access(envPath); @@ -448,7 +475,19 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { return res.status(400).json({ error: 'Content must be a string' }); } - const envPath = await resolveEnvFilePath(stackName); + const requestedFile = req.query.file as string | undefined; + const envPaths = await resolveAllEnvFilePaths(stackName); + + let envPath = envPaths[0]; // Fallback + + if (requestedFile) { + if (envPaths.includes(requestedFile)) { + envPath = requestedFile; + } else { + return res.status(400).json({ error: 'Requested env file not allowed' }); + } + } + await fsPromises.writeFile(envPath, content, 'utf-8'); res.json({ message: 'Env file saved successfully' }); } catch (error) { diff --git a/docker-compose.yml b/docker-compose.yml index 1f841fa7..7636a6b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,7 @@ services: sencho: image: saelix/sencho:latest + build: . container_name: sencho restart: unless-stopped ports: diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index f422363b..84633dba 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -25,6 +25,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { NotificationSettingsModal } from './NotificationSettingsModal'; import { StackAlertSheet } from './StackAlertSheet'; interface ContainerInfo { @@ -56,6 +57,8 @@ export default function EditorLayout() { const [envContent, setEnvContent] = useState(''); const [originalEnvContent, setOriginalEnvContent] = useState(''); const [envExists, setEnvExists] = useState(false); + const [envFiles, setEnvFiles] = useState([]); + const [selectedEnvFile, setSelectedEnvFile] = useState(''); const [containers, setContainers] = useState([]); const [containerStats, setContainerStats] = useState>({}); const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose'); @@ -259,20 +262,44 @@ export default function EditorLayout() { setContent(text || ''); setOriginalContent(text || ''); - // Load env file + // Load env files try { - const envRes = await apiFetch(`/stacks/${filename}/env`); - if (envRes.ok) { - const envText = await envRes.text(); - setEnvContent(envText || ''); - setOriginalEnvContent(envText || ''); - setEnvExists(true); + const envsRes = await apiFetch(`/stacks/${filename}/envs`); + if (envsRes.ok) { + const { envFiles } = await envsRes.json(); + if (envFiles && envFiles.length > 0) { + setEnvFiles(envFiles); + const firstFile = envFiles[0]; + setSelectedEnvFile(firstFile); + setEnvExists(true); + + // Load specific env file content + const envContentRes = await apiFetch(`/stacks/${filename}/env?file=${encodeURIComponent(firstFile)}`); + if (envContentRes.ok) { + const envText = await envContentRes.text(); + setEnvContent(envText || ''); + setOriginalEnvContent(envText || ''); + } else { + setEnvContent(''); + setOriginalEnvContent(''); + } + } else { + setEnvFiles([]); + setSelectedEnvFile(''); + setEnvContent(''); + setOriginalEnvContent(''); + setEnvExists(false); + } } else { + setEnvFiles([]); + setSelectedEnvFile(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); } } catch { + setEnvFiles([]); + setSelectedEnvFile(''); setEnvContent(''); setOriginalEnvContent(''); setEnvExists(false); @@ -300,10 +327,25 @@ export default function EditorLayout() { } }; + const changeEnvFile = async (file: string) => { + setSelectedEnvFile(file); + setIsFileLoading(true); + try { + const res = await apiFetch(`/stacks/${selectedFile}/env?file=${encodeURIComponent(file)}`); + const text = await res.text(); + setEnvContent(text || ''); + setOriginalEnvContent(text || ''); + } catch (e) { + console.error('Failed to switch env file', e); + } finally { + setIsFileLoading(false); + } + }; + const saveFile = async () => { if (!selectedFile) return; const currentContent = activeTab === 'compose' ? (content || '') : (envContent || ''); - const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env`; + const endpoint = activeTab === 'compose' ? `/stacks/${selectedFile}` : `/stacks/${selectedFile}/env?file=${encodeURIComponent(selectedEnvFile)}`; try { const response = await apiFetch(endpoint, { method: 'PUT', @@ -692,6 +734,8 @@ export default function EditorLayout() { setOriginalContent(''); setEnvContent(''); setOriginalEnvContent(''); + setEnvFiles([]); + setSelectedEnvFile(''); setEnvExists(false); setContainers([]); setIsEditing(false); @@ -987,12 +1031,29 @@ export default function EditorLayout() { {/* Right Column (The Editor) */}
- setActiveTab(value as 'compose' | 'env')}> - - compose.yaml - .env - - +
+ setActiveTab(value as 'compose' | 'env')}> + + compose.yaml + .env + + + + {activeTab === 'env' && envFiles.length > 0 && ( + + )} +
{!isEditing ? (