mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
refactor: migrate to directory-based stack structure
- Updated ComposeService to run docker commands from stack-specific directories, ensuring relative paths resolve correctly. - Refactored FileSystemService to manage stacks as directories, including methods for creating, updating, and deleting stacks. - Implemented automatic migration of existing flat-file stacks to the new directory structure on server startup. - Adjusted API routes to use stack names instead of filenames, simplifying stack management. - Modified frontend components to align with the new stack naming conventions and removed unnecessary file extensions.
This commit is contained in:
+62
-45
@@ -20,6 +20,9 @@ const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-producti
|
|||||||
// ConfigService for persistent auth storage
|
// ConfigService for persistent auth storage
|
||||||
const configService = new ConfigService();
|
const configService = new ConfigService();
|
||||||
|
|
||||||
|
// FileSystemService for stack management
|
||||||
|
const fileSystemService = new FileSystemService();
|
||||||
|
|
||||||
// Cookie settings
|
// Cookie settings
|
||||||
const COOKIE_NAME = 'sencho_token';
|
const COOKIE_NAME = 'sencho_token';
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
@@ -249,67 +252,67 @@ app.get('/api/containers', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileSystemService = new FileSystemService();
|
// Stack Routes - Updated to use stackName (directory name) instead of filename
|
||||||
|
|
||||||
app.get('/api/stacks', async (req: Request, res: Response) => {
|
app.get('/api/stacks', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const files = await fileSystemService.getStackFiles();
|
const stacks = await fileSystemService.getStacks();
|
||||||
res.json(files);
|
res.json(stacks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to fetch stack files' });
|
res.status(500).json({ error: 'Failed to fetch stacks' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stacks/:filename', async (req: Request, res: Response) => {
|
app.get('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
const content = await fileSystemService.getStackContent(filename);
|
const content = await fileSystemService.getStackContent(stackName);
|
||||||
res.send(content);
|
res.send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to read file' });
|
res.status(500).json({ error: 'Failed to read stack' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/stacks/:filename', async (req: Request, res: Response) => {
|
app.put('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
console.log('PUT /api/stacks/:filename', { filename, contentType: typeof content, contentLength: content?.length });
|
console.log('PUT /api/stacks/:stackName', { stackName, contentType: typeof content, contentLength: content?.length });
|
||||||
if (typeof content !== 'string') {
|
if (typeof content !== 'string') {
|
||||||
console.error('Content is not a string:', content);
|
console.error('Content is not a string:', content);
|
||||||
return res.status(400).json({ error: 'Content must be a string' });
|
return res.status(400).json({ error: 'Content must be a string' });
|
||||||
}
|
}
|
||||||
await fileSystemService.saveStackContent(filename, content);
|
await fileSystemService.saveStackContent(stackName, content);
|
||||||
console.log('File saved successfully:', filename);
|
console.log('Stack saved successfully:', stackName);
|
||||||
res.json({ message: 'File saved successfully' });
|
res.json({ message: 'Stack saved successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save file:', error);
|
console.error('Failed to save stack:', error);
|
||||||
res.status(500).json({ error: 'Failed to save file' });
|
res.status(500).json({ error: 'Failed to save stack' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stacks/:filename/env', async (req: Request, res: Response) => {
|
app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
const exists = await fileSystemService.envExists(filename);
|
const exists = await fileSystemService.envExists(stackName);
|
||||||
if (!exists) {
|
if (!exists) {
|
||||||
return res.status(404).json({ error: 'Env file not found' });
|
return res.status(404).json({ error: 'Env file not found' });
|
||||||
}
|
}
|
||||||
const content = await fileSystemService.getEnvContent(filename);
|
const content = await fileSystemService.getEnvContent(stackName);
|
||||||
res.send(content);
|
res.send(content);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to read env file' });
|
res.status(500).json({ error: 'Failed to read env file' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => {
|
app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
const { content } = req.body;
|
const { content } = req.body;
|
||||||
if (typeof content !== 'string') {
|
if (typeof content !== 'string') {
|
||||||
return res.status(400).json({ error: 'Content must be a string' });
|
return res.status(400).json({ error: 'Content must be a string' });
|
||||||
}
|
}
|
||||||
await fileSystemService.saveEnvContent(filename, content);
|
await fileSystemService.saveEnvContent(stackName, content);
|
||||||
res.json({ message: 'Env file saved successfully' });
|
res.json({ message: 'Env file saved successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save env file:', error);
|
console.error('Failed to save env file:', error);
|
||||||
@@ -319,11 +322,11 @@ app.put('/api/stacks/:filename/env', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
app.post('/api/stacks', async (req: Request, res: Response) => {
|
app.post('/api/stacks', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { filename } = req.body;
|
const { stackName } = req.body;
|
||||||
if (!filename || typeof filename !== 'string') {
|
if (!stackName || typeof stackName !== 'string') {
|
||||||
return res.status(400).json({ error: 'Filename is required and must be a string' });
|
return res.status(400).json({ error: 'Stack name is required and must be a string' });
|
||||||
}
|
}
|
||||||
await fileSystemService.createStack(filename);
|
await fileSystemService.createStack(stackName);
|
||||||
res.json({ message: 'Stack created successfully' });
|
res.json({ message: 'Stack created successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create stack:', error);
|
console.error('Failed to create stack:', error);
|
||||||
@@ -331,10 +334,10 @@ app.post('/api/stacks', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.delete('/api/stacks/:filename', async (req: Request, res: Response) => {
|
app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
await fileSystemService.deleteStack(filename);
|
await fileSystemService.deleteStack(stackName);
|
||||||
res.json({ message: 'Stack deleted successfully' });
|
res.json({ message: 'Stack deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to delete stack:', error);
|
console.error('Failed to delete stack:', error);
|
||||||
@@ -342,10 +345,9 @@ app.delete('/api/stacks/:filename', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/stacks/:filename/containers', async (req: Request, res: Response) => {
|
app.get('/api/stacks/:stackName/containers', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
const stackName = filename.replace(/\.yml$/, '');
|
|
||||||
const dockerController = DockerController.getInstance();
|
const dockerController = DockerController.getInstance();
|
||||||
const containers = await dockerController.getContainersByStack(stackName);
|
const containers = await dockerController.getContainersByStack(stackName);
|
||||||
res.json(containers);
|
res.json(containers);
|
||||||
@@ -389,20 +391,20 @@ app.post('/api/containers/:id/restart', async (req: Request, res: Response) => {
|
|||||||
|
|
||||||
const composeService = new ComposeService();
|
const composeService = new ComposeService();
|
||||||
|
|
||||||
app.post('/api/stacks/:filename/up', async (req: Request, res: Response) => {
|
app.post('/api/stacks/:stackName/up', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
composeService.runCommand(filename, 'up', terminalWs || undefined);
|
composeService.runCommand(stackName, 'up', terminalWs || undefined);
|
||||||
res.json({ status: 'Command started' });
|
res.json({ status: 'Command started' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to start command' });
|
res.status(500).json({ error: 'Failed to start command' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => {
|
app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
composeService.runCommand(filename, 'down', terminalWs || undefined);
|
composeService.runCommand(stackName, 'down', terminalWs || undefined);
|
||||||
res.json({ status: 'Command started' });
|
res.json({ status: 'Command started' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: 'Failed to start command' });
|
res.status(500).json({ error: 'Failed to start command' });
|
||||||
@@ -410,11 +412,11 @@ app.post('/api/stacks/:filename/down', async (req: Request, res: Response) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Update stack: pull images and recreate containers
|
// Update stack: pull images and recreate containers
|
||||||
app.post('/api/stacks/:filename/update', async (req: Request, res: Response) => {
|
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const filename = req.params.filename as string;
|
const stackName = req.params.stackName as string;
|
||||||
// Run update asynchronously, don't wait for completion
|
// Run update asynchronously, don't wait for completion
|
||||||
composeService.updateStack(filename, terminalWs || undefined).catch(error => {
|
composeService.updateStack(stackName, terminalWs || undefined).catch(error => {
|
||||||
console.error('Update stack error:', error);
|
console.error('Update stack error:', error);
|
||||||
});
|
});
|
||||||
res.json({ status: 'Update started' });
|
res.json({ status: 'Update started' });
|
||||||
@@ -505,6 +507,21 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
// Start server with migration
|
||||||
console.log(`Server running on port ${PORT}`);
|
async function startServer() {
|
||||||
});
|
try {
|
||||||
|
// Run migration before starting server
|
||||||
|
console.log('Running stack migration check...');
|
||||||
|
await fileSystemService.migrateFlatToDirectory();
|
||||||
|
console.log('Migration check completed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
// Continue starting server even if migration fails
|
||||||
|
}
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
startServer();
|
||||||
|
|||||||
@@ -9,11 +9,24 @@ export class ComposeService {
|
|||||||
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
||||||
}
|
}
|
||||||
|
|
||||||
runCommand(filename: string, action: 'up' | 'down', ws?: WebSocket) {
|
/**
|
||||||
const filePath = path.join(this.baseDir, filename);
|
* Run docker compose up or down command
|
||||||
const args = action === 'up' ? ['compose', '-f', filePath, 'up', '-d'] : ['compose', '-f', filePath, 'down'];
|
* CRITICAL: cwd is set to the stack directory so relative paths in compose files
|
||||||
|
* resolve correctly inside the isolated stack folder
|
||||||
|
*/
|
||||||
|
runCommand(stackName: string, action: 'up' | 'down', ws?: WebSocket) {
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
|
||||||
|
// Run docker compose from within the stack directory
|
||||||
|
// This ensures relative paths (e.g., ./data:/config) resolve correctly
|
||||||
|
const args = action === 'up'
|
||||||
|
? ['compose', 'up', '-d']
|
||||||
|
: ['compose', 'down'];
|
||||||
|
|
||||||
const child = spawn('docker', args, { shell: true });
|
const child = spawn('docker', args, {
|
||||||
|
cwd: stackDir, // CRITICAL: Set working directory to stack folder
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
child.stdout.on('data', (data: Buffer) => {
|
child.stdout.on('data', (data: Buffer) => {
|
||||||
@@ -34,9 +47,12 @@ export class ComposeService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update command: pull images first, then recreate containers
|
/**
|
||||||
async updateStack(filename: string, ws?: WebSocket): Promise<void> {
|
* Update stack: pull images first, then recreate containers
|
||||||
const filePath = path.join(this.baseDir, filename);
|
* CRITICAL: cwd is set to the stack directory so relative paths resolve correctly
|
||||||
|
*/
|
||||||
|
async updateStack(stackName: string, ws?: WebSocket): Promise<void> {
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
|
||||||
const sendOutput = (data: string) => {
|
const sendOutput = (data: string) => {
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
@@ -47,7 +63,10 @@ export class ComposeService {
|
|||||||
// Step 1: Pull images
|
// Step 1: Pull images
|
||||||
sendOutput('=== Pulling latest images ===\n');
|
sendOutput('=== Pulling latest images ===\n');
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const pullProcess = spawn('docker', ['compose', '-f', filePath, 'pull'], { shell: true });
|
const pullProcess = spawn('docker', ['compose', 'pull'], {
|
||||||
|
cwd: stackDir,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
|
||||||
pullProcess.stdout.on('data', (data: Buffer) => {
|
pullProcess.stdout.on('data', (data: Buffer) => {
|
||||||
sendOutput(data.toString());
|
sendOutput(data.toString());
|
||||||
@@ -76,7 +95,10 @@ export class ComposeService {
|
|||||||
// Step 2: Recreate containers with new images
|
// Step 2: Recreate containers with new images
|
||||||
sendOutput('=== Recreating containers ===\n');
|
sendOutput('=== Recreating containers ===\n');
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const upProcess = spawn('docker', ['compose', '-f', filePath, 'up', '-d'], { shell: true });
|
const upProcess = spawn('docker', ['compose', 'up', '-d'], {
|
||||||
|
cwd: stackDir,
|
||||||
|
shell: true
|
||||||
|
});
|
||||||
|
|
||||||
upProcess.stdout.on('data', (data: Buffer) => {
|
upProcess.stdout.on('data', (data: Buffer) => {
|
||||||
sendOutput(data.toString());
|
sendOutput(data.toString());
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs, Dirent } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export class FileSystemService {
|
export class FileSystemService {
|
||||||
@@ -8,41 +8,109 @@ export class FileSystemService {
|
|||||||
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
this.baseDir = process.env.COMPOSE_DIR || path.join(process.cwd(), '..', 'mock_data', 'docker', 'compose');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStackFiles(): Promise<string[]> {
|
/**
|
||||||
|
* Check if a directory contains a valid compose file
|
||||||
|
*/
|
||||||
|
private async hasComposeFile(dir: string): Promise<boolean> {
|
||||||
|
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||||
|
|
||||||
|
for (const file of composeFiles) {
|
||||||
|
try {
|
||||||
|
await fs.access(path.join(dir, file));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Continue checking other options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the path to the compose file for a stack
|
||||||
|
* Throws if no compose file is found
|
||||||
|
*/
|
||||||
|
private async getComposeFilePath(stackName: string): Promise<string> {
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
const composeFiles = ['compose.yaml', 'compose.yml', 'docker-compose.yaml', 'docker-compose.yml'];
|
||||||
|
|
||||||
|
for (const file of composeFiles) {
|
||||||
|
const filePath = path.join(stackDir, file);
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return filePath;
|
||||||
|
} catch {
|
||||||
|
// Continue checking other options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No compose file found for stack: ${stackName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all stacks (directories containing compose files)
|
||||||
|
* Returns array of stack names (directory names)
|
||||||
|
*/
|
||||||
|
async getStacks(): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const files = await fs.readdir(this.baseDir);
|
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||||
return files.filter(file => file.endsWith('.yml') || file.endsWith('.yaml'));
|
const stackNames: string[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.isDirectory()) continue;
|
||||||
|
|
||||||
|
const stackDir = path.join(this.baseDir, item.name);
|
||||||
|
const hasCompose = await this.hasComposeFile(stackDir);
|
||||||
|
|
||||||
|
if (hasCompose) {
|
||||||
|
stackNames.push(item.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return stackNames;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading stack files:', error);
|
console.error('Error reading stacks:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStackContent(filename: string): Promise<string> {
|
/**
|
||||||
const filePath = path.join(this.baseDir, filename);
|
* Get the content of a stack's compose file
|
||||||
|
*/
|
||||||
|
async getStackContent(stackName: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
|
const filePath = await this.getComposeFilePath(stackName);
|
||||||
return await fs.readFile(filePath, 'utf-8');
|
return await fs.readFile(filePath, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading file:', error);
|
console.error('Error reading stack content:', error);
|
||||||
throw new Error(`Failed to read file: ${filename}`);
|
throw new Error(`Failed to read stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveStackContent(filename: string, content: string): Promise<void> {
|
/**
|
||||||
const filePath = path.join(this.baseDir, filename);
|
* Save content to a stack's compose file
|
||||||
|
* Always writes to compose.yaml (standardizing on this filename)
|
||||||
|
*/
|
||||||
|
async saveStackContent(stackName: string, content: string): Promise<void> {
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
const filePath = path.join(stackDir, 'compose.yaml');
|
||||||
|
|
||||||
console.log('Saving to path:', filePath);
|
console.log('Saving to path:', filePath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(filePath, content, 'utf-8');
|
await fs.writeFile(filePath, content, 'utf-8');
|
||||||
console.log('File written successfully');
|
console.log('File written successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing file:', error);
|
console.error('Error writing file:', error);
|
||||||
throw new Error(`Failed to save file: ${filename}`);
|
throw new Error(`Failed to save stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async envExists(filename: string): Promise<boolean> {
|
/**
|
||||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
* Check if a stack has an .env file
|
||||||
const envPath = path.join(this.baseDir, envFilename);
|
*/
|
||||||
|
async envExists(stackName: string): Promise<boolean> {
|
||||||
|
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||||
try {
|
try {
|
||||||
await fs.access(envPath);
|
await fs.access(envPath);
|
||||||
return true;
|
return true;
|
||||||
@@ -51,66 +119,156 @@ export class FileSystemService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEnvContent(filename: string): Promise<string> {
|
/**
|
||||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
* Get the content of a stack's .env file
|
||||||
const envPath = path.join(this.baseDir, envFilename);
|
*/
|
||||||
|
async getEnvContent(stackName: string): Promise<string> {
|
||||||
|
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||||
try {
|
try {
|
||||||
return await fs.readFile(envPath, 'utf-8');
|
return await fs.readFile(envPath, 'utf-8');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading env file:', error);
|
console.error('Error reading env file:', error);
|
||||||
throw new Error(`Failed to read env file for: ${filename}`);
|
throw new Error(`Failed to read env file for stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async saveEnvContent(filename: string, content: string): Promise<void> {
|
/**
|
||||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
* Save content to a stack's .env file
|
||||||
const envPath = path.join(this.baseDir, envFilename);
|
*/
|
||||||
|
async saveEnvContent(stackName: string, content: string): Promise<void> {
|
||||||
|
const envPath = path.join(this.baseDir, stackName, '.env');
|
||||||
console.log('Saving env to path:', envPath);
|
console.log('Saving env to path:', envPath);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(envPath, content, 'utf-8');
|
await fs.writeFile(envPath, content, 'utf-8');
|
||||||
console.log('Env file written successfully');
|
console.log('Env file written successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error writing env file:', error);
|
console.error('Error writing env file:', error);
|
||||||
throw new Error(`Failed to save env file for: ${filename}`);
|
throw new Error(`Failed to save env file for stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStack(filename: string): Promise<void> {
|
/**
|
||||||
if (!filename.endsWith('.yml')) {
|
* Create a new stack (directory with boilerplate compose.yaml)
|
||||||
throw new Error('Filename must end with .yml');
|
*/
|
||||||
|
async createStack(stackName: string): Promise<void> {
|
||||||
|
// Validate stack name (no special characters, not empty)
|
||||||
|
if (!stackName || !/^[a-zA-Z0-9_-]+$/.test(stackName)) {
|
||||||
|
throw new Error('Stack name must contain only alphanumeric characters, underscores, or hyphens');
|
||||||
}
|
}
|
||||||
const filePath = path.join(this.baseDir, filename);
|
|
||||||
const boilerplate = `version: '3.8'
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
|
||||||
services:
|
// Check if directory already exists
|
||||||
|
try {
|
||||||
|
await fs.access(stackDir);
|
||||||
|
throw new Error(`Stack "${stackName}" already exists`);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error.message.includes('already exists')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Directory doesn't exist, proceed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the directory
|
||||||
|
await fs.mkdir(stackDir, { recursive: true });
|
||||||
|
|
||||||
|
// Write boilerplate compose.yaml
|
||||||
|
const composePath = path.join(stackDir, 'compose.yaml');
|
||||||
|
const boilerplate = `services:
|
||||||
# Add your services here
|
# Add your services here
|
||||||
`;
|
`;
|
||||||
try {
|
try {
|
||||||
await fs.writeFile(filePath, boilerplate, 'utf-8');
|
await fs.writeFile(composePath, boilerplate, 'utf-8');
|
||||||
console.log('Stack file created successfully:', filename);
|
console.log('Stack created successfully:', stackName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating stack file:', error);
|
console.error('Error creating stack:', error);
|
||||||
throw new Error(`Failed to create stack file: ${filename}`);
|
throw new Error(`Failed to create stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteStack(filename: string): Promise<void> {
|
/**
|
||||||
const filePath = path.join(this.baseDir, filename);
|
* Delete a stack (entire directory and its contents)
|
||||||
const envFilename = filename.replace(/\.yml$/, '.env');
|
*/
|
||||||
const envPath = path.join(this.baseDir, envFilename);
|
async deleteStack(stackName: string): Promise<void> {
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.unlink(filePath);
|
await fs.rm(stackDir, { recursive: true, force: true });
|
||||||
console.log('Stack file deleted successfully:', filename);
|
console.log('Stack deleted successfully:', stackName);
|
||||||
// Try to delete env file if it exists
|
} catch (error) {
|
||||||
|
console.error('Error deleting stack:', error);
|
||||||
|
throw new Error(`Failed to delete stack: ${stackName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the base directory path for stacks
|
||||||
|
*/
|
||||||
|
getBaseDir(): string {
|
||||||
|
return this.baseDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migrate existing flat-file stacks to directory-based structure
|
||||||
|
* This runs automatically on server startup
|
||||||
|
*/
|
||||||
|
async migrateFlatToDirectory(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// Ensure base directory exists
|
||||||
try {
|
try {
|
||||||
await fs.unlink(envPath);
|
await fs.access(this.baseDir);
|
||||||
console.log('Associated env file deleted:', envFilename);
|
|
||||||
} catch {
|
} catch {
|
||||||
// Env file doesn't exist, ignore
|
console.log('Creating compose directory:', this.baseDir);
|
||||||
|
await fs.mkdir(this.baseDir, { recursive: true });
|
||||||
|
return; // No files to migrate in a new directory
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
// Only process .yml/.yaml files (skip directories and other files)
|
||||||
|
if (!item.isFile()) continue;
|
||||||
|
if (!item.name.endsWith('.yml') && !item.name.endsWith('.yaml')) continue;
|
||||||
|
|
||||||
|
const stackName = item.name.replace(/\.(yml|yaml)$/, '');
|
||||||
|
const stackDir = path.join(this.baseDir, stackName);
|
||||||
|
|
||||||
|
// Check if target directory already exists
|
||||||
|
try {
|
||||||
|
await fs.access(stackDir);
|
||||||
|
console.log(`Skipping migration for "${stackName}": directory already exists`);
|
||||||
|
continue;
|
||||||
|
} catch {
|
||||||
|
// Directory doesn't exist, proceed with migration
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migrating stack: ${stackName}`);
|
||||||
|
|
||||||
|
// Create the stack directory
|
||||||
|
await fs.mkdir(stackDir, { recursive: true });
|
||||||
|
|
||||||
|
// Move compose file to new location (standardize on compose.yaml)
|
||||||
|
const oldComposePath = path.join(this.baseDir, item.name);
|
||||||
|
const newComposePath = path.join(stackDir, 'compose.yaml');
|
||||||
|
await fs.rename(oldComposePath, newComposePath);
|
||||||
|
|
||||||
|
// Move env file if it exists (old pattern: stackname.env)
|
||||||
|
const oldEnvPath = path.join(this.baseDir, `${stackName}.env`);
|
||||||
|
const newEnvPath = path.join(stackDir, '.env');
|
||||||
|
try {
|
||||||
|
await fs.access(oldEnvPath);
|
||||||
|
await fs.rename(oldEnvPath, newEnvPath);
|
||||||
|
console.log(`Migrated env file for: ${stackName}`);
|
||||||
|
} catch {
|
||||||
|
// No env file to migrate, that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Successfully migrated stack: ${stackName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting stack file:', error);
|
console.error('Migration error:', error);
|
||||||
throw new Error(`Failed to delete stack file: ${filename}`);
|
// Don't throw - allow the server to start even if migration fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+11
-7
@@ -1,19 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
sencho:
|
sencho:
|
||||||
|
image: saelix/sencho:latest
|
||||||
build: .
|
build: .
|
||||||
container_name: sencho
|
container_name: sencho
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
volumes:
|
volumes:
|
||||||
# Docker socket for managing containers
|
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
# Compose files directory
|
|
||||||
- ${COMPOSE_DIR:-./mock_data/docker/compose}:/app/compose
|
|
||||||
# Persistent data directory for auth config
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|
||||||
|
# Stacks Directory
|
||||||
|
# Left Side = Path on your host server (Change this to your actual folder)
|
||||||
|
# Right Side = Path inside Sencho (Do NOT change)
|
||||||
|
- /path/to/your/docker/compose:/app/compose
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
# Tell Sencho's backend where to look inside the container
|
||||||
- JWT_SECRET=${JWT_SECRET:-change-this-secret-in-production}
|
|
||||||
- COMPOSE_DIR=/app/compose
|
- COMPOSE_DIR=/app/compose
|
||||||
- DATA_DIR=/app/data
|
- DATA_DIR=/app/data
|
||||||
restart: unless-stopped
|
# Generate a secure random string for this in production
|
||||||
|
- JWT_SECRET=change-me-in-production
|
||||||
|
|||||||
@@ -356,11 +356,12 @@ export default function EditorLayout() {
|
|||||||
|
|
||||||
const handleCreateStack = async () => {
|
const handleCreateStack = async () => {
|
||||||
if (!newStackName.trim()) return;
|
if (!newStackName.trim()) return;
|
||||||
const filename = newStackName.endsWith('.yml') ? newStackName : newStackName + '.yml';
|
// Send stackName directly (no .yml extension - backend creates directory)
|
||||||
|
const stackName = newStackName.trim();
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/stacks', {
|
const response = await apiFetch('/stacks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ filename }),
|
body: JSON.stringify({ stackName }),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error('Failed to create stack');
|
if (!response.ok) throw new Error('Failed to create stack');
|
||||||
setCreateDialogOpen(false);
|
setCreateDialogOpen(false);
|
||||||
@@ -388,18 +389,17 @@ export default function EditorLayout() {
|
|||||||
const safeContent = content || '';
|
const safeContent = content || '';
|
||||||
const safeEnvContent = envContent || '';
|
const safeEnvContent = envContent || '';
|
||||||
|
|
||||||
// Get stack name without extension
|
// Stack name is now the same as selectedFile (no extension to strip)
|
||||||
const stackName = selectedFile ? selectedFile.replace('.yml', '').replace('.yaml', '') : '';
|
const stackName = selectedFile || '';
|
||||||
|
|
||||||
// Filter files based on search query
|
// Filter files based on search query
|
||||||
const filteredFiles = files.filter(file => {
|
const filteredFiles = files.filter(file => {
|
||||||
const nameWithoutExt = file.replace('.yml', '').replace('.yaml', '').toLowerCase();
|
return file.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
return nameWithoutExt.includes(searchQuery.toLowerCase());
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get display name for stack (without extension)
|
// Get display name for stack (now just returns the name as-is since no extension)
|
||||||
const getDisplayName = (filename: string) => {
|
const getDisplayName = (stackName: string) => {
|
||||||
return filename.replace('.yml', '').replace('.yaml', '');
|
return stackName;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -104,17 +104,18 @@ export default function HomeDashboard() {
|
|||||||
|
|
||||||
const handleCreateStack = async () => {
|
const handleCreateStack = async () => {
|
||||||
if (!newStackName.trim() || !convertedYaml) return;
|
if (!newStackName.trim() || !convertedYaml) return;
|
||||||
const filename = newStackName.endsWith('.yml') ? newStackName : newStackName + '.yml';
|
// Send stackName directly (no .yml extension - backend creates directory)
|
||||||
|
const stackName = newStackName.trim();
|
||||||
try {
|
try {
|
||||||
// Create the stack
|
// Create the stack
|
||||||
const createResponse = await apiFetch('/stacks', {
|
const createResponse = await apiFetch('/stacks', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ filename }),
|
body: JSON.stringify({ stackName }),
|
||||||
});
|
});
|
||||||
if (!createResponse.ok) throw new Error('Failed to create stack');
|
if (!createResponse.ok) throw new Error('Failed to create stack');
|
||||||
|
|
||||||
// Save the converted YAML content
|
// Save the converted YAML content
|
||||||
const saveResponse = await apiFetch(`/stacks/${filename}`, {
|
const saveResponse = await apiFetch(`/stacks/${stackName}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({ content: convertedYaml }),
|
body: JSON.stringify({ content: convertedYaml }),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user