From 2465f7607e5bccd0b0f84d0ca9f1eecc18018753 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 12 Apr 2026 05:43:15 -0400 Subject: [PATCH] fix(stacks): harden stack management with security, validation, and logging (#520) * fix(stacks): harden stack management with security fixes, validation alignment, and logging Validate WebSocket stack names with isValidStackName() to close a path-traversal gap on the /api/stacks/:stackName/logs WS endpoint. Align POST /api/stacks to use the canonical validator (allows underscores). Replace error: any catch blocks with error: unknown + type narrowing. Add cache invalidation to PUT /api/stacks/:stackName/env. Rename DELETE param from :name to :stackName for consistency. Add standard [Stacks] lifecycle logs and diagnostic [Stacks:debug] logs gated behind the Developer Mode toggle (with 5s TTL cache). Extract shared isDebugEnabled() and getErrorMessage() utilities. Frontend: roll back optimistic status on API failure, guard unsaved changes when switching stacks, pre-check duplicate names in App Store. * docs(settings): update Developer Mode description to mention debug diagnostics --- .../src/__tests__/stack-management.test.ts | 139 ++++++++++++++++++ backend/src/__tests__/validation.test.ts | 24 +++ backend/src/index.ts | 103 +++++++++---- backend/src/services/ComposeService.ts | 10 ++ backend/src/services/FileSystemService.ts | 9 ++ backend/src/utils/debug.ts | 30 ++++ backend/src/utils/errors.ts | 9 ++ docs/reference/settings.mdx | 2 +- frontend/src/components/AppStoreView.tsx | 13 ++ frontend/src/components/EditorLayout.tsx | 39 +++++ .../components/settings/DeveloperSection.tsx | 2 +- 11 files changed, 350 insertions(+), 30 deletions(-) create mode 100644 backend/src/__tests__/stack-management.test.ts create mode 100644 backend/src/utils/debug.ts create mode 100644 backend/src/utils/errors.ts diff --git a/backend/src/__tests__/stack-management.test.ts b/backend/src/__tests__/stack-management.test.ts new file mode 100644 index 00000000..0950a105 --- /dev/null +++ b/backend/src/__tests__/stack-management.test.ts @@ -0,0 +1,139 @@ +/** + * Unit tests for stack management hardening: + * - WebSocket stack name validation + * - Stack creation validation alignment + * - Error type narrowing (unknown vs any) + * - Cache invalidation on env write + */ +import { describe, it, expect } from 'vitest'; +import { isValidStackName } from '../utils/validation'; + +// ─── WebSocket Stack Name Validation ──────────────────────────────────────── +// The WS handler at /api/stacks/:stackName/logs uses a regex to extract the +// stack name, then must validate it with isValidStackName() before passing +// it to streamLogs(). These tests verify the validation catches traversal +// attempts that the regex alone would allow. + +describe('WebSocket stack name validation', () => { + it('rejects traversal patterns the URL regex would pass', () => { + // The WS regex is /^\/api\/stacks\/([^/]+)\/logs$/ which only blocks '/' + // After decodeURIComponent, these should still be caught by isValidStackName + expect(isValidStackName('..')).toBe(false); + expect(isValidStackName('..%2f')).toBe(false); + expect(isValidStackName('foo bar')).toBe(false); + expect(isValidStackName('foo;ls')).toBe(false); + expect(isValidStackName('')).toBe(false); + }); + + it('accepts valid stack names that would appear in WS paths', () => { + expect(isValidStackName('nginx')).toBe(true); + expect(isValidStackName('my-app')).toBe(true); + expect(isValidStackName('my_app')).toBe(true); + expect(isValidStackName('stack123')).toBe(true); + }); + + it('validates URL-decoded stack names correctly', () => { + // Simulate what happens after decodeURIComponent + const encoded = '%2e%2e'; // '..' URL-encoded + const decoded = decodeURIComponent(encoded); + expect(isValidStackName(decoded)).toBe(false); + + const encodedSlash = 'foo%2fbar'; // 'foo/bar' URL-encoded + const decodedSlash = decodeURIComponent(encodedSlash); + expect(isValidStackName(decodedSlash)).toBe(false); + }); +}); + +// ─── Stack Creation Validation Alignment ──────────────────────────────────── +// POST /api/stacks should use isValidStackName() (which allows underscores) +// instead of the old inline regex /^[a-zA-Z0-9-]+$/ (which rejected underscores). + +describe('stack creation validation alignment', () => { + it('allows underscores in stack names (aligned with isValidStackName)', () => { + expect(isValidStackName('my_stack')).toBe(true); + expect(isValidStackName('test_app_v2')).toBe(true); + }); + + it('allows hyphens in stack names', () => { + expect(isValidStackName('my-stack')).toBe(true); + expect(isValidStackName('test-app-v2')).toBe(true); + }); + + it('allows mixed hyphens and underscores', () => { + expect(isValidStackName('my-stack_v2')).toBe(true); + expect(isValidStackName('test_app-prod')).toBe(true); + }); + + it('rejects names with dots (not valid for Docker compose project names)', () => { + expect(isValidStackName('my.stack')).toBe(false); + expect(isValidStackName('.env')).toBe(false); + }); +}); + +// ─── Error Type Narrowing ────────────────────────────────────────────────── +// Catch blocks should use `error: unknown` with instanceof narrowing, not `error: any`. +// These tests verify the narrowing pattern works correctly. + +describe('error type narrowing pattern', () => { + it('extracts message from Error instances', () => { + const error: unknown = new Error('Deploy failed: port conflict'); + const message = error instanceof Error ? error.message : 'Fallback message'; + expect(message).toBe('Deploy failed: port conflict'); + }); + + it('uses fallback for non-Error values', () => { + const error: unknown = 'string error'; + const message = error instanceof Error ? error.message : 'Fallback message'; + expect(message).toBe('Fallback message'); + }); + + it('uses fallback for null/undefined', () => { + const nullErr: unknown = null; + const message1 = nullErr instanceof Error ? nullErr.message : 'Fallback'; + expect(message1).toBe('Fallback'); + + const undefErr: unknown = undefined; + const message2 = undefErr instanceof Error ? undefErr.message : 'Fallback'; + expect(message2).toBe('Fallback'); + }); + + it('handles objects without message property', () => { + const error: unknown = { code: 'ENOENT' }; + const message = error instanceof Error ? error.message : 'Fallback'; + expect(message).toBe('Fallback'); + }); +}); + +// ─── Stack Name Edge Cases ────────────────────────────────────────────────── + +describe('stack name edge cases for all endpoints', () => { + it('rejects names that could cause shell injection', () => { + expect(isValidStackName('$(whoami)')).toBe(false); + expect(isValidStackName('`id`')).toBe(false); + expect(isValidStackName('stack;rm -rf')).toBe(false); + expect(isValidStackName('stack&&echo')).toBe(false); + expect(isValidStackName('stack|cat')).toBe(false); + }); + + it('rejects null bytes and control characters', () => { + expect(isValidStackName('stack\x00')).toBe(false); + expect(isValidStackName('stack\n')).toBe(false); + expect(isValidStackName('stack\t')).toBe(false); + expect(isValidStackName('stack\r')).toBe(false); + }); + + it('rejects Windows path separators', () => { + expect(isValidStackName('stack\\name')).toBe(false); + expect(isValidStackName('C:\\stacks')).toBe(false); + }); + + it('accepts single character names', () => { + expect(isValidStackName('a')).toBe(true); + expect(isValidStackName('1')).toBe(true); + }); + + it('accepts numeric-only names', () => { + expect(isValidStackName('123')).toBe(true); + expect(isValidStackName('42')).toBe(true); + }); +}); diff --git a/backend/src/__tests__/validation.test.ts b/backend/src/__tests__/validation.test.ts index cb905408..cec04c17 100644 --- a/backend/src/__tests__/validation.test.ts +++ b/backend/src/__tests__/validation.test.ts @@ -30,6 +30,30 @@ describe('isValidStackName', () => { expect(isValidStackName('foo;rm -rf /')).toBe(false); expect(isValidStackName('')).toBe(false); }); + + it('rejects URL-encoded traversal attempts', () => { + // These would already be decoded by decodeURIComponent before validation, + // but the raw encoded forms should also fail + expect(isValidStackName('%2e%2e%2f')).toBe(false); + expect(isValidStackName('foo%2fbar')).toBe(false); + expect(isValidStackName('..%2fetc')).toBe(false); + }); + + it('rejects unicode characters', () => { + expect(isValidStackName('stäck')).toBe(false); + expect(isValidStackName('stack\u0000')).toBe(false); + expect(isValidStackName('\u202Estack')).toBe(false); + }); + + it('handles very long names', () => { + // The regex itself has no length limit, but 255 chars should still pass + const longValid = 'a'.repeat(255); + expect(isValidStackName(longValid)).toBe(true); + + // Names with only valid chars remain valid regardless of length + const veryLong = 'stack-' + 'x'.repeat(1000); + expect(isValidStackName(veryLong)).toBe(true); + }); }); // ─── isValidRemoteUrl ──────────────────────────────────────────────────────── diff --git a/backend/src/index.ts b/backend/src/index.ts index 03160883..145b672a 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -56,6 +56,9 @@ function invalidateNodeCaches(nodeId: number): void { cache.invalidate(`stack-statuses:${nodeId}`); cache.invalidate('project-name-map'); } + +import { isDebugEnabled } from './utils/debug'; +import { getErrorMessage } from './utils/errors'; import SelfUpdateService from './services/SelfUpdateService'; import semver from 'semver'; import { CronExpressionParser } from 'cron-parser'; @@ -3018,10 +3021,19 @@ server.on('upgrade', async (req, socket, head) => { // would accumulate listeners and allocate memory for every connection. logsWss.close(); const stackName = decodeURIComponent(logsMatch[1]); + if (!isValidStackName(stackName)) { + ws.send('Error: Invalid stack name\r\n'); + ws.close(); + return; + } try { + if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream opened', { stackName, nodeId }); + ws.on('close', () => { + if (isDebugEnabled()) console.debug('[Stacks:debug] WS log stream closed', { stackName, nodeId }); + }); ComposeService.getInstance(nodeId).streamLogs(stackName, ws); } catch (error) { - console.error('Failed to stream logs:', error); + console.error('[Stacks] Failed to stream logs:', error); if (ws.readyState === WebSocket.OPEN) { ws.send(`Error streaming logs: ${(error as Error).message}\n`); } @@ -3406,6 +3418,7 @@ app.put('/api/stacks/:stackName', async (req: Request, res: Response) => { } await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Compose file saved: ${stackName}`); res.json({ message: 'Stack saved successfully' }); } catch (error) { console.error('Failed to save stack:', error); @@ -3569,9 +3582,12 @@ app.put('/api/stacks/:stackName/env', async (req: Request, res: Response) => { const fsService = FileSystemService.getInstance(req.nodeId); await fsService.writeFile(envPath, content, 'utf-8'); + invalidateNodeCaches(req.nodeId); + const envFileName = path.basename(envPath); + console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`); res.json({ message: 'Env file saved successfully' }); } catch (error) { - console.error('Failed to save env file:', error); + console.error('[Stacks] Failed to save env file:', error); res.status(500).json({ error: 'Failed to save env file' }); } }); @@ -3583,14 +3599,16 @@ app.post('/api/stacks', async (req: Request, res: Response) => { if (!stackName || typeof stackName !== 'string') { return res.status(400).json({ error: 'Stack name is required and must be a string' }); } - if (!/^[a-zA-Z0-9-]+$/.test(stackName)) { - return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters and hyphens' }); + if (!isValidStackName(stackName)) { + return res.status(400).json({ error: 'Stack name can only contain alphanumeric characters, hyphens, and underscores' }); } await FileSystemService.getInstance(req.nodeId).createStack(stackName); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Stack created: ${stackName}`); res.json({ message: 'Stack created successfully', name: stackName }); - } catch (error: any) { - if (error.message && error.message.includes('already exists')) { + } catch (error: unknown) { + const message = getErrorMessage(error, ''); + if (message.includes('already exists')) { return res.status(409).json({ error: 'Stack already exists' }); } console.error('Failed to create stack:', error); @@ -3598,8 +3616,8 @@ app.post('/api/stacks', async (req: Request, res: Response) => { } }); -app.delete('/api/stacks/:name', async (req: Request, res: Response) => { - const stackName = req.params.name as string; +app.delete('/api/stacks/:stackName', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; if (!requirePermission(req, res, 'stack:delete', 'stack', stackName)) return; if (!isValidStackName(stackName)) { return res.status(400).json({ error: 'Invalid stack name' }); @@ -3619,9 +3637,12 @@ app.delete('/api/stacks/:name', async (req: Request, res: Response) => { DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Stack deleted: ${stackName}`); res.json({ success: true }); - } catch (error: any) { - res.status(500).json({ error: error.message || 'Failed to delete stack' }); + } catch (error: unknown) { + console.error(`[Stacks] Failed to delete stack ${stackName}:`, error); + const message = getErrorMessage(error, 'Failed to delete stack'); + res.status(500).json({ error: message }); } }); @@ -3713,14 +3734,21 @@ app.post('/api/stacks/:stackName/deploy', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid stack name' }); } try { + const debug = isDebugEnabled(); const atomic = LicenseService.getInstance().getTier() === 'paid'; + if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId }); + const t0 = Date.now(); await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, atomic); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Deploy completed: ${stackName}`); + if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); res.json({ message: 'Deployed successfully' }); - } catch (error: any) { - console.error('Failed to deploy stack:', error); + } catch (error: unknown) { + console.error(`[Stacks] Deploy failed: ${stackName}`, error); const rolledBack = LicenseService.getInstance().getTier() === 'paid'; - res.status(500).json({ error: error.message || 'Failed to deploy stack', rolledBack }); + if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`); + const message = getErrorMessage(error, 'Failed to deploy stack'); + res.status(500).json({ error: message, rolledBack }); } }); @@ -3733,8 +3761,10 @@ app.post('/api/stacks/:stackName/down', async (req: Request, res: Response) => { try { await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', terminalWs || undefined); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Down completed: ${stackName}`); res.json({ status: 'Command started' }); } catch (error) { + console.error(`[Stacks] Down failed: ${stackName}`, error); res.status(500).json({ error: 'Failed to start command' }); } }); @@ -3755,10 +3785,12 @@ app.post('/api/stacks/:stackName/restart', async (req: Request, res: Response) = await Promise.all(containers.map(c => dockerController.restartContainer(c.Id))); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Restart completed: ${stackName} (${containers.length} containers)`); res.json({ success: true, message: 'Restart completed via Engine API.' }); - } catch (error: any) { - console.error('Failed to restart containers:', error); - res.status(500).json({ error: error.message || 'Failed to restart containers' }); + } catch (error: unknown) { + console.error(`[Stacks] Restart failed: ${stackName}`, error); + const message = getErrorMessage(error, 'Failed to restart containers'); + res.status(500).json({ error: message }); } }); @@ -3778,10 +3810,12 @@ app.post('/api/stacks/:stackName/stop', async (req: Request, res: Response) => { await Promise.all(containers.map(c => dockerController.stopContainer(c.Id))); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Stop completed: ${stackName} (${containers.length} containers)`); res.json({ success: true, message: 'Stop completed via Engine API.' }); - } catch (error: any) { - console.error('Failed to stop containers:', error); - res.status(500).json({ error: error.message || 'Failed to stop containers' }); + } catch (error: unknown) { + console.error(`[Stacks] Stop failed: ${stackName}`, error); + const message = getErrorMessage(error, 'Failed to stop containers'); + res.status(500).json({ error: message }); } }); @@ -3801,10 +3835,12 @@ app.post('/api/stacks/:stackName/start', async (req: Request, res: Response) => await Promise.all(containers.map(c => dockerController.startContainer(c.Id))); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Start completed: ${stackName} (${containers.length} containers)`); res.json({ success: true, message: 'Start completed via Engine API.' }); - } catch (error: any) { - console.error('Failed to start containers:', error); - res.status(500).json({ error: error.message || 'Failed to start containers' }); + } catch (error: unknown) { + console.error(`[Stacks] Start failed: ${stackName}`, error); + const message = getErrorMessage(error, 'Failed to start containers'); + res.status(500).json({ error: message }); } }); @@ -3816,13 +3852,20 @@ app.post('/api/stacks/:stackName/update', async (req: Request, res: Response) => return res.status(400).json({ error: 'Invalid stack name' }); } try { + const debug = isDebugEnabled(); const atomic = LicenseService.getInstance().getTier() === 'paid'; + if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId }); + const t0 = Date.now(); await ComposeService.getInstance(req.nodeId).updateStack(stackName, terminalWs || undefined, atomic); DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Update completed: ${stackName}`); + if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`); res.json({ status: 'Update completed' }); } catch (error) { + console.error(`[Stacks] Update failed: ${stackName}`, error); const rolledBack = LicenseService.getInstance().getTier() === 'paid'; + if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`); res.status(500).json({ error: 'Failed to update', rolledBack }); } }); @@ -3841,14 +3884,17 @@ app.post('/api/stacks/:stackName/rollback', async (req: Request, res: Response) if (!backupInfo.exists) { return res.status(404).json({ error: 'No backup available for this stack.' }); } + console.log(`[Stacks] Rollback initiated: ${stackName}`); await fsSvc.restoreStackFiles(stackName); // Re-deploy with restored files (non-atomic to avoid loops) await ComposeService.getInstance(req.nodeId).deployStack(stackName, terminalWs || undefined, false); invalidateNodeCaches(req.nodeId); + console.log(`[Stacks] Rollback completed: ${stackName}`); res.json({ message: 'Stack rolled back successfully.' }); - } catch (error: any) { - console.error('Rollback failed:', error); - res.status(500).json({ error: error.message || 'Rollback failed.' }); + } catch (error: unknown) { + console.error(`[Stacks] Rollback failed: ${stackName}`, error); + const message = getErrorMessage(error, 'Rollback failed.'); + res.status(500).json({ error: message }); } }); @@ -3862,9 +3908,10 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => const fsSvc = FileSystemService.getInstance(req.nodeId); const info = await fsSvc.getBackupInfo(stackName); res.json(info); - } catch (error: any) { + } catch (error: unknown) { console.error('Failed to get backup info:', error); - res.status(500).json({ error: error.message || 'Failed to get backup info.' }); + const message = getErrorMessage(error, 'Failed to get backup info.'); + res.status(500).json({ error: message }); } }); @@ -6095,7 +6142,7 @@ app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Respons res.json(meta); } catch (error: unknown) { console.error('Failed to fetch node meta:', error); - const message = error instanceof Error ? error.message : 'Failed to fetch node metadata'; + const message = getErrorMessage(error, 'Failed to fetch node metadata'); res.status(500).json({ error: message }); } }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 234066d5..9489f681 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -10,6 +10,8 @@ import { LogFormatter } from './LogFormatter'; import { NodeRegistry } from './NodeRegistry'; import { RegistryService } from './RegistryService'; +import { isDebugEnabled } from '../utils/debug'; + /** * ComposeService - local docker compose CLI execution. * @@ -113,6 +115,9 @@ export class ComposeService { async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { const stackDir = path.join(this.baseDir, stackName); + const debug = isDebugEnabled(); + const t0 = Date.now(); + if (debug) console.debug('[ComposeService:debug] deployStack', { stackName, stackDir, atomic }); const sendOutput = (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }; @@ -166,6 +171,7 @@ export class ComposeService { } } } + if (debug) console.debug(`[ComposeService:debug] deployStack completed in ${Date.now() - t0}ms`, { stackName }); } catch (deployError) { // Atomic: auto-rollback on failure if (atomic) { @@ -299,6 +305,9 @@ export class ComposeService { async updateStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { const stackDir = path.join(this.baseDir, stackName); + const debug = isDebugEnabled(); + const t0 = Date.now(); + if (debug) console.debug('[ComposeService:debug] updateStack', { stackName, stackDir, atomic }); const sendOutput = (data: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(data); }; @@ -358,6 +367,7 @@ export class ComposeService { } sendOutput('=== Stack updated successfully ===\n'); + if (debug) console.debug(`[ComposeService:debug] updateStack completed in ${Date.now() - t0}ms`, { stackName }); } catch (updateError) { // Atomic: auto-rollback on failure if (atomic) { diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index b51166bb..25a5d243 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -12,6 +12,8 @@ function getBackupBaseDir(): string { return path.join(dataDir, 'backups'); } +import { isDebugEnabled } from '../utils/debug'; + /** * FileSystemService - local-only file I/O for compose stack management. * @@ -52,6 +54,7 @@ export class FileSystemService { const filePath = path.join(stackDir, file); try { await fsPromises.access(filePath); + if (isDebugEnabled()) console.debug('[FileSystemService:debug] Resolved compose file', { stackName, file }); return filePath; } catch { // continue @@ -249,6 +252,8 @@ export class FileSystemService { * holds sencho.db and encryption.key. */ async backupStackFiles(stackName: string): Promise { + const debug = isDebugEnabled(); + const t0 = Date.now(); const stackDir = path.join(this.baseDir, stackName); const backupDir = path.join(getBackupBaseDir(), stackName); await fsPromises.mkdir(backupDir, { recursive: true }); @@ -282,9 +287,12 @@ export class FileSystemService { // Write timestamp marker await fsPromises.writeFile(path.join(backupDir, '.timestamp'), Date.now().toString(), 'utf-8'); + if (debug) console.debug(`[FileSystemService:debug] Backup completed in ${Date.now() - t0}ms`, { stackName }); } async restoreStackFiles(stackName: string): Promise { + const debug = isDebugEnabled(); + const t0 = Date.now(); const stackDir = path.join(this.baseDir, stackName); const backupDir = path.join(getBackupBaseDir(), stackName); @@ -293,6 +301,7 @@ export class FileSystemService { if (item === '.timestamp') continue; await fsPromises.copyFile(path.join(backupDir, item), path.join(stackDir, item)); } + if (debug) console.debug(`[FileSystemService:debug] Restore completed in ${Date.now() - t0}ms`, { stackName, files: items.filter(i => i !== '.timestamp') }); } async getBackupInfo(stackName: string): Promise<{ exists: boolean; timestamp: number | null }> { diff --git a/backend/src/utils/debug.ts b/backend/src/utils/debug.ts new file mode 100644 index 00000000..3fb2762a --- /dev/null +++ b/backend/src/utils/debug.ts @@ -0,0 +1,30 @@ +/** + * Shared diagnostic logging gate. + * + * Reads `developer_mode` from the global settings, cached for a short + * window so hot paths (per-request, per-log-line) do not hit SQLite + * on every call. + */ + +let cachedValue = false; +let cacheExpiry = 0; +const CACHE_TTL_MS = 5_000; + +export function isDebugEnabled(): boolean { + const now = Date.now(); + if (now < cacheExpiry) return cachedValue; + + try { + // Dynamic require avoids circular-dependency issues when this + // utility is imported from services that DatabaseService itself + // depends on, and prevents SQLite side effects during tests. + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseService } = require('../services/DatabaseService'); + cachedValue = DatabaseService.getInstance().getGlobalSettings().developer_mode === '1'; + } catch { + cachedValue = false; + } + + cacheExpiry = now + CACHE_TTL_MS; + return cachedValue; +} diff --git a/backend/src/utils/errors.ts b/backend/src/utils/errors.ts new file mode 100644 index 00000000..7fa762af --- /dev/null +++ b/backend/src/utils/errors.ts @@ -0,0 +1,9 @@ +/** + * Safely extract a message from an unknown caught value. + * + * Replaces the repeated `error instanceof Error ? error.message : '...'` + * pattern throughout the codebase. + */ +export function getErrorMessage(error: unknown, fallback: string): string { + return error instanceof Error ? error.message : fallback; +} diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index eae1b50b..43618eb1 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -215,7 +215,7 @@ Advanced settings for log streaming behavior and data retention. Most users can | Setting | Default | Description | |---------|---------|-------------| -| **Developer Mode** | Off | When on, the [Global Observability](/features/global-observability) view switches from polling to real-time SSE streaming | +| **Developer Mode** | Off | Enables real-time SSE streaming for [Global Observability](/features/global-observability), and activates debug diagnostics (timing, cache state, file resolution) in the server logs | | **Standard Log Polling Rate** | 5s | Polling interval in standard mode. Options: `1s`, `3s`, `5s`, `10s`. Disabled when Developer Mode is on. | ### Data Retention diff --git a/frontend/src/components/AppStoreView.tsx b/frontend/src/components/AppStoreView.tsx index 6a00f98a..94e8bbbe 100644 --- a/frontend/src/components/AppStoreView.tsx +++ b/frontend/src/components/AppStoreView.tsx @@ -138,6 +138,19 @@ export function AppStoreView({ onDeploySuccess }: AppStoreViewProps) { toast.error("Stack name is required"); return; } + + // Pre-check for duplicate stack name + try { + const checkRes = await apiFetch('/stacks'); + if (checkRes.ok) { + const existingStacks: string[] = await checkRes.json(); + if (existingStacks.includes(stackName.trim())) { + toast.error(`A stack named "${stackName.trim()}" already exists. Choose a different name.`); + return; + } + } + } catch { /* proceed to deploy; backend will catch duplicates */ } + setIsDeploying(true); const modifiedTemplate = { ...selectedTemplate }; diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index b12a1f56..0922ed03 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -122,6 +122,7 @@ export default function EditorLayout() { const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); const [stackToDelete, setStackToDelete] = useState(null); + const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState(null); const [isLoading, setIsLoading] = useState(false); const [stackActions, setStackActions] = useState>({}); const stackActionsRef = useRef>({}); @@ -802,8 +803,16 @@ export default function EditorLayout() { }; }, [containers]); // eslint-disable-line react-hooks/exhaustive-deps + const hasUnsavedChanges = () => + content !== originalContent || envContent !== originalEnvContent; + const loadFile = async (filename: string) => { if (!filename) return; + // Guard: if there are unsaved changes and we're switching to a different stack, confirm first + if (selectedFile && filename !== selectedFile && hasUnsavedChanges()) { + setPendingUnsavedLoad(filename); + return; + } setIsFileLoading(true); setIsEditing(false); // Reset to view mode when loading a new file try { @@ -985,6 +994,7 @@ export default function EditorLayout() { const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'deploy'); + const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/deploy`, { @@ -1010,6 +1020,7 @@ export default function EditorLayout() { } } catch (error) { console.error('Failed to deploy:', error); + if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); const msg = (error as Error).message || 'Failed to deploy stack'; toast.error(isPaid ? `${msg} - automatically rolled back to previous version.` : msg); } finally { @@ -1025,6 +1036,7 @@ export default function EditorLayout() { const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'stop'); + const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'exited'); try { const response = await apiFetch(`/stacks/${stackName}/stop`, { @@ -1043,6 +1055,7 @@ export default function EditorLayout() { } } catch (error) { console.error('Failed to stop:', error); + if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to stop stack'); } finally { clearStackAction(stackFile); @@ -1057,6 +1070,7 @@ export default function EditorLayout() { const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'restart'); + const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/restart`, { @@ -1075,6 +1089,7 @@ export default function EditorLayout() { } } catch (error) { console.error('Failed to restart:', error); + if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to restart stack'); } finally { clearStackAction(stackFile); @@ -1089,6 +1104,7 @@ export default function EditorLayout() { const stackFile = selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); setStackAction(stackFile, 'update'); + const previousStatus = stackStatuses[stackFile]; setOptimisticStatus(stackFile, 'running'); try { const response = await apiFetch(`/stacks/${stackName}/update`, { @@ -1107,6 +1123,7 @@ export default function EditorLayout() { } } catch (error) { console.error('Failed to update:', error); + if (previousStatus !== undefined) setOptimisticStatus(stackFile, previousStatus as 'running' | 'exited'); toast.error((error as Error).message || 'Failed to update stack'); } finally { clearStackAction(stackFile); @@ -2286,6 +2303,28 @@ export default function EditorLayout() { + { if (!open) setPendingUnsavedLoad(null); }}> + + + Unsaved Changes + + You have unsaved changes. Switching stacks will discard them. Continue? + + + + setPendingUnsavedLoad(null)}>Cancel + { + const target = pendingUnsavedLoad; + // Reset content to original so the guard doesn't re-trigger + setContent(originalContent); + setEnvContent(originalEnvContent); + setPendingUnsavedLoad(null); + if (target) loadFile(target); + }}>Discard Changes + + + + diff --git a/frontend/src/components/settings/DeveloperSection.tsx b/frontend/src/components/settings/DeveloperSection.tsx index 9c1ea2c0..f55aef13 100644 --- a/frontend/src/components/settings/DeveloperSection.tsx +++ b/frontend/src/components/settings/DeveloperSection.tsx @@ -66,7 +66,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
-

Enable Real-Time Metrics & Extended Logs

+

Enable Real-Time Metrics, Debug Diagnostics & Extended Logs