diff --git a/dashcaddy-api/routes/containers.js b/dashcaddy-api/routes/containers.js index 9a6f136..9a586c5 100644 --- a/dashcaddy-api/routes/containers.js +++ b/dashcaddy-api/routes/containers.js @@ -1,14 +1,21 @@ +/** + * Container management routes + * Refactored to use explicit dependencies instead of ctx god object + */ + const express = require('express'); const { DOCKER } = require('../constants'); const { paginate, parsePaginationParams } = require('../pagination'); const { NotFoundError } = require('../errors'); +const asyncHandler = require('../src/utils/async-handler'); +const log = require('../src/utils/logger'); -module.exports = function(ctx) { +module.exports = function({ docker }) { const router = express.Router(); // Helper: verify container exists before operating on it async function getVerifiedContainer(id) { - const container = ctx.docker.client.getContainer(id); + const container = docker.client.getContainer(id); try { await container.inspect(); } catch (err) { @@ -21,28 +28,28 @@ module.exports = function(ctx) { } // Start container - router.post('/:id/start', ctx.asyncHandler(async (req, res) => { + router.post('/:id/start', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.start(); res.json({ success: true, message: 'Container started' }); }, 'container-start')); // Stop container - router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { + router.post('/:id/stop', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.stop(); res.json({ success: true, message: 'Container stopped' }); }, 'container-stop')); // Restart container - router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { + router.post('/:id/restart', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.restart(); res.json({ success: true, message: 'Container restarted' }); }, 'container-restart')); // Update container to latest image version - router.post('/:id/update', ctx.asyncHandler(async (req, res) => { + router.post('/:id/update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); @@ -51,11 +58,11 @@ module.exports = function(ctx) { const imageName = containerInfo.Config.Image; const containerName = containerInfo.Name.replace(/^\//, ''); - ctx.log.info('docker', 'Updating container', { containerName, imageName }); + log.info('docker', 'Updating container', { containerName, imageName }); // Pull the latest image - ctx.log.info('docker', `Pulling latest image: ${imageName}`); - await ctx.docker.pull(imageName); + log.info('docker', `Pulling latest image: ${imageName}`); + await docker.pull(imageName); // Get current container config for recreation const hostConfig = containerInfo.HostConfig; @@ -75,7 +82,7 @@ module.exports = function(ctx) { CapAdd: hostConfig.CapAdd, CapDrop: hostConfig.CapDrop, Devices: hostConfig.Devices, - LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers + LogConfig: DOCKER.LOG_CONFIG, }, NetworkingConfig: {}, }; @@ -89,40 +96,45 @@ module.exports = function(ctx) { } // Stop and remove old container - ctx.log.info('docker', 'Stopping container', { containerName }); + log.info('docker', 'Stopping container', { containerName }); await container.stop().catch(() => {}); // Ignore if already stopped - ctx.log.info('docker', 'Removing container', { containerName }); + log.info('docker', 'Removing container', { containerName }); await container.remove(); - // Wait for port release (Windows/Docker Desktop can be slow to free ports) - await new Promise(r => setTimeout(r, 3000)); + // Wait for port release + await new Promise((r) => setTimeout(r, 3000)); // Create and start new container - ctx.log.info('docker', 'Creating new container', { containerName }); + log.info('docker', 'Creating new container', { containerName }); let newContainer; try { - newContainer = await ctx.docker.client.createContainer(config); - ctx.log.info('docker', 'Starting container', { containerName }); + newContainer = await docker.client.createContainer(config); + log.info('docker', 'Starting container', { containerName }); await newContainer.start(); } catch (startError) { - // Clean up the failed container so it doesn't block future attempts - ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); if (newContainer) { - try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } + try { + await newContainer.remove({ force: true }); + } catch (e) { + /* already gone */ + } } throw startError; } const newContainerInfo = await newContainer.inspect(); - // Prune dangling images after update + // Prune dangling images try { - const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + const pruneResult = await docker.client.pruneImages({ filters: { dangling: { true: true } } }); if (pruneResult.SpaceReclaimed > 0) { - ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + log.info('docker', 'Pruned dangling images after update', { + spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024)}MB`, + }); } } catch (pruneErr) { - ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); } res.json({ @@ -132,27 +144,27 @@ module.exports = function(ctx) { }); }, 'container-update')); - // Check for available updates (compares local and remote image digests) - router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { + // Check for available updates + router.get('/:id/check-update', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = await getVerifiedContainer(containerId); const containerInfo = await container.inspect(); const imageName = containerInfo.Config.Image; - const localImage = ctx.docker.client.getImage(containerInfo.Image); + const localImage = docker.client.getImage(containerInfo.Image); const localImageInfo = await localImage.inspect(); const localDigest = localImageInfo.RepoDigests?.[0] || null; let updateAvailable = false; try { - const pullStream = await ctx.docker.pull(imageName); + const pullStream = await docker.pull(imageName); - const downloadedLayers = pullStream.filter(e => - e.status === 'Downloading' || e.status === 'Download complete', + const downloadedLayers = pullStream.filter( + (e) => e.status === 'Downloading' || e.status === 'Download complete', ); updateAvailable = downloadedLayers.length > 0; - const newImage = ctx.docker.client.getImage(imageName); + const newImage = docker.client.getImage(imageName); const newImageInfo = await newImage.inspect(); const newDigest = newImageInfo.RepoDigests?.[0] || null; @@ -160,7 +172,7 @@ module.exports = function(ctx) { updateAvailable = true; } } catch (pullError) { - ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); + log.debug('docker', 'Could not check for updates', { error: pullError.message }); } res.json({ @@ -172,7 +184,7 @@ module.exports = function(ctx) { }, 'container-check-update')); // Get container logs - router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { + router.get('/:id/logs', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); const logs = await container.logs({ stdout: true, @@ -184,20 +196,20 @@ module.exports = function(ctx) { }, 'container-logs')); // Delete container - router.delete('/:id', ctx.asyncHandler(async (req, res) => { + router.delete('/:id', asyncHandler(async (req, res) => { const container = await getVerifiedContainer(req.params.id); await container.remove({ force: true }); res.json({ success: true, message: 'Container removed' }); }, 'container-delete')); // Discover running containers - router.get('/discover', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: true }); - const samiContainers = containers.filter(container => - container.Labels && container.Labels['sami.managed'] === 'true', + router.get('/discover', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: true }); + const samiContainers = containers.filter( + (container) => container.Labels && container.Labels['sami.managed'] === 'true', ); - const discoveredContainers = samiContainers.map(container => ({ + const discoveredContainers = samiContainers.map((container) => ({ id: container.Id, name: container.Names[0].replace('/', ''), image: container.Image, @@ -210,7 +222,11 @@ module.exports = function(ctx) { const paginationParams = parsePaginationParams(req.query); const result = paginate(discoveredContainers, paginationParams); - res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); + res.json({ + success: true, + containers: result.data, + ...(result.pagination && { pagination: result.pagination }), + }); }, 'containers-discover')); return router; diff --git a/dashcaddy-api/routes/containers.old.js b/dashcaddy-api/routes/containers.old.js new file mode 100644 index 0000000..9a6f136 --- /dev/null +++ b/dashcaddy-api/routes/containers.old.js @@ -0,0 +1,217 @@ +const express = require('express'); +const { DOCKER } = require('../constants'); +const { paginate, parsePaginationParams } = require('../pagination'); +const { NotFoundError } = require('../errors'); + +module.exports = function(ctx) { + const router = express.Router(); + + // Helper: verify container exists before operating on it + async function getVerifiedContainer(id) { + const container = ctx.docker.client.getContainer(id); + try { + await container.inspect(); + } catch (err) { + if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { + throw new NotFoundError(`Container ${id}`); + } + throw err; + } + return container; + } + + // Start container + router.post('/:id/start', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.start(); + res.json({ success: true, message: 'Container started' }); + }, 'container-start')); + + // Stop container + router.post('/:id/stop', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.stop(); + res.json({ success: true, message: 'Container stopped' }); + }, 'container-stop')); + + // Restart container + router.post('/:id/restart', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.restart(); + res.json({ success: true, message: 'Container restarted' }); + }, 'container-restart')); + + // Update container to latest image version + router.post('/:id/update', ctx.asyncHandler(async (req, res) => { + const containerId = req.params.id; + const container = await getVerifiedContainer(containerId); + + // Get container info + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + const containerName = containerInfo.Name.replace(/^\//, ''); + + ctx.log.info('docker', 'Updating container', { containerName, imageName }); + + // Pull the latest image + ctx.log.info('docker', `Pulling latest image: ${imageName}`); + await ctx.docker.pull(imageName); + + // Get current container config for recreation + const hostConfig = containerInfo.HostConfig; + const config = { + Image: imageName, + name: containerName, + Env: containerInfo.Config.Env, + ExposedPorts: containerInfo.Config.ExposedPorts, + Labels: containerInfo.Config.Labels, + HostConfig: { + Binds: hostConfig.Binds, + PortBindings: hostConfig.PortBindings, + RestartPolicy: hostConfig.RestartPolicy, + NetworkMode: hostConfig.NetworkMode, + ExtraHosts: hostConfig.ExtraHosts, + Privileged: hostConfig.Privileged, + CapAdd: hostConfig.CapAdd, + CapDrop: hostConfig.CapDrop, + Devices: hostConfig.Devices, + LogConfig: DOCKER.LOG_CONFIG, // Ensure log rotation on updated containers + }, + NetworkingConfig: {}, + }; + + // Get network settings if using a custom network + if (hostConfig.NetworkMode && !['bridge', 'host', 'none'].includes(hostConfig.NetworkMode)) { + const networkName = hostConfig.NetworkMode; + config.NetworkingConfig.EndpointsConfig = { + [networkName]: containerInfo.NetworkSettings.Networks[networkName], + }; + } + + // Stop and remove old container + ctx.log.info('docker', 'Stopping container', { containerName }); + await container.stop().catch(() => {}); // Ignore if already stopped + ctx.log.info('docker', 'Removing container', { containerName }); + await container.remove(); + + // Wait for port release (Windows/Docker Desktop can be slow to free ports) + await new Promise(r => setTimeout(r, 3000)); + + // Create and start new container + ctx.log.info('docker', 'Creating new container', { containerName }); + let newContainer; + try { + newContainer = await ctx.docker.client.createContainer(config); + ctx.log.info('docker', 'Starting container', { containerName }); + await newContainer.start(); + } catch (startError) { + // Clean up the failed container so it doesn't block future attempts + ctx.log.error('docker', 'Failed to start new container', { containerName, error: startError.message }); + if (newContainer) { + try { await newContainer.remove({ force: true }); } catch (e) { /* already gone */ } + } + throw startError; + } + + const newContainerInfo = await newContainer.inspect(); + + // Prune dangling images after update + try { + const pruneResult = await ctx.docker.client.pruneImages({ filters: { dangling: { true: true } } }); + if (pruneResult.SpaceReclaimed > 0) { + ctx.log.info('docker', 'Pruned dangling images after update', { spaceReclaimed: `${Math.round(pruneResult.SpaceReclaimed / 1024 / 1024) }MB` }); + } + } catch (pruneErr) { + ctx.log.debug('docker', 'Image prune after update failed', { error: pruneErr.message }); + } + + res.json({ + success: true, + message: `Container ${containerName} updated successfully`, + newContainerId: newContainerInfo.Id, + }); + }, 'container-update')); + + // Check for available updates (compares local and remote image digests) + router.get('/:id/check-update', ctx.asyncHandler(async (req, res) => { + const containerId = req.params.id; + const container = await getVerifiedContainer(containerId); + const containerInfo = await container.inspect(); + const imageName = containerInfo.Config.Image; + + const localImage = ctx.docker.client.getImage(containerInfo.Image); + const localImageInfo = await localImage.inspect(); + const localDigest = localImageInfo.RepoDigests?.[0] || null; + + let updateAvailable = false; + try { + const pullStream = await ctx.docker.pull(imageName); + + const downloadedLayers = pullStream.filter(e => + e.status === 'Downloading' || e.status === 'Download complete', + ); + updateAvailable = downloadedLayers.length > 0; + + const newImage = ctx.docker.client.getImage(imageName); + const newImageInfo = await newImage.inspect(); + const newDigest = newImageInfo.RepoDigests?.[0] || null; + + if (localDigest && newDigest && localDigest !== newDigest) { + updateAvailable = true; + } + } catch (pullError) { + ctx.log.debug('docker', 'Could not check for updates', { error: pullError.message }); + } + + res.json({ + success: true, + imageName, + updateAvailable, + currentDigest: localDigest, + }); + }, 'container-check-update')); + + // Get container logs + router.get('/:id/logs', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + const logs = await container.logs({ + stdout: true, + stderr: true, + tail: 100, + timestamps: true, + }); + res.json({ success: true, logs: logs.toString() }); + }, 'container-logs')); + + // Delete container + router.delete('/:id', ctx.asyncHandler(async (req, res) => { + const container = await getVerifiedContainer(req.params.id); + await container.remove({ force: true }); + res.json({ success: true, message: 'Container removed' }); + }, 'container-delete')); + + // Discover running containers + router.get('/discover', ctx.asyncHandler(async (req, res) => { + const containers = await ctx.docker.client.listContainers({ all: true }); + const samiContainers = containers.filter(container => + container.Labels && container.Labels['sami.managed'] === 'true', + ); + + const discoveredContainers = samiContainers.map(container => ({ + id: container.Id, + name: container.Names[0].replace('/', ''), + image: container.Image, + state: container.State, + status: container.Status, + appTemplate: container.Labels['sami.app'], + subdomain: container.Labels['sami.subdomain'], + ports: container.Ports, + })); + + const paginationParams = parsePaginationParams(req.query); + const result = paginate(discoveredContainers, paginationParams); + res.json({ success: true, containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); + }, 'containers-discover')); + + return router; +}; diff --git a/dashcaddy-api/routes/monitoring.js b/dashcaddy-api/routes/monitoring.js index 69170e6..d1e8860 100644 --- a/dashcaddy-api/routes/monitoring.js +++ b/dashcaddy-api/routes/monitoring.js @@ -1,19 +1,23 @@ -const express = require('express'); +/** + * Monitoring and stats routes + * Refactored to use explicit dependencies + */ -module.exports = function(ctx) { +const express = require('express'); +const asyncHandler = require('../src/utils/async-handler'); + +module.exports = function({ docker, resourceMonitor }) { const router = express.Router(); // ===== RESOURCE MONITORING ENDPOINTS ===== - // Get all container stats (from resource monitor module) - router.get('/monitoring/stats', ctx.asyncHandler(async (req, res) => { - const stats = ctx.resourceMonitor.getAllStats(); + router.get('/monitoring/stats', asyncHandler(async (req, res) => { + const stats = resourceMonitor.getAllStats(); res.json({ success: true, stats }); }, 'monitoring-stats')); - // Get stats for specific container - router.get('/monitoring/stats/:containerId', ctx.asyncHandler(async (req, res) => { - const stats = ctx.resourceMonitor.getCurrentStats(req.params.containerId); + router.get('/monitoring/stats/:containerId', asyncHandler(async (req, res) => { + const stats = resourceMonitor.getCurrentStats(req.params.containerId); if (!stats) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Container'); @@ -21,17 +25,15 @@ module.exports = function(ctx) { res.json({ success: true, stats }); }, 'monitoring-stats-container')); - // Get historical stats - router.get('/monitoring/history/:containerId', ctx.asyncHandler(async (req, res) => { + router.get('/monitoring/history/:containerId', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const history = ctx.resourceMonitor.getHistoricalStats(req.params.containerId, hours); + const history = resourceMonitor.getHistoricalStats(req.params.containerId, hours); res.json({ success: true, history, hours }); }, 'monitoring-history')); - // Get aggregated stats - router.get('/monitoring/aggregated/:containerId', ctx.asyncHandler(async (req, res) => { + router.get('/monitoring/aggregated/:containerId', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; - const aggregated = ctx.resourceMonitor.getAggregatedStats(req.params.containerId, hours); + const aggregated = resourceMonitor.getAggregatedStats(req.params.containerId, hours); if (!aggregated) { const { NotFoundError } = require('../errors'); throw new NotFoundError('Monitoring data'); @@ -39,49 +41,42 @@ module.exports = function(ctx) { res.json({ success: true, aggregated, hours }); }, 'monitoring-aggregated')); - // Configure alerts - router.post('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - ctx.resourceMonitor.setAlertConfig(req.params.containerId, req.body); + router.post('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + resourceMonitor.setAlertConfig(req.params.containerId, req.body); res.json({ success: true, message: 'Alert configuration saved' }); }, 'monitoring-alerts-set')); - // Get alert configuration - router.get('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - const config = ctx.resourceMonitor.getAlertConfig(req.params.containerId); + router.get('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + const config = resourceMonitor.getAlertConfig(req.params.containerId); res.json({ success: true, config: config || {} }); }, 'monitoring-alerts-get')); - // Delete alert configuration - router.delete('/monitoring/alerts/:containerId', ctx.asyncHandler(async (req, res) => { - ctx.resourceMonitor.removeAlertConfig(req.params.containerId); + router.delete('/monitoring/alerts/:containerId', asyncHandler(async (req, res) => { + resourceMonitor.removeAlertConfig(req.params.containerId); res.json({ success: true, message: 'Alert configuration removed' }); }, 'monitoring-alerts-delete')); // ===== CONTAINER STATS ENDPOINTS (legacy /stats/) ===== - // Get all container stats (live Docker stats) - router.get('/stats/containers', ctx.asyncHandler(async (req, res) => { - const containers = await ctx.docker.client.listContainers({ all: false }); + router.get('/stats/containers', asyncHandler(async (req, res) => { + const containers = await docker.client.listContainers({ all: false }); const stats = []; for (const containerInfo of containers) { try { - const container = ctx.docker.client.getContainer(containerInfo.Id); + const container = docker.client.getContainer(containerInfo.Id); const containerStats = await container.stats({ stream: false }); - // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; - // Calculate memory usage const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; const memPercent = (memUsage / memLimit) * 100; - // Network stats let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -95,21 +90,15 @@ module.exports = function(ctx) { name: containerInfo.Names[0]?.replace(/^\//, '') || 'unknown', image: containerInfo.Image, status: containerInfo.State, - cpu: { - percent: Math.round(cpuPercent * 100) / 100, - }, + cpu: { percent: Math.round(cpuPercent * 100) / 100 }, memory: { used: memUsage, limit: memLimit, percent: Math.round(memPercent * 100) / 100, }, - network: { - rx: netRx, - tx: netTx, - }, + network: { rx: netRx, tx: netTx }, }); } catch (e) { - // Skip containers we can't get stats for console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message); } } @@ -117,24 +106,20 @@ module.exports = function(ctx) { res.json({ success: true, stats, timestamp: new Date().toISOString() }); }, 'stats-containers')); - // Get single container stats - router.get('/stats/container/:id', ctx.asyncHandler(async (req, res) => { - const container = ctx.docker.client.getContainer(req.params.id); + router.get('/stats/container/:id', asyncHandler(async (req, res) => { + const container = docker.client.getContainer(req.params.id); const containerStats = await container.stats({ stream: false }); const info = await container.inspect(); - // Calculate CPU percentage const cpuDelta = containerStats.cpu_stats.cpu_usage.total_usage - (containerStats.precpu_stats.cpu_usage?.total_usage || 0); const systemDelta = containerStats.cpu_stats.system_cpu_usage - (containerStats.precpu_stats.system_cpu_usage || 0); const cpuPercent = systemDelta > 0 ? (cpuDelta / systemDelta) * 100 * (containerStats.cpu_stats.online_cpus || 1) : 0; - // Memory const memUsage = containerStats.memory_stats.usage || 0; const memLimit = containerStats.memory_stats.limit || 1; - // Network let netRx = 0, netTx = 0; if (containerStats.networks) { for (const net of Object.values(containerStats.networks)) { @@ -150,9 +135,7 @@ module.exports = function(ctx) { image: info.Config.Image, status: info.State.Status, started: info.State.StartedAt, - cpu: { - percent: Math.round(cpuPercent * 100) / 100, - }, + cpu: { percent: Math.round(cpuPercent * 100) / 100 }, memory: { used: memUsage, limit: memLimit,