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
This commit is contained in:
Anso
2026-04-12 05:43:15 -04:00
committed by GitHub
parent 3ad1ab5c84
commit 2465f7607e
11 changed files with 350 additions and 30 deletions
@@ -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);
});
});
+24
View File
@@ -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 ────────────────────────────────────────────────────────
+75 -28
View File
@@ -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 });
}
});
+10
View File
@@ -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<void> {
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<void> {
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) {
@@ -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<void> {
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<void> {
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 }> {
+30
View File
@@ -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;
}
+9
View File
@@ -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;
}
+1 -1
View File
@@ -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
+13
View File
@@ -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 };
+39
View File
@@ -122,6 +122,7 @@ export default function EditorLayout() {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [newStackName, setNewStackName] = useState('');
const [stackToDelete, setStackToDelete] = useState<string | null>(null);
const [pendingUnsavedLoad, setPendingUnsavedLoad] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [stackActions, setStackActions] = useState<Record<string, StackAction>>({});
const stackActionsRef = useRef<Record<string, StackAction>>({});
@@ -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() {
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!pendingUnsavedLoad} onOpenChange={(open) => { if (!open) setPendingUnsavedLoad(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
<AlertDialogDescription>
You have unsaved changes. Switching stacks will discard them. Continue?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setPendingUnsavedLoad(null)}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={() => {
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</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={bulkActionOpen} onOpenChange={setBulkActionOpen}>
<AlertDialogContent>
<AlertDialogHeader>
@@ -66,7 +66,7 @@ export function DeveloperSection({ settings, onSettingChange, onSave, isSaving,
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="developer_mode" className="text-base">Developer Mode</Label>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics & Extended Logs</p>
<p className="text-xs text-muted-foreground">Enable Real-Time Metrics, Debug Diagnostics & Extended Logs</p>
</div>
<Switch
id="developer_mode"