diff --git a/backend/src/index.ts b/backend/src/index.ts
index 38d4f11b..d6b90882 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -497,6 +497,7 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) =
});
// Direct container stop - bypasses docker compose for legacy container support
+// Only stops containers that are currently running to avoid 304 errors
app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
try {
const stackName = req.params.stackName as string;
@@ -504,7 +505,9 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
const containers = await dockerController.getContainersByStack(stackName);
if (containers && containers.length > 0) {
for (const c of containers) {
- await dockerController.stopContainer(c.Id);
+ if (c.State === 'running') {
+ await dockerController.stopContainer(c.Id);
+ }
}
}
res.json({ status: 'Containers stopped' });
@@ -514,6 +517,27 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => {
}
});
+// Direct container start - bypasses docker compose for legacy container support
+// Only starts containers that are not currently running
+app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => {
+ try {
+ const stackName = req.params.stackName as string;
+ const dockerController = DockerController.getInstance();
+ const containers = await dockerController.getContainersByStack(stackName);
+ if (containers && containers.length > 0) {
+ for (const c of containers) {
+ if (c.State !== 'running') {
+ await dockerController.startContainer(c.Id);
+ }
+ }
+ }
+ res.json({ status: 'Containers started' });
+ } catch (error) {
+ console.error('Failed to start containers:', error);
+ res.status(500).json({ error: 'Failed to start containers' });
+ }
+});
+
// Update stack: pull images and recreate containers
app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => {
try {
diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts
index bf439c11..165569a6 100644
--- a/backend/src/services/ComposeService.ts
+++ b/backend/src/services/ComposeService.ts
@@ -162,12 +162,16 @@ export class ComposeService {
}
});
+ let errorLog = '';
+
upProcess.stdout.on('data', (data: Buffer) => {
sendOutput(data.toString());
});
upProcess.stderr.on('data', (data: Buffer) => {
- sendOutput(data.toString());
+ const text = data.toString();
+ errorLog += text;
+ sendOutput(text);
});
upProcess.on('close', (code: number | null) => {
@@ -176,7 +180,7 @@ export class ComposeService {
resolve();
} else {
sendOutput(`=== Update failed with code ${code} ===\n`);
- reject(new Error(`Up failed with code ${code}`));
+ reject(new Error(errorLog.trim() || `Up failed with code ${code}`));
}
});
diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts
index 9fc5119a..9a5685b2 100644
--- a/backend/src/services/DockerController.ts
+++ b/backend/src/services/DockerController.ts
@@ -39,16 +39,16 @@ class DockerController {
public async getContainersByStack(stackName: string) {
const stackDir = path.join(COMPOSE_DIR, stackName);
-
+
try {
- const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
+ const { stdout, stderr } = await execAsync('docker compose ps --format json -a', {
cwd: stackDir,
- env: {
- ...process.env,
- PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
+ env: {
+ ...process.env,
+ PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin'
}
});
-
+
// Robust JSON parsing - handle both JSON array and newline-separated JSON objects
// Docker Compose v2 may return either format depending on version
interface ComposeContainer {
@@ -57,9 +57,9 @@ class DockerController {
State?: string;
Status?: string;
}
-
+
let containers: ComposeContainer[] = [];
-
+
// Only parse if stdout has content
if (stdout && stdout.trim() !== '') {
try {
@@ -78,7 +78,7 @@ class DockerController {
}
}
}
-
+
// If containers found via docker compose ps, return them
if (containers.length > 0) {
// Map to frontend's expected interface
@@ -91,16 +91,16 @@ class DockerController {
Status: c.Status || ''
}));
}
-
+
// SMART FALLBACK: Trigger when docker compose ps returns empty
// This handles legacy containers with incorrect project labels
return await this.smartFallback(stackName, stackDir);
-
+
} catch (error) {
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file)
const execError = error as { stderr?: string; message?: string };
console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message);
-
+
// Try smart fallback even on error
return await this.smartFallback(stackName, stackDir);
}
@@ -117,7 +117,7 @@ class DockerController {
// Try multiple valid compose file names
const composeFileNames = ['compose.yaml', 'docker-compose.yml', 'compose.yml', 'docker-compose.yaml'];
let yamlContent: string | null = null;
-
+
for (const fileName of composeFileNames) {
try {
yamlContent = await fs.readFile(path.join(stackDir, fileName), 'utf-8');
@@ -127,14 +127,14 @@ class DockerController {
continue;
}
}
-
+
if (!yamlContent) {
// No compose file found
return [];
}
-
+
const parsedYaml = yaml.parse(yamlContent);
-
+
if (!parsedYaml || !parsedYaml.services) return [];
// 2. Extract expected container names with legacy prefix support
@@ -155,7 +155,7 @@ class DockerController {
// 3. Query the raw Docker daemon
const allContainers = await this.docker.listContainers({ all: true });
-
+
// 4. Match containers by name
const fallbackContainers = allContainers.filter(container => {
// container.Names usually looks like ['/plex']
@@ -213,7 +213,7 @@ class DockerController {
public async execContainer(containerId: string, ws: WebSocket) {
try {
const container = this.docker.getContainer(containerId);
-
+
// Try bash first, fall back to sh
let exec: Docker.Exec;
try {
@@ -240,25 +240,24 @@ class DockerController {
this.execStream = stream;
- // Handle output from container
+ // Handle output from container - send raw text directly
stream.on('data', (chunk: Buffer) => {
- ws.send(JSON.stringify({ type: 'output', data: chunk.toString() }));
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(chunk.toString());
+ }
});
stream.on('error', (err: Error) => {
- ws.send(JSON.stringify({ type: 'error', message: err.message }));
+ console.error('Exec stream error:', err.message);
});
stream.on('end', () => {
- ws.send(JSON.stringify({ type: 'exit' }));
this.execStream = null;
this.currentExec = null;
});
-
- ws.send(JSON.stringify({ type: 'connected' }));
} catch (error) {
const err = error as Error;
- ws.send(JSON.stringify({ type: 'error', message: err.message }));
+ console.error('Failed to exec container:', err.message);
}
}
diff --git a/frontend/src/components/BashExecModal.tsx b/frontend/src/components/BashExecModal.tsx
index 29d629ae..16d56e85 100644
--- a/frontend/src/components/BashExecModal.tsx
+++ b/frontend/src/components/BashExecModal.tsx
@@ -39,7 +39,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(terminalRef.current);
-
+
setTimeout(() => {
fitAddon.fit();
}, 100);
@@ -48,7 +48,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
fitAddonRef.current = fitAddon;
// Connect to WebSocket for bash exec
- const ws = new WebSocket('ws://localhost:3000');
+ const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const ws = new WebSocket(`${wsProtocol}//${window.location.host}`);
wsRef.current = ws;
ws.onopen = () => {
@@ -60,20 +61,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
ws.onmessage = (event) => {
- try {
- const data = JSON.parse(event.data);
- if (data.type === 'output') {
- term.write(data.data);
- } else if (data.type === 'error') {
- term.write(`\r\n\x1b[31mError: ${data.message}\x1b[0m\r\n`);
- } else if (data.type === 'exit') {
- term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
- setIsConnected(false);
- }
- } catch {
- // Raw output
- term.write(event.data);
- }
+ // Write raw text directly to terminal
+ term.write(event.data);
};
ws.onerror = () => {
@@ -82,6 +71,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
ws.onclose = () => {
+ term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n');
setIsConnected(false);
};
@@ -110,7 +100,7 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN
};
window.addEventListener('resize', handleResize);
-
+
return () => {
window.removeEventListener('resize', handleResize);
};
@@ -165,8 +155,8 @@ export default function BashExecModal({ isOpen, onClose, containerId, containerN