This commit is contained in:
jarek committed 2026-03-13 08:22:46 +01:00
1 parent 790ce092ee
commit 55f3101a19
51 files changed
+763 -599

No files matched your search

+10 -4
View File
@@ -30,8 +30,11 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const details = await inspectContainer(params.id, envIdNum);
return json(details);
} catch (error) {
console.error('Error inspecting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error inspecting container:', error?.message || error);
return json({ error: 'Failed to inspect container' }, { status: 500 });
}
};
@@ -96,8 +99,11 @@ export const DELETE: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
console.error('Error removing container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error removing container:', error?.message || error);
return json({ error: 'Failed to remove container' }, { status: 500 });
}
};
@@ -26,7 +26,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json(result);
} catch (error: any) {
console.error('Error listing container directory:', error);
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error listing container directory:', error?.message || error);
return json({ error: error.message || 'Failed to list directory' }, { status: 500 });
}
};
@@ -36,7 +36,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return json({ content, path });
} catch (error: any) {
console.error('Error reading container file:', error);
if (error?.statusCode === 404 && !error.message?.includes('No such file')) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error reading container file:', error?.message || error);
const msg = error.message || String(error);
if (msg.includes('No such file or directory')) {
@@ -92,7 +95,7 @@ export const PUT: RequestHandler = async ({ params, url, cookies, request }) =>
return json({ success: true, path });
} catch (error: any) {
console.error('Error writing container file:', error);
console.error('Error writing container file:', error?.message || error);
const msg = error.message || String(error);
if (msg.includes('Permission denied')) {
@@ -76,7 +76,7 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
return new Response(body, { headers });
} catch (error: any) {
console.error('Error downloading container file:', error);
console.error('Error downloading container file:', error?.message || error);
if (error.message?.includes('No such file or directory')) {
return new Response(JSON.stringify({ error: 'File not found' }), {
@@ -140,7 +140,7 @@ export const POST: RequestHandler = async ({ params, url, request, cookies }) =>
errors: errors.length > 0 ? errors : undefined
});
} catch (error: any) {
console.error('Error uploading to container:', error);
console.error('Error uploading to container:', error?.message || error);
if (error.message?.includes('Permission denied')) {
return json({ error: 'Permission denied to write to this path' }, { status: 403 });
@@ -46,8 +46,11 @@ export const POST: RequestHandler = async (event) => {
}
return json({ success: true });
} catch (error) {
console.error('Error renaming container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error renaming container:', error?.message || error);
return json({ error: 'Failed to rename container' }, { status: 500 });
}
};
@@ -38,8 +38,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'restart', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error restarting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error restarting container:', error?.message || error);
return json({ error: 'Failed to restart container' }, { status: 500 });
}
};
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'start', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error starting container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error starting container:', error?.message || error);
return json({ error: 'Failed to start container' }, { status: 500 });
}
};
@@ -31,8 +31,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'stop', params.id, containerName, envIdNum);
return json({ success: true });
} catch (error) {
console.error('Error stopping container:', error);
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error stopping container:', error?.message || error);
return json({ error: 'Failed to stop container' }, { status: 500 });
}
};
@@ -47,8 +47,11 @@ export const POST: RequestHandler = async (event) => {
await auditContainer(event, 'update', container.id, options.name, envIdNum, { ...options, startAfterUpdate });
return json({ success: true, id: container.id });
} catch (error) {
console.error('Error updating container:', error);
return json({ error: 'Failed to update container', details: String(error) }, { status: 500 });
} catch (error: any) {
if (error?.statusCode === 404) {
return json({ error: error.json?.message || 'Container not found' }, { status: 404 });
}
console.error('Error updating container:', error?.message || error);
return json({ error: 'Failed to update container', details: error?.message || String(error) }, { status: 500 });
}
};
@@ -4,6 +4,7 @@ import { authorize } from '$lib/server/authorize';
import { listContainers, inspectContainer, checkImageUpdateAvailable } from '$lib/server/docker';
import { clearPendingContainerUpdates, addPendingContainerUpdate } from '$lib/server/db';
import { isSystemContainer } from '$lib/server/scheduler/tasks/update-utils';
import { createJobResponse } from '$lib/server/sse';
export interface UpdateCheckResult {
containerId: string;
@@ -19,9 +20,9 @@ export interface UpdateCheckResult {
/**
* Check all containers for available image updates.
* Returns all results at once after checking in parallel.
* Returns progress events during checking, final result when done.
*/
export const POST: RequestHandler = async ({ url, cookies }) => {
export const POST: RequestHandler = async ({ url, cookies, request }) => {
const auth = await authorize(cookies);
const envId = url.searchParams.get('env');
@@ -32,21 +33,21 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
return json({ error: 'Permission denied' }, { status: 403 });
}
try {
return createJobResponse(async (send) => {
// Clear existing pending updates for this environment before checking
if (envIdNum) {
await clearPendingContainerUpdates(envIdNum);
}
const allContainers = await listContainers(true, envIdNum);
// Include all containers (system containers get flagged, not filtered)
const containers = allContainers;
send('progress', { checked: 0, total: containers.length });
// Check container for updates
let checked = 0;
const checkContainer = async (container: typeof containers[0]): Promise<UpdateCheckResult> => {
try {
// Get container's image name from config
const inspectData = await inspectContainer(container.id, envIdNum) as any;
const imageName = inspectData.Config?.Image;
const currentImageId = inspectData.Image;
@@ -62,7 +63,6 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
};
}
// Use shared update detection function
const result = await checkImageUpdateAvailable(imageName, currentImageId, envIdNum);
return {
@@ -88,13 +88,23 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
}
};
// Check all containers in parallel
const results = await Promise.all(containers.map(checkContainer));
// Sliding window concurrency limit to avoid DNS threadpool saturation (#676).
const CONCURRENCY = 20;
const results: UpdateCheckResult[] = new Array(containers.length);
let next = 0;
async function runNext(): Promise<void> {
while (next < containers.length) {
const idx = next++;
results[idx] = await checkContainer(containers[idx]);
checked++;
send('progress', { checked, total: containers.length });
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, containers.length) }, () => runNext()));
const updatesFound = results.filter(r => r.hasUpdate).length;
// Save containers with updates to the database for persistence
// Skip system containers (Dockhand/Hawser) - they use their own update paths
if (envIdNum) {
for (const result of results) {
if (result.hasUpdate && !result.systemContainer) {
@@ -108,13 +118,10 @@ export const POST: RequestHandler = async ({ url, cookies }) => {
}
}
return json({
send('result', {
total: containers.length,
updatesFound,
results
});
} catch (error: any) {
console.error('Error checking for updates:', error);
return json({ error: 'Failed to check for updates', details: error.message }, { status: 500 });
}
}, request);
};
@@ -24,6 +24,7 @@ import { authorize } from '$lib/server/authorize';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
import type { EnvironmentStats } from '../+server';
import { parseLabels } from '$lib/utils/label-colors';
import { isEdgeConnected } from '$lib/server/hawser';
// Skip disk usage collection (Synology NAS performance fix)
@@ -249,6 +250,22 @@ async function getEnvironmentStatsProgressive(
loading: { ...envStats.loading }
});
// For edge envs with no connected agent, skip the 5s ping and fail immediately.
// On restart, agents take 30-70s to reconnect — without this check, every open
// dashboard tab fires a 5s ping per edge env simultaneously, creating a flood.
if (env.connectionType === 'hawser-edge' && !isEdgeConnected(env.id)) {
envStats.online = false;
envStats.error = 'Agent not connected';
envStats.loading = undefined;
onPartialUpdate({
id: env.id,
online: false,
error: 'Agent not connected',
loading: undefined
});
return envStats;
}
// Quick reachability check — if ping fails, skip all expensive Docker API calls
if (!await dockerPing(env.id)) {
envStats.online = false;
+7 -1
View File
@@ -80,8 +80,14 @@ export const POST: RequestHandler = async (event) => {
return json({ error: 'An environment with this name already exists' }, { status: 409 });
}
// Host is required for direct and hawser-standard connections
// Validate connection type
const validConnectionTypes = ['socket', 'direct', 'hawser-standard', 'hawser-edge'];
const connectionType = data.connectionType || 'socket';
if (!validConnectionTypes.includes(connectionType)) {
return json({ error: `Invalid connection type: ${connectionType}` }, { status: 400 });
}
// Host is required for direct and hawser-standard connections
if ((connectionType === 'direct' || connectionType === 'hawser-standard') && !data.host) {
return json({ error: 'Host is required for this connection type' }, { status: 400 });
}
+1 -1
View File
@@ -152,7 +152,7 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
return json(hostInfo);
} catch (error) {
console.error('Failed to get host info:', error);
console.error('Failed to get host info:', (error as Error)?.message ?? error);
return json({ error: 'Failed to get host info' }, { status: 500 });
}
};
+6 -1
View File
@@ -49,7 +49,9 @@ async function fetchDockerHubTags(imageName: string, page: number = 1, pageSize:
if (response.status === 404) {
throw new Error('Image not found on Docker Hub');
}
throw new Error(`Docker Hub returned error: ${response.status}`);
const err = new Error(`Docker Hub returned error: ${response.status}`) as any;
err.statusCode = response.status;
throw err;
}
const data = await response.json();
@@ -162,6 +164,9 @@ export const GET: RequestHandler = async ({ url }) => {
if (error.code === 'ENOTFOUND') {
return json({ error: 'Registry host not found' }, { status: 503 });
}
if (error.statusCode) {
return json({ error: error.message || 'Failed to fetch tags' }, { status: error.statusCode });
}
return json({ error: error.message || 'Failed to fetch tags' }, { status: 500 });
}
+51 -19
View File
@@ -1,26 +1,41 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId, getHostDockerSocket } from '$lib/server/host-path';
import { getOwnContainerId, getHostDockerSocket, getOwnDockerHost, getOwnNetworkMode } from '$lib/server/host-path';
import { buildRegistryAuthHeader, unixSocketRequest, unixSocketStreamRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
import { prefersJSON, sseToJSON } from '$lib/server/sse';
const UPDATER_IMAGE = 'fnsys/dockhand-updater:latest';
const UPDATER_LABEL = 'dockhand.updater';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket (buffered). */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
/** Get TCP Docker host if configured, null otherwise. */
function getDockerTcpHost(): string | null {
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
return dockerHost?.startsWith('tcp://') ? dockerHost : null;
}
/** Fetch from the local Docker socket (streaming body for pull progress). */
/** Fetch from the local Docker (buffered). Supports TCP and Unix socket. */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
const tcpHost = getDockerTcpHost();
if (tcpHost) {
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path, options);
}
/** Fetch from the local Docker (streaming body for pull progress). */
function localDockerStreamFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketStreamRequest(DOCKER_SOCKET, path, options);
const tcpHost = getDockerTcpHost();
if (tcpHost) {
return fetch(tcpHost.replace('tcp://', 'http://') + path, options);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketStreamRequest(socketPath, path, options);
}
/**
* Pull an image via local Docker socket, streaming progress via callback.
* Pull an image via local Docker, streaming progress via callback.
*/
async function pullImageLocal(imageName: string, onProgress?: (line: string) => void): Promise<void> {
let fromImage = imageName;
@@ -79,9 +94,14 @@ async function pullImageLocal(imageName: string, onProgress?: (line: string) =>
}
/**
* Check if Docker socket is mounted read-write
* Check if Docker access allows write operations.
* TCP connections always allow writes (no RO mount concept).
* Socket connections check if the mount is read-write.
*/
async function isDockerSocketWritable(containerId: string): Promise<boolean> {
async function isDockerWritable(containerId: string): Promise<boolean> {
// TCP connections don't have mount-level RO/RW — access implies full control
if (getDockerTcpHost()) return true;
const response = await localDockerFetch(`/containers/${containerId}/json`);
if (!response.ok) return false;
@@ -235,7 +255,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Not running in Docker' }, { status: 400 });
}
const writable = await isDockerSocketWritable(containerId);
const writable = await isDockerWritable(containerId);
if (!writable) {
return json({
error: 'Docker socket is mounted read-only. Self-update requires read-write Docker socket access.'
@@ -252,8 +272,6 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
return json({ error: 'Failed to determine container name' }, { status: 500 });
}
const socketHostPath = getHostDockerSocket();
// Start SSE stream for preparation progress
const encoder = new TextEncoder();
let controllerClosed = false;
@@ -353,6 +371,25 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
...networkEnvVars
];
// Configure updater's Docker access based on connection type
const tcpHost = getDockerTcpHost();
const updaterHostConfig: Record<string, unknown> = { AutoRemove: true };
if (tcpHost) {
// TCP: pass DOCKER_HOST so docker CLI in sidecar uses TCP
updaterEnv.push(`DOCKER_HOST=${tcpHost}`);
// Put sidecar on same network so it can reach the Docker TCP endpoint
const network = getOwnNetworkMode();
if (network) {
updaterHostConfig.NetworkMode = network;
}
send('log', { message: `Updater using TCP: ${tcpHost}` });
} else {
// Socket: bind-mount the host Docker socket
const socketHostPath = getHostDockerSocket();
updaterHostConfig.Binds = [`${socketHostPath}:/var/run/docker.sock`];
}
console.log('[SelfUpdate] Creating updater container...');
const updaterResponse = await localDockerFetch('/containers/create?name=dockhand-updater', {
method: 'POST',
@@ -363,12 +400,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
Labels: {
[UPDATER_LABEL]: 'true'
},
HostConfig: {
AutoRemove: true,
Binds: [
`${socketHostPath}:/var/run/docker.sock`
]
}
HostConfig: updaterHostConfig
})
});
+13 -5
View File
@@ -1,15 +1,23 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnContainerId } from '$lib/server/host-path';
import { getOwnContainerId, getOwnDockerHost } from '$lib/server/host-path';
import { getRegistryManifestDigest, unixSocketRequest } from '$lib/server/docker';
import { compareVersions } from '$lib/utils/version';
import type { RequestHandler } from './$types';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly (not through environment routing) */
/** Fetch from the local Docker directly (not through environment routing) */
function localDockerFetch(path: string, options: RequestInit = {}): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path, options);
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
if (dockerHost?.startsWith('tcp://')) {
// TCP connection (socat proxy, socket-proxy, remote Docker)
const url = dockerHost.replace('tcp://', 'http://') + path;
return fetch(url, options);
}
// Unix socket (default)
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path, options);
}
/**
@@ -1,13 +1,17 @@
import { json } from '@sveltejs/kit';
import { authorize } from '$lib/server/authorize';
import { getOwnDockerHost } from '$lib/server/host-path';
import { unixSocketRequest } from '$lib/server/docker';
import type { RequestHandler } from './$types';
const DOCKER_SOCKET = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
/** Fetch from the local Docker socket directly */
/** Fetch from the local Docker directly. Supports TCP and Unix socket. */
function localDockerFetch(path: string): Promise<Response> {
return unixSocketRequest(DOCKER_SOCKET, path);
const dockerHost = process.env.DOCKER_HOST || getOwnDockerHost();
if (dockerHost?.startsWith('tcp://')) {
return fetch(dockerHost.replace('tcp://', 'http://') + path);
}
const socketPath = process.env.DOCKER_SOCKET || '/var/run/docker.sock';
return unixSocketRequest(socketPath, path);
}
/**
+13 -1
View File
@@ -65,6 +65,8 @@ export interface GeneralSettings {
gridFontSize: string;
terminalFont: string;
editorFont: string;
// Compact ports
compactPorts: boolean;
// External stack paths
externalStackPaths: string[];
// Primary stack location
@@ -85,6 +87,7 @@ const DEFAULT_SETTINGS: Omit<GeneralSettings, 'scheduleRetentionDays' | 'eventRe
eventCollectionMode: 'stream',
eventPollInterval: 60000,
metricsCollectionInterval: 30000,
compactPorts: false,
lightTheme: 'default',
darkTheme: 'default',
font: 'system',
@@ -140,6 +143,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
gridFontSize,
terminalFont,
editorFont,
compactPorts,
externalStackPaths,
primaryStackLocation
] = await Promise.all([
@@ -169,6 +173,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -200,6 +205,7 @@ export const GET: RequestHandler = async ({ cookies }) => {
gridFontSize: gridFontSize ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFont ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFont ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPorts ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths,
primaryStackLocation
};
@@ -219,7 +225,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const body = await request.json();
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, externalStackPaths, primaryStackLocation } = body;
const { confirmDestructive, showStoppedContainers, highlightUpdates, timeFormat, dateFormat, downloadFormat, defaultGrypeArgs, defaultTrivyArgs, scheduleRetentionDays, eventRetentionDays, scheduleCleanupCron, eventCleanupCron, scheduleCleanupEnabled, eventCleanupEnabled, logBufferSizeKb, defaultTimezone, eventCollectionMode, eventPollInterval, metricsCollectionInterval, lightTheme, darkTheme, font, fontSize, gridFontSize, terminalFont, editorFont, compactPorts, externalStackPaths, primaryStackLocation } = body;
if (confirmDestructive !== undefined) {
await setSetting('confirm_destructive', confirmDestructive);
@@ -312,6 +318,9 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (editorFont !== undefined && VALID_EDITOR_FONTS.includes(editorFont)) {
await setSetting('theme_editor_font', editorFont);
}
if (compactPorts !== undefined) {
await setSetting('compact_ports', compactPorts);
}
if (externalStackPaths !== undefined && Array.isArray(externalStackPaths)) {
// Filter to valid non-empty strings
const validPaths = externalStackPaths.filter((p: unknown) => typeof p === 'string' && p.trim());
@@ -355,6 +364,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
gridFontSizeVal,
terminalFontVal,
editorFontVal,
compactPortsVal,
externalStackPathsVal,
primaryStackLocationVal
] = await Promise.all([
@@ -384,6 +394,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
getSetting('theme_grid_font_size'),
getSetting('theme_terminal_font'),
getSetting('theme_editor_font'),
getSetting('compact_ports'),
getExternalStackPaths(),
getPrimaryStackLocation()
]);
@@ -415,6 +426,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
gridFontSize: gridFontSizeVal ?? DEFAULT_SETTINGS.gridFontSize,
terminalFont: terminalFontVal ?? DEFAULT_SETTINGS.terminalFont,
editorFont: editorFontVal ?? DEFAULT_SETTINGS.editorFont,
compactPorts: compactPortsVal ?? DEFAULT_SETTINGS.compactPorts,
externalStackPaths: externalStackPathsVal,
primaryStackLocation: primaryStackLocationVal
};
+11 -5
View File
@@ -24,9 +24,10 @@ export const GET: RequestHandler = async ({ params, url, cookies }) => {
const volume = await inspectVolume(params.name, envIdNum);
return json(volume);
} catch (error) {
console.error('Failed to inspect volume:', error);
return json({ error: 'Failed to inspect volume' }, { status: 500 });
} catch (error: any) {
const status = error.statusCode ?? 500;
console.error(`Failed to inspect volume ${params.name}: ${error.message}`);
return json({ error: 'Failed to inspect volume' }, { status });
}
};
@@ -57,7 +58,12 @@ export const DELETE: RequestHandler = async (event) => {
return json({ success: true });
} catch (error: any) {
console.error('Failed to remove volume:', error);
return json({ error: 'Failed to remove volume', details: error.message }, { status: 500 });
const status = error.statusCode ?? 500;
if (status === 404) {
console.warn(`Failed to remove volume ${params.name}: ${error.message}`);
} else {
console.error(`Failed to remove volume ${params.name}: ${error.message}`);
}
return json({ error: 'Failed to remove volume', details: error.message }, { status });
}
};