mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 12:48:10 +00:00
feat: enhance environment file handling by resolving multiple env files and adding selection functionality in the editor
This commit is contained in:
+64
-25
@@ -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
|
// Helper: resolve all env file paths dynamically from compose.yaml's env_file field
|
||||||
async function resolveEnvFilePath(stackName: string): Promise<string> {
|
async function resolveAllEnvFilePaths(stackName: string): Promise<string[]> {
|
||||||
const stackDir = path.join(fileSystemService.getBaseDir(), stackName);
|
const stackDir = path.join(fileSystemService.getBaseDir(), stackName);
|
||||||
const defaultEnvPath = path.join(stackDir, '.env');
|
const defaultEnvPath = path.join(stackDir, '.env');
|
||||||
|
|
||||||
@@ -381,47 +381,74 @@ async function resolveEnvFilePath(stackName: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!composeContent) return defaultEnvPath;
|
if (!composeContent) return [defaultEnvPath];
|
||||||
|
|
||||||
const parsed = YAML.parse(composeContent);
|
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<string>();
|
||||||
|
|
||||||
|
// Iterate through all services and collect every env_file declaration
|
||||||
for (const serviceName of Object.keys(parsed.services)) {
|
for (const serviceName of Object.keys(parsed.services)) {
|
||||||
const service = parsed.services[serviceName];
|
const service = parsed.services[serviceName];
|
||||||
if (!service?.env_file) continue;
|
if (!service?.env_file) continue;
|
||||||
|
|
||||||
let envFilePath: string;
|
|
||||||
|
|
||||||
if (typeof service.env_file === 'string') {
|
if (typeof service.env_file === 'string') {
|
||||||
envFilePath = service.env_file;
|
const resolvedPath = path.isAbsolute(service.env_file)
|
||||||
} else if (Array.isArray(service.env_file) && service.env_file.length > 0) {
|
? service.env_file
|
||||||
// Handle array format: take the first entry
|
: path.resolve(stackDir, service.env_file);
|
||||||
const first = service.env_file[0];
|
envFiles.add(resolvedPath);
|
||||||
envFilePath = typeof first === 'string' ? first : (first?.path || '');
|
} else if (Array.isArray(service.env_file)) {
|
||||||
} else {
|
for (const entry of service.env_file) {
|
||||||
continue;
|
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) {
|
} catch (error) {
|
||||||
console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, 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) => {
|
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const stackName = req.params.stackName as string;
|
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 {
|
try {
|
||||||
await fsPromises.access(envPath);
|
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' });
|
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');
|
await fsPromises.writeFile(envPath, content, 'utf-8');
|
||||||
res.json({ message: 'Env file saved successfully' });
|
res.json({ message: 'Env file saved successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
sencho:
|
sencho:
|
||||||
image: saelix/sencho:latest
|
image: saelix/sencho:latest
|
||||||
|
build: .
|
||||||
container_name: sencho
|
container_name: sencho
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t
|
|||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
import { HoverCard, HoverCardContent, HoverCardTrigger } from './ui/hover-card';
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from './ui/dropdown-menu';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { NotificationSettingsModal } from './NotificationSettingsModal';
|
import { NotificationSettingsModal } from './NotificationSettingsModal';
|
||||||
import { StackAlertSheet } from './StackAlertSheet';
|
import { StackAlertSheet } from './StackAlertSheet';
|
||||||
interface ContainerInfo {
|
interface ContainerInfo {
|
||||||
@@ -56,6 +57,8 @@ export default function EditorLayout() {
|
|||||||
const [envContent, setEnvContent] = useState<string>('');
|
const [envContent, setEnvContent] = useState<string>('');
|
||||||
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
|
const [originalEnvContent, setOriginalEnvContent] = useState<string>('');
|
||||||
const [envExists, setEnvExists] = useState<boolean>(false);
|
const [envExists, setEnvExists] = useState<boolean>(false);
|
||||||
|
const [envFiles, setEnvFiles] = useState<string[]>([]);
|
||||||
|
const [selectedEnvFile, setSelectedEnvFile] = useState<string>('');
|
||||||
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
const [containers, setContainers] = useState<ContainerInfo[]>([]);
|
||||||
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
|
const [containerStats, setContainerStats] = useState<Record<string, { cpu: string, ram: string, net: string, lastRx?: number, lastTx?: number }>>({});
|
||||||
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
const [activeTab, setActiveTab] = useState<'compose' | 'env'>('compose');
|
||||||
@@ -259,20 +262,44 @@ export default function EditorLayout() {
|
|||||||
setContent(text || '');
|
setContent(text || '');
|
||||||
setOriginalContent(text || '');
|
setOriginalContent(text || '');
|
||||||
|
|
||||||
// Load env file
|
// Load env files
|
||||||
try {
|
try {
|
||||||
const envRes = await apiFetch(`/stacks/${filename}/env`);
|
const envsRes = await apiFetch(`/stacks/${filename}/envs`);
|
||||||
if (envRes.ok) {
|
if (envsRes.ok) {
|
||||||
const envText = await envRes.text();
|
const { envFiles } = await envsRes.json();
|
||||||
setEnvContent(envText || '');
|
if (envFiles && envFiles.length > 0) {
|
||||||
setOriginalEnvContent(envText || '');
|
setEnvFiles(envFiles);
|
||||||
setEnvExists(true);
|
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 {
|
} else {
|
||||||
|
setEnvFiles([]);
|
||||||
|
setSelectedEnvFile('');
|
||||||
setEnvContent('');
|
setEnvContent('');
|
||||||
setOriginalEnvContent('');
|
setOriginalEnvContent('');
|
||||||
setEnvExists(false);
|
setEnvExists(false);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
setEnvFiles([]);
|
||||||
|
setSelectedEnvFile('');
|
||||||
setEnvContent('');
|
setEnvContent('');
|
||||||
setOriginalEnvContent('');
|
setOriginalEnvContent('');
|
||||||
setEnvExists(false);
|
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 () => {
|
const saveFile = async () => {
|
||||||
if (!selectedFile) return;
|
if (!selectedFile) return;
|
||||||
const currentContent = activeTab === 'compose' ? (content || '') : (envContent || '');
|
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 {
|
try {
|
||||||
const response = await apiFetch(endpoint, {
|
const response = await apiFetch(endpoint, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -692,6 +734,8 @@ export default function EditorLayout() {
|
|||||||
setOriginalContent('');
|
setOriginalContent('');
|
||||||
setEnvContent('');
|
setEnvContent('');
|
||||||
setOriginalEnvContent('');
|
setOriginalEnvContent('');
|
||||||
|
setEnvFiles([]);
|
||||||
|
setSelectedEnvFile('');
|
||||||
setEnvExists(false);
|
setEnvExists(false);
|
||||||
setContainers([]);
|
setContainers([]);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -987,12 +1031,29 @@ export default function EditorLayout() {
|
|||||||
{/* Right Column (The Editor) */}
|
{/* Right Column (The Editor) */}
|
||||||
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
|
<Card className="rounded-xl border-muted overflow-hidden flex flex-col h-full min-h-[600px] bg-card">
|
||||||
<div className="p-4 border-b border-muted flex items-center justify-between">
|
<div className="p-4 border-b border-muted flex items-center justify-between">
|
||||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
<div className="flex items-center gap-4">
|
||||||
<TabsList className="bg-muted">
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as 'compose' | 'env')}>
|
||||||
<TabsTrigger value="compose" className="rounded-lg">compose.yaml</TabsTrigger>
|
<TabsList className="bg-muted">
|
||||||
<TabsTrigger value="env" disabled={!envExists} className="rounded-lg">.env</TabsTrigger>
|
<TabsTrigger value="compose" className="rounded-lg">compose.yaml</TabsTrigger>
|
||||||
</TabsList>
|
<TabsTrigger value="env" disabled={!envExists} className="rounded-lg">.env</TabsTrigger>
|
||||||
</Tabs>
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{activeTab === 'env' && envFiles.length > 0 && (
|
||||||
|
<Select value={selectedEnvFile} onValueChange={changeEnvFile} disabled={isEditing || isFileLoading}>
|
||||||
|
<SelectTrigger className="h-9 text-xs bg-muted border-none min-w-[200px]">
|
||||||
|
<SelectValue placeholder="Select environment file" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{envFiles.map((file) => (
|
||||||
|
<SelectItem key={file} value={file} className="text-xs">
|
||||||
|
{file.split('/').pop()}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{!isEditing ? (
|
{!isEditing ? (
|
||||||
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
<Button size="sm" variant="default" className="rounded-lg" onClick={enterEditMode}>
|
||||||
|
|||||||
Reference in New Issue
Block a user