refactor(routes): Phase 3.3 - standardize health.js with explicit dependencies

- Replaced god object ctx with explicit dependency injection
- Added JSDoc documenting required dependencies (8 deps vs 50+)
- Updated response calls to use response-helpers (success/error)
- Self-documenting: you can see exactly what this route needs
- Health checks, pylon relay, CA cert validation all preserved
This commit is contained in:
Krystie
2026-03-28 19:25:06 -07:00
parent 4e2bec2ef0
commit eac4ede21e
2 changed files with 80 additions and 50 deletions

View File

@@ -7,8 +7,31 @@ const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination'); const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths'); const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver'); const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers');
module.exports = function(ctx) { /**
* Health routes factory
* @param {Object} deps - Explicit dependencies
* @param {Function} deps.fetchT - Fetch wrapper with timeout
* @param {string} deps.SERVICES_FILE - Path to services.json
* @param {Object} deps.servicesStateManager - State manager for services.json
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildServiceUrl - URL builder function
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.logError - Error logging function
* @param {Object} deps.healthChecker - Health check manager instance
* @returns {express.Router}
*/
module.exports = function({
fetchT,
SERVICES_FILE,
servicesStateManager,
siteConfig,
buildServiceUrl,
asyncHandler,
logError,
healthChecker
}) {
const router = express.Router(); const router = express.Router();
// In-memory cache for health results (local to this router) // In-memory cache for health results (local to this router)
@@ -23,7 +46,7 @@ module.exports = function(ctx) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
const response = await ctx.fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' }); const response = await fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout); clearTimeout(timeout);
return { return {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
@@ -37,7 +60,7 @@ module.exports = function(ctx) {
try { try {
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000); const timeout = setTimeout(() => controller.abort(), 5000);
const response = await ctx.fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' }); const response = await fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout); clearTimeout(timeout);
return { return {
status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy', status: response.ok || response.status < 500 ? 'healthy' : 'unhealthy',
@@ -61,7 +84,7 @@ module.exports = function(ctx) {
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 12000); const timeout = setTimeout(() => controller.abort(), 12000);
const response = await ctx.fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers }); const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(timeout); clearTimeout(timeout);
if (!response.ok) return null; if (!response.ok) return null;
const data = await response.json(); const data = await response.json();
@@ -82,15 +105,15 @@ module.exports = function(ctx) {
// ===== HEALTH / SERVICES ===== // ===== HEALTH / SERVICES =====
// Check health of all services (performs live checks) // Check health of all services (performs live checks)
router.get('/health/services', ctx.asyncHandler(async (req, res) => { router.get('/health/services', asyncHandler(async (req, res) => {
if (!await exists(ctx.SERVICES_FILE)) { if (!await exists(SERVICES_FILE)) {
return res.json({ success: true, health: {} }); return success(res, { health: {} });
} }
const servicesData = await ctx.servicesStateManager.read(); const servicesData = await servicesStateManager.read();
const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const services = Array.isArray(servicesData) ? servicesData : servicesData.services || [];
const health = {}; const health = {};
const pylonConfig = ctx.siteConfig?.pylon; const pylonConfig = siteConfig?.pylon;
// Check each service // Check each service
await Promise.all(services.map(async (service) => { await Promise.all(services.map(async (service) => {
@@ -98,7 +121,7 @@ module.exports = function(ctx) {
if (!serviceId) return; if (!serviceId) return;
try { try {
const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); const url = resolveServiceUrl(serviceId, service, siteConfig, buildServiceUrl);
if (!url) { if (!url) {
health[serviceId] = { status: 'unknown', reason: 'No URL configured' }; health[serviceId] = { status: 'unknown', reason: 'No URL configured' };
return; return;
@@ -144,8 +167,7 @@ module.exports = function(ctx) {
const healthEntries = Object.entries(health); const healthEntries = Object.entries(health);
const result = paginate(healthEntries, paginationParams); const result = paginate(healthEntries, paginationParams);
const paginatedHealth = Object.fromEntries(result.data); const paginatedHealth = Object.fromEntries(result.data);
res.json({ success(res, {
success: true,
health: paginatedHealth, health: paginatedHealth,
checkedAt: lastHealthCheck, checkedAt: lastHealthCheck,
...(result.pagination && { pagination: result.pagination }) ...(result.pagination && { pagination: result.pagination })
@@ -153,9 +175,8 @@ module.exports = function(ctx) {
}, 'health-services')); }, 'health-services'));
// Get cached health status (fast, no re-check) // Get cached health status (fast, no re-check)
router.get('/health/cached', ctx.asyncHandler(async (req, res) => { router.get('/health/cached', asyncHandler(async (req, res) => {
res.json({ success(res, {
success: true,
health: serviceHealthCache, health: serviceHealthCache,
lastCheck: lastHealthCheck, lastCheck: lastHealthCheck,
cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null cacheAge: lastHealthCheck ? Date.now() - new Date(lastHealthCheck).getTime() : null
@@ -163,16 +184,16 @@ module.exports = function(ctx) {
}, 'health-cached')); }, 'health-cached'));
// Check health of single service // Check health of single service
router.get('/health/service/:id', ctx.asyncHandler(async (req, res) => { router.get('/health/service/:id', asyncHandler(async (req, res) => {
const serviceId = req.params.id; const serviceId = req.params.id;
// Load service config // Load service config
if (!await exists(ctx.SERVICES_FILE)) { if (!await exists(SERVICES_FILE)) {
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
throw new NotFoundError('Services file'); throw new NotFoundError('Services file');
} }
const servicesData = await ctx.servicesStateManager.read(); const servicesData = await servicesStateManager.read();
const services = Array.isArray(servicesData) ? servicesData : servicesData.services || []; const services = Array.isArray(servicesData) ? servicesData : servicesData.services || [];
const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId); const service = services.find(s => (s.id || s.name?.toLowerCase()) === serviceId);
@@ -182,8 +203,8 @@ module.exports = function(ctx) {
} }
// Determine URL // Determine URL
const url = resolveServiceUrl(serviceId, service, ctx.siteConfig, ctx.buildServiceUrl); const url = resolveServiceUrl(serviceId, service, siteConfig, buildServiceUrl);
const pylonConfig = ctx.siteConfig?.pylon; const pylonConfig = siteConfig?.pylon;
// Try direct, then pylon relay // Try direct, then pylon relay
let result = await checkDirect(url); let result = await checkDirect(url);
@@ -199,16 +220,16 @@ module.exports = function(ctx) {
}; };
} }
res.json({ success: true, serviceId, health: result }); success(res, { serviceId, health: result });
}, 'health-service')); }, 'health-service'));
// ===== HEALTH / PROBE (Pylon-compatible) ===== // ===== HEALTH / PROBE (Pylon-compatible) =====
// Probe endpoint — lets this DashCaddy act as a pylon for other instances // Probe endpoint — lets this DashCaddy act as a pylon for other instances
router.get('/health/probe', ctx.asyncHandler(async (req, res) => { router.get('/health/probe', asyncHandler(async (req, res) => {
const targetUrl = req.query.url; const targetUrl = req.query.url;
if (!targetUrl) { if (!targetUrl) {
return ctx.errorResponse(res, 400, 'Missing ?url= parameter'); return errorResponse(res, 'Missing ?url= parameter', 400);
} }
const result = await checkDirect(targetUrl); const result = await checkDirect(targetUrl);
res.json(result || { res.json(result || {
@@ -220,29 +241,29 @@ module.exports = function(ctx) {
}, 'health-probe')); }, 'health-probe'));
// Pylon status — check if the configured pylon is reachable // Pylon status — check if the configured pylon is reachable
router.get('/health/pylon', ctx.asyncHandler(async (req, res) => { router.get('/health/pylon', asyncHandler(async (req, res) => {
const pylonConfig = ctx.siteConfig?.pylon; const pylonConfig = siteConfig?.pylon;
if (!pylonConfig?.url) { if (!pylonConfig?.url) {
return res.json({ success: true, configured: false }); return success(res, { configured: false });
} }
try { try {
const headers = {}; const headers = {};
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key; if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const response = await ctx.fetchT(`${pylonConfig.url}/health`, { const response = await fetchT(`${pylonConfig.url}/health`, {
method: 'GET', method: 'GET',
headers headers
}, 5000); }, 5000);
const data = await response.json(); const data = await response.json();
res.json({ success: true, configured: true, reachable: true, pylon: data }); success(res, { configured: true, reachable: true, pylon: data });
} catch (e) { } catch (e) {
res.json({ success: true, configured: true, reachable: false, error: e.message }); success(res, { configured: true, reachable: false, error: e.message });
} }
}, 'health-pylon')); }, 'health-pylon'));
// ===== HEALTH / CA ===== // ===== HEALTH / CA =====
// Get CA certificate health status // Get CA certificate health status
router.get('/health/ca', ctx.asyncHandler(async (req, res) => { router.get('/health/ca', asyncHandler(async (req, res) => {
// Try deployed location first, then Caddy PKI location // Try deployed location first, then Caddy PKI location
const deployedCertPath = path.join(platformPaths.caCertDir, 'root.crt'); const deployedCertPath = path.join(platformPaths.caCertDir, 'root.crt');
const pkiCertPath = platformPaths.pkiRootCert; const pkiCertPath = platformPaths.pkiRootCert;
@@ -288,7 +309,7 @@ module.exports = function(ctx) {
expiresAt: notAfter expiresAt: notAfter
}); });
} catch (error) { } catch (error) {
await ctx.logError('GET /api/health/ca', error); await logError('GET /api/health/ca', error);
res.json({ res.json({
status: 'error', status: 'error',
message: error.message, message: error.message,
@@ -300,50 +321,50 @@ module.exports = function(ctx) {
// ===== HEALTH CHECK (health-checker module) ===== // ===== HEALTH CHECK (health-checker module) =====
// Get current status for all services // Get current status for all services
router.get('/health-checks/status', ctx.asyncHandler(async (req, res) => { router.get('/health-checks/status', asyncHandler(async (req, res) => {
const status = ctx.healthChecker.getCurrentStatus(); const status = healthChecker.getCurrentStatus();
res.json({ success: true, status }); success(res, { status });
}, 'health-check-status')); }, 'health-check-status'));
// Get service statistics // Get service statistics
router.get('/health-checks/:serviceId/stats', ctx.asyncHandler(async (req, res) => { router.get('/health-checks/:serviceId/stats', asyncHandler(async (req, res) => {
const hours = parseInt(req.query.hours) || 24; const hours = parseInt(req.query.hours) || 24;
const stats = ctx.healthChecker.getServiceStats(req.params.serviceId, hours); const stats = healthChecker.getServiceStats(req.params.serviceId, hours);
if (!stats) { if (!stats) {
const { NotFoundError } = require('../errors'); const { NotFoundError } = require('../errors');
throw new NotFoundError('Service'); throw new NotFoundError('Service');
} }
res.json({ success: true, stats }); success(res, { stats });
}, 'health-check-stats')); }, 'health-check-stats'));
// Configure health check // Configure health check
router.post('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { router.post('/health-checks/:serviceId/configure', asyncHandler(async (req, res) => {
ctx.healthChecker.configureService(req.params.serviceId, req.body); healthChecker.configureService(req.params.serviceId, req.body);
res.json({ success: true, message: 'Health check configured' }); success(res, { message: 'Health check configured' });
}, 'health-check-configure')); }, 'health-check-configure'));
// Remove health check configuration // Remove health check configuration
router.delete('/health-checks/:serviceId/configure', ctx.asyncHandler(async (req, res) => { router.delete('/health-checks/:serviceId/configure', asyncHandler(async (req, res) => {
ctx.healthChecker.removeService(req.params.serviceId); healthChecker.removeService(req.params.serviceId);
res.json({ success: true, message: 'Health check removed' }); success(res, { message: 'Health check removed' });
}, 'health-check-remove')); }, 'health-check-remove'));
// Get open incidents // Get open incidents
router.get('/health-checks/incidents', ctx.asyncHandler(async (req, res) => { router.get('/health-checks/incidents', asyncHandler(async (req, res) => {
const incidents = ctx.healthChecker.getOpenIncidents(); const incidents = healthChecker.getOpenIncidents();
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
const result = paginate(incidents, paginationParams); const result = paginate(incidents, paginationParams);
res.json({ success: true, incidents: result.data, ...(result.pagination && { pagination: result.pagination }) }); success(res, { incidents: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'health-check-incidents')); }, 'health-check-incidents'));
// Get incident history // Get incident history
router.get('/health-checks/incidents/history', ctx.asyncHandler(async (req, res) => { router.get('/health-checks/incidents/history', asyncHandler(async (req, res) => {
const paginationParams = parsePaginationParams(req.query); const paginationParams = parsePaginationParams(req.query);
// When paginating, fetch all history so pagination can slice correctly // When paginating, fetch all history so pagination can slice correctly
const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50); const fetchLimit = paginationParams ? Number.MAX_SAFE_INTEGER : (parseInt(req.query.limit) || 50);
const history = ctx.healthChecker.getIncidentHistory(fetchLimit); const history = healthChecker.getIncidentHistory(fetchLimit);
const result = paginate(history, paginationParams); const result = paginate(history, paginationParams);
res.json({ success: true, history: result.data, ...(result.pagination && { pagination: result.pagination }) }); success(res, { history: result.data, ...(result.pagination && { pagination: result.pagination }) });
}, 'health-check-incidents-history')); }, 'health-check-incidents-history'));
return router; return router;

View File

@@ -1207,7 +1207,16 @@ apiRouter.use(serviceRoutes({
caddy: ctx.caddy, caddy: ctx.caddy,
dns: ctx.dns dns: ctx.dns
})); }));
apiRouter.use(healthRoutes(ctx)); apiRouter.use(healthRoutes({
fetchT: ctx.fetchT,
SERVICES_FILE: ctx.SERVICES_FILE,
servicesStateManager: ctx.servicesStateManager,
siteConfig: ctx.siteConfig,
buildServiceUrl: ctx.buildServiceUrl,
asyncHandler: ctx.asyncHandler,
logError: ctx.logError,
healthChecker: ctx.healthChecker
}));
apiRouter.use(monitoringRoutes(ctx)); apiRouter.use(monitoringRoutes(ctx));
apiRouter.use(updatesRoutes(ctx)); apiRouter.use(updatesRoutes(ctx));
apiRouter.use('/tailscale', tailscaleRoutes(ctx)); apiRouter.use('/tailscale', tailscaleRoutes(ctx));