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
+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"