Compare commits

...

7 Commits

Author SHA1 Message Date
Krystie
a216dd882d Add dashboard version button and self-update UI wiring 2026-05-05 17:52:05 -07:00
Krystie
95b137bf17 Fix DNS2 self-updater path and sync live dashboard version UI 2026-05-05 17:26:42 -07:00
Krystie
f5fe32b999 feat(update): add release policy checks and dashboard version verification 2026-05-04 18:05:00 -07:00
Krystie
0c658a26a8 fix(routes): complete post-refactor dependency wiring cleanup 2026-05-04 16:44:18 -07:00
Krystie
4eebb3ce7a fix(test): remove stale jest setupFilesAfterEnv entry 2026-05-04 16:36:09 -07:00
Krystie
55c405082a fix: use TIMEOUTS constants instead of magic numbers in health and services routes
- health.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT (twice)
- services.js: replace magic number 5000 with TIMEOUTS.HTTP_DEFAULT

Both files already import TIMEOUTS from constants but weren't using it.
2026-05-01 02:36:31 -07:00
Krystie
2f1e2107bc fix: replace console.log/console.error with proper logging in monitoring and themes routes
- monitoring.js: Added log dependency, replaced console.log with log.warn
- themes.js: Added log dependency, replaced console.error with log.error
- src/app.js: Pass log to monitoringRoutes and themesRoutes

This fixes error messages being lost to stdout instead of proper log files.
2026-05-01 02:24:59 -07:00
43 changed files with 955 additions and 439 deletions

View File

@@ -53,5 +53,30 @@ module.exports = {
'max-depth': 'off',
},
},
{
// Browser-side assets (client JS)
files: ['assets/**/*.js', 'frontend/**/*.js'],
env: {
browser: true,
es2021: true,
node: false,
},
globals: {
// Common dashboard globals from status/index.html context
apiUrl: 'readonly',
API_BASE_URL: 'readonly',
CONFIG: 'readonly',
// Client-side dashboard classes (loaded via script tags)
ErrorHandler: 'readonly',
ProgressTracker: 'readonly',
ThemeAdapter: 'readonly',
DnsTemplateSelector: 'readonly',
TourManager: 'readonly',
TooltipDefinitions: 'readonly',
},
rules: {
'no-undef': 'warn',
},
},
],
};

View File

@@ -14,6 +14,9 @@ const {
} = require('../input-validator');
describe('Input Validator', () => {
function fail(message) {
throw new Error(message);
}
describe('ValidationError', () => {
it('has correct name, message, field, and statusCode', () => {

View File

@@ -524,9 +524,7 @@ describe('Health Routes', () => {
it('returns error when no url parameter provided', async () => {
const { app } = createApp();
const res = await request(app).get('/api/health/probe');
// ValidationError is not imported at module scope, so this throws a ReferenceError
// which the error handler catches as a 500
expect(res.status).toBe(500);
expect(res.status).toBe(400);
});
});

View File

@@ -296,7 +296,7 @@
* @returns {boolean} True if theme is available
*/
isThemeAvailable(themeName) {
return THEME_CONFIGS.hasOwnProperty(themeName);
return Object.hasOwn(THEME_CONFIGS, themeName);
}
}

View File

@@ -75,7 +75,7 @@ class BackupManager extends EventEmitter {
case 'monthly':
intervalMs = 30 * 24 * 60 * 60 * 1000;
break;
default:
default: {
// Custom interval in minutes
const minutes = parseInt(backup.schedule, 10);
if (!isNaN(minutes) && minutes > 0) {
@@ -85,6 +85,7 @@ class BackupManager extends EventEmitter {
return;
}
}
}
// Schedule the job
const job = setInterval(() => {

View File

@@ -20,7 +20,7 @@ const colors = {
magenta: '\x1b[35m'
};
let testResults = {
const testResults = {
passed: 0,
failed: 0,
warnings: 0,

View File

@@ -507,7 +507,7 @@ async function validateSecurePath(requestedPath, allowedRoots, auditLogger = nul
const suspiciousPatterns = [
/\.\./, // ..
/%2e%2e/i, // URL encoded ..
/\.\%2f/i, // .%2F (encoded ./)
/\.%2f/i, // .%2F (encoded ./)
/%2e\./i, // %2E.
/\.\\/, // .\ (Windows)
/%5c/i // URL encoded backslash

View File

@@ -32,7 +32,6 @@ module.exports = {
statements: 80
}
},
setupFilesAfterEnv: ['<rootDir>/__tests__/jest.setup.js'],
restoreMocks: true,
clearMocks: true
};

View File

@@ -256,7 +256,9 @@ module.exports = function({ docker, caddy, servicesStateManager, portLockManager
const existing = docker.client.getContainer(containerName);
await existing.remove({ force: true });
await new Promise(r => setTimeout(r, 1000));
} catch (_) {}
} catch (_) {
// No stale container to remove
}
const container = await docker.client.createContainer(containerConfig);
await container.start();

View File

@@ -17,8 +17,7 @@ const platformPaths = require('../../platform-paths');
* @param {Object} deps.log - Logger instance
* @returns {Object} Helper functions
*/
module.exports = function(ctx) {
const { docker, caddy, credentialManager, servicesStateManager, fetchT, log } = ctx;
module.exports = function({ docker, caddy, credentialManager, servicesStateManager, fetchT, log, ctx }) {
async function checkPortConflicts(ports, excludeContainerName = null) {
const conflicts = [];

View File

@@ -27,17 +27,21 @@ module.exports = function(ctx) {
log: ctx.log,
// Additional context properties needed by routes
APP_TEMPLATES: ctx.APP_TEMPLATES,
TEMPLATE_CATEGORIES: ctx.TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS: ctx.DIFFICULTY_LEVELS,
siteConfig: ctx.siteConfig,
buildDomain: ctx.buildDomain,
buildServiceUrl: ctx.buildServiceUrl,
addServiceToConfig: ctx.addServiceToConfig,
dns: ctx.dns,
notification: ctx.notification,
safeErrorMessage: ctx.safeErrorMessage
safeErrorMessage: ctx.safeErrorMessage,
SERVICES_FILE: ctx.SERVICES_FILE,
ctx: ctx
};
// Initialize helpers with dependencies
const helpers = initHelpers(ctx);
// Initialize helpers with dependencies (ctx is the Koa context)
const helpers = initHelpers({ ...deps, ctx });
// Mount sub-routes — pass full ctx so sub-routes can reference ctx.* properties
const subCtx = Object.assign({}, ctx, { helpers });

View File

@@ -1,9 +1,23 @@
const express = require('express');
const { exists } = require('../../fs-helpers');
const { logError } = require('../../src/utils/logging');
module.exports = function(ctx) {
const { docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers } = ctx;
module.exports = function({
docker, caddy, servicesStateManager, asyncHandler, log, helpers,
errorResponse, dns, siteConfig, buildDomain, SERVICES_FILE, safeErrorMessage
}) {
const router = express.Router();
// Ctx shim for backward compatibility with existing route code
const ctx = {
dns,
siteConfig,
buildDomain,
SERVICES_FILE,
safeErrorMessage
};
// Remove deployed app
/**
* Apps removal routes factory
* @param {Object} deps - Explicit dependencies
@@ -13,6 +27,12 @@ module.exports = function(ctx) {
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.dns - DNS context
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildDomain - Build domain helper
* @param {string} deps.SERVICES_FILE - Services file path
* @param {Function} deps.safeErrorMessage - Safe error message formatter
* @returns {express.Router}
*/
router.delete('/apps/:appId', asyncHandler(async (req, res) => {

View File

@@ -8,14 +8,23 @@ const { DOCKER } = require('../../constants');
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Apps helpers module
* @param {Object} deps.APP_TEMPLATES - App templates registry
* @param {Object} deps.dns - DNS client
* @param {Function} deps.buildServiceUrl - Service URL builder
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { docker, caddy, servicesStateManager, asyncHandler, log, helpers } = ctx;
module.exports = function({ docker, caddy, servicesStateManager, asyncHandler, errorResponse, log, helpers, APP_TEMPLATES, dns, buildServiceUrl }) {
const router = express.Router();
const ctx = {
APP_TEMPLATES,
dns,
buildServiceUrl
};
/**
* Restore a single service from its deployment manifest.
* Pulls image, creates container, starts it, recreates Caddy config.
@@ -125,11 +134,6 @@ module.exports = function(ctx) {
// Static sites: just recreate Caddy config
if (template?.isStaticSite) {
log.info('restore', `Restoring static site Caddy config: ${service.name}`);
const caddyOptions = {
tailscaleOnly: manifest.caddy.tailscaleOnly,
allowedIPs: manifest.caddy.allowedIPs,
subpathSupport: manifest.caddy.subpathSupport,
};
// Static site Caddy config would need to be regenerated
// For now, just confirm the service entry exists
return {
@@ -288,7 +292,7 @@ module.exports = function(ctx) {
const svc = services.find(s => s.id === service.id);
if (svc) {
svc.containerId = container.id;
svc.url = ctx.buildServiceUrl(manifest.config.subdomain);
svc.url = buildServiceUrl(manifest.config.subdomain);
}
return services;
});

View File

@@ -6,14 +6,40 @@ const { exists } = require('../../fs-helpers');
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.helpers - Apps helpers module
* @param {Object} deps.APP_TEMPLATES - App templates registry
* @param {Object} deps.TEMPLATE_CATEGORIES - Template categories
* @param {Object} deps.DIFFICULTY_LEVELS - Difficulty levels
* @param {Object} deps.docker - Docker client
* @param {Object} deps.caddy - Caddy client
* @param {Object} deps.dns - DNS context
* @param {Object} deps.siteConfig - Site configuration
* @param {Function} deps.buildDomain - Build domain helper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {string} deps.SERVICES_FILE - Services file path
* @returns {express.Router}
*/
const { REGEX } = require('../../constants');
module.exports = function(ctx) {
const { servicesStateManager, asyncHandler, helpers, docker, caddy, log, errorResponse } = ctx;
module.exports = function({
servicesStateManager, asyncHandler, helpers,
APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS,
docker, caddy, dns, siteConfig, buildDomain,
errorResponse, log, SERVICES_FILE
}) {
const router = express.Router();
// Ctx shim for backward compatibility with existing route code
const ctx = {
APP_TEMPLATES,
TEMPLATE_CATEGORIES,
DIFFICULTY_LEVELS,
dns,
siteConfig,
buildDomain,
SERVICES_FILE
};
// Get available app templates
router.get('/apps/templates', asyncHandler(async (req, res) => {
res.json({
@@ -64,6 +90,8 @@ module.exports = function(ctx) {
// Update subdomain for deployed app
router.post('/apps/update-subdomain', asyncHandler(async (req, res) => {
const { serviceId, oldSubdomain, newSubdomain, containerId, ip } = req.body;
const { ValidationError } = require('../../errors');
if (!oldSubdomain || typeof oldSubdomain !== 'string') {
throw new ValidationError('oldSubdomain is required');
}

View File

@@ -28,7 +28,7 @@ module.exports = function(ctx) {
const results = { radarr: null, sonarr: null };
// Step 1: Authenticate with Overseerr via Plex token
let overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrUrl = `http://host.docker.internal:${APP_PORTS.overseerr}`;
const overseerrSession = await helpers.getOverseerrSession();
if (!overseerrSession) {
@@ -227,7 +227,7 @@ module.exports = function(ctx) {
}
// Normalize URL - remove trailing slash
let baseUrl = url.replace(/\/+$/, '');
const baseUrl = url.replace(/\/+$/, '');
// Build the API endpoint
let apiEndpoint;

View File

@@ -9,12 +9,18 @@ const { APP_PORTS } = require('../../constants');
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Arr helpers module
* @param {Object} deps.credentialManager - Credential manager
* @param {Object} deps.servicesStateManager - Services state manager
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { fetchT, asyncHandler, errorResponse, log, helpers } = ctx;
module.exports = function({ fetchT, asyncHandler, errorResponse, log: _log, helpers, credentialManager, servicesStateManager }) {
const router = express.Router();
const ctx = {
credentialManager,
servicesStateManager
};
// Plex Libraries endpoint
router.get('/plex/libraries', asyncHandler(async (req, res) => {
// Get Plex token

View File

@@ -10,12 +10,18 @@ const { APP_PORTS } = require('../../constants');
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @param {Object} deps.helpers - Arr helpers module
* @param {Object} deps.servicesStateManager - Services state manager
* @param {Object} deps.notification - Notification helper
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { credentialManager, fetchT, asyncHandler, errorResponse, log, helpers } = ctx;
module.exports = function({ credentialManager, servicesStateManager, fetchT, asyncHandler, errorResponse: _errorResponse, log: _log, helpers, notification }) {
const router = express.Router();
const ctx = {
servicesStateManager,
notification
};
// Smart Connect: Unified orchestration endpoint
router.post('/arr/smart-connect', asyncHandler(async (req, res) => {
const { services: inputServices, configurePlex, configureProwlarr, configureSeerr, saveCredentials } = req.body;

View File

@@ -1,8 +1,6 @@
const { SESSION_TTL, APP, PLEX, TIMEOUTS, buildMediaAuth } = require('../../constants');
const { createCache, CACHE_CONFIGS } = require('../../cache-config');
module.exports = function({ authManager, credentialManager, fetchT, asyncHandler, errorResponse, log }) {
// App session cache for auto-login
/**
* Auth session handlers routes factory
* @param {Object} deps - Explicit dependencies
@@ -11,8 +9,11 @@ module.exports = function({ authManager, credentialManager, fetchT, asyncHandler
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Function} deps.errorResponse - Error response helper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @returns {{ getAppSession: Function, appSessionCache: Object }}
*/
module.exports = function({ authManager: _authManager, credentialManager: _credentialManager, asyncHandler: _asyncHandler, errorResponse: _errorResponse, log, fetchT }) {
const ctx = { fetchT };
const appSessionCache = createCache(CACHE_CONFIGS.appSessions);
async function getAppSession(serviceId, baseUrl, username, password) {

View File

@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const path = require('path');
const { exists, isAccessible } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError } = require('../errors');
const { ValidationError, ForbiddenError } = require('../errors');
/**
* Browse route factory

View File

@@ -4,6 +4,7 @@ const fsp = require('fs').promises;
const path = require('path');
const { execSync } = require('child_process');
const { exists } = require('../fs-helpers');
const { ValidationError } = require('../errors');
const platformPaths = require('../platform-paths');
module.exports = function(ctx) {

View File

@@ -22,9 +22,9 @@ try {
// Image processing libraries not available — favicon conversion disabled
}
module.exports = function(ctx) {
const { servicesStateManager, asyncHandler, log } = ctx;
module.exports = function({ servicesStateManager: _servicesStateManager, asyncHandler, log: _log, CONFIG_FILE, readConfig, saveConfig, errorResponse }) {
const router = express.Router();
const ctx = { CONFIG_FILE, readConfig, saveConfig, errorResponse };
// ===== ASSET UPLOAD =====
@@ -47,7 +47,6 @@ module.exports = function(ctx) {
throw new ValidationError('Invalid image data format');
}
const extension = matches[1] === 'svg+xml' ? 'svg' : matches[1];
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');
@@ -109,6 +108,7 @@ module.exports = function(ctx) {
// Upload custom logo(s) and/or update position and title
// Supports: dataDark/dataLight (separate variants) or data (single logo for both)
// eslint-disable-next-line complexity
router.post('/logo', express.json({ limit: LIMITS.BODY_UPLOAD }), asyncHandler(async (req, res) => {
const { data, dataDark, dataLight, position, dashboardTitle } = req.body;
@@ -231,7 +231,6 @@ module.exports = function(ctx) {
throw new ValidationError('Invalid image data format');
}
const imageType = matches[1];
const base64Data = matches[2];
const buffer = Buffer.from(base64Data, 'base64');

View File

@@ -13,7 +13,10 @@ module.exports = function(ctx) {
configStateManager: ctx.configStateManager,
servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler,
log: ctx.log
log: ctx.log,
CONFIG_FILE: ctx.CONFIG_FILE,
errorResponse: ctx.errorResponse,
loadSiteConfig: ctx.loadSiteConfig
};
// Additional deps for backup route
@@ -35,8 +38,8 @@ module.exports = function(ctx) {
saveTotpConfig: ctx.saveTotpConfig
};
router.use(require('./settings')(ctx));
router.use(require('./assets')(ctx));
router.use(require('./settings')(baseDeps));
router.use(require('./assets')({ ...baseDeps, readConfig: ctx.readConfig, saveConfig: ctx.saveConfig }));
router.use(require('./backup')(backupDeps));
return router;
};

View File

@@ -5,13 +5,19 @@ const { ValidationError } = require('../../errors');
/**
* Config settings routes factory
* @param {Object} ctx - Application context
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.configStateManager - Config state manager
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @param {string} deps.CONFIG_FILE - Config file path
* @param {Function} deps.errorResponse - Error response helper
* @param {Function} deps.loadSiteConfig - Site config reload helper
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { configStateManager, asyncHandler, log } = ctx;
module.exports = function({ configStateManager: _configStateManager, asyncHandler, log, CONFIG_FILE, errorResponse, loadSiteConfig }) {
const express = require('express');
const router = express.Router();
const ctx = { CONFIG_FILE, errorResponse, loadSiteConfig };
// ===== DASHCADDY CONFIG ENDPOINTS =====
// Server-side config storage for setup wizard (shared across all browsers/machines)
@@ -60,7 +66,9 @@ module.exports = function(ctx) {
config.updatedAt = new Date().toISOString();
await fsp.writeFile(ctx.CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
if (typeof ctx.loadSiteConfig === 'function') {
ctx.loadSiteConfig(); // Refresh in-memory config
}
log.info('config', 'Config saved', { path: ctx.CONFIG_FILE });
res.json({ success: true, message: 'Configuration saved', config, warnings });

View File

@@ -55,7 +55,9 @@ async function handleExec(ws, containerId, log) {
if (chunks.length > 0 && Buffer.concat(chunks).toString().includes('/bash')) {
shell = '/bin/bash';
}
} catch (_) {}
} catch (_) {
// Fall back to /bin/sh when bash detection fails
}
execInstance = await container.exec({
Cmd: [shell],
@@ -96,21 +98,27 @@ async function handleExec(ws, containerId, log) {
return;
}
}
} catch (_) {}
} catch (_) {
// Treat message as raw terminal input if JSON parsing fails
}
// Regular terminal input
execStream.write(data);
});
ws.on('close', () => {
if (execStream) {
try { execStream.destroy(); } catch (_) {}
try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors on socket close
}
}
});
ws.on('error', (err) => {
log.warn('exec', 'WebSocket error', { containerId, error: err.message });
if (execStream) {
try { execStream.destroy(); } catch (_) {}
try { execStream.destroy(); } catch (_) {
// Ignore stream teardown errors after websocket errors
}
}
});

View File

@@ -8,6 +8,7 @@ const { paginate, parsePaginationParams } = require('../pagination');
const platformPaths = require('../platform-paths');
const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers');
const { ValidationError } = require('../errors');
/**
* Health routes factory
@@ -45,7 +46,7 @@ module.exports = function({
// Try HEAD first
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(url, { method: 'HEAD', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout);
return {
@@ -54,12 +55,12 @@ module.exports = function({
url,
checkedAt: new Date().toISOString()
};
} catch (_) {}
} catch { /* ignore */ }
// Fallback to GET
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(url, { method: 'GET', signal: controller.signal, redirect: 'follow' });
clearTimeout(timeout);
return {

View File

@@ -4,7 +4,7 @@ const fsp = require('fs').promises;
const path = require('path');
const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination');
const { NotFoundError } = require('../errors');
const { NotFoundError, ValidationError, ForbiddenError } = require('../errors');
/**
* Logs route factory

View File

@@ -7,9 +7,10 @@ const { success } = require('../response-helpers');
* @param {Object} deps.resourceMonitor - Resource monitoring manager
* @param {Object} deps.docker - Docker client wrapper
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ resourceMonitor, docker, asyncHandler }) {
module.exports = function({ resourceMonitor, docker, asyncHandler, log }) {
const router = express.Router();
// ===== RESOURCE MONITORING ENDPOINTS =====
@@ -119,7 +120,7 @@ module.exports = function({ resourceMonitor, docker, asyncHandler }) {
});
} catch (e) {
// Skip containers we can't get stats for
console.log(`Could not get stats for ${containerInfo.Names[0]}:`, e.message);
log.warn('monitoring', `Could not get stats for ${containerInfo.Names[0]}`, { error: e.message });
}
}

View File

@@ -14,9 +14,9 @@ const { DOCKER } = require('../../constants');
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function(ctx) {
const { docker, credentialManager, servicesStateManager, asyncHandler, errorResponse, log } = ctx;
module.exports = function({ docker, credentialManager: _credentialManager, servicesStateManager: _servicesStateManager, asyncHandler, errorResponse: _errorResponse, log, addServiceToConfig, notification, APP_TEMPLATES, siteConfig, caddy, buildDomain }) {
const router = express.Router();
const ctx = { addServiceToConfig, notification, APP_TEMPLATES, siteConfig, caddy, buildDomain };
/**
* Deploy a recipe — creates multiple containers as a coordinated stack
@@ -24,6 +24,7 @@ module.exports = function(ctx) {
* POST /api/recipes/deploy
* Body: { recipeId, config: { selectedComponents, sharedConfig, componentOverrides } }
*/
// eslint-disable-next-line complexity
router.post('/deploy', asyncHandler(async (req, res) => {
const { recipeId, config } = req.body;
const { RECIPE_TEMPLATES } = require('../../recipe-templates');
@@ -44,7 +45,6 @@ module.exports = function(ctx) {
// Generate shared passwords for the recipe (consistent across components)
const generatedPasswords = {};
const passwordKey = `recipe-${recipeId}-${Date.now()}`;
generatedPasswords.default = crypto.randomBytes(24).toString('base64url');
// Create Docker network if defined
@@ -185,6 +185,7 @@ module.exports = function(ctx) {
/**
* Deploy a single component of a recipe
*/
// eslint-disable-next-line complexity
async function deployComponent(component, recipe, config, passwords, networkName) {
const sharedConfig = config.sharedConfig || {};
const overrides = config.componentOverrides?.[component.id] || {};
@@ -364,7 +365,7 @@ module.exports = function(ctx) {
/**
* Run auto-connect steps after recipe deployment
*/
async function runAutoConnect(recipe, deployedComponents, config) {
async function runAutoConnect(recipe, _deployedComponents, _config) {
if (!recipe.autoConnect?.steps) return;
// Wait for services to be fully ready

View File

@@ -17,7 +17,13 @@ module.exports = function(ctx) {
servicesStateManager: ctx.servicesStateManager,
asyncHandler: ctx.asyncHandler,
errorResponse: ctx.errorResponse,
log: ctx.log
log: ctx.log,
notification: ctx.notification,
buildDomain: ctx.buildDomain,
caddy: ctx.caddy,
addServiceToConfig: ctx.addServiceToConfig,
APP_TEMPLATES: ctx.APP_TEMPLATES,
siteConfig: ctx.siteConfig
};
// All recipe routes require premium license

View File

@@ -2,9 +2,12 @@ const express = require('express');
const { DOCKER } = require('../../constants');
const { NotFoundError } = require('../../errors');
module.exports = function(ctx) {
const { servicesStateManager, asyncHandler, log } = ctx;
module.exports = function({ servicesStateManager, asyncHandler, log, docker, notification, buildDomain, caddy }) {
const router = express.Router();
// Ctx shim for backward compatibility
const ctx = { docker, notification, buildDomain, caddy };
/**
* Recipes management routes factory
* @param {Object} deps - Explicit dependencies
@@ -303,7 +306,7 @@ module.exports = function(ctx) {
*/
async function removeCaddyBlock(subdomain) {
const domain = ctx.buildDomain(subdomain);
let content = await ctx.caddy.read();
const content = await ctx.caddy.read();
// Find and remove the block for this domain
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

View File

@@ -8,9 +8,9 @@ const { APP, REGEX, TIMEOUTS } = require('../constants');
const { validateServiceConfig, isValidPort } = require('../input-validator');
const { exists } = require('../fs-helpers');
const { paginate, parsePaginationParams } = require('../pagination');
const { ValidationError, NotFoundError, ConflictError } = require('../errors');
const { resolveServiceUrl } = require('../url-resolver');
const { success, error: errorResponse } = require('../response-helpers');
const { ConflictError, ValidationError, NotFoundError } = require('../errors');
/**
* Services route factory
@@ -113,7 +113,7 @@ module.exports = function({
const headers = {};
if (pylonConfig.key) headers['x-pylon-key'] = pylonConfig.key;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const timeout = setTimeout(() => controller.abort(), TIMEOUTS.HTTP_DEFAULT);
const response = await fetchT(probeUrl, { method: 'GET', signal: controller.signal, headers });
clearTimeout(timeout);
if (!response.ok) return null;
@@ -470,7 +470,7 @@ module.exports = function({
const oldDomain = buildDomain(oldSubdomain);
const newDomain = buildDomain(newSubdomain);
let content = await caddy.read();
const content = await caddy.read();
const escapedOldDomain = oldDomain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(

View File

@@ -164,7 +164,7 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
let content = await caddy.read();
const content = await caddy.read();
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const siteBlockRegex = new RegExp(`\\n?${escapedDomain}\\s*\\{`, 'g');
if (siteBlockRegex.test(content)) {
@@ -203,7 +203,6 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
const domain = buildDomain(subdomain);
let dnsWarning = null;
try {
if (createDns) {
try {
await dns.createRecord(subdomain, siteConfig.dnsServerIp);
@@ -267,9 +266,6 @@ module.exports = function({ asyncHandler, caddy, dns, fetchT, buildDomain, addSe
};
if (dnsWarning) response.warning = dnsWarning;
res.json(response);
} catch (error) {
throw error;
}
}, 'site-external'));
return router;

View File

@@ -152,7 +152,7 @@ module.exports = function({
throw new ValidationError('subdomain is required');
}
let content = await caddy.read();
const content = await caddy.read();
const domain = buildDomain(subdomain);
const blockRegex = new RegExp(`(${domain.replace('.', '\\.')}\\s*\\{[^}]*\\})`, 's');

View File

@@ -8,9 +8,10 @@ const { ValidationError, NotFoundError } = require('../errors');
* Themes routes factory
* @param {Object} deps - Explicit dependencies
* @param {Function} deps.asyncHandler - Async route handler wrapper
* @param {Object} deps.log - Logger instance
* @returns {express.Router}
*/
module.exports = function({ asyncHandler }) {
module.exports = function({ asyncHandler, log }) {
const router = express.Router();
const THEMES_DIR = process.env.THEMES_DIR || path.join(path.dirname(process.env.SERVICES_FILE || '/app/services.json'), 'themes');
@@ -29,7 +30,7 @@ module.exports = function({ asyncHandler }) {
themes[slug] = data;
}
} catch (e) {
console.error('[Themes] Failed to read themes:', e.message);
log.error('themes', 'Failed to read themes', { error: e.message });
}
return themes;
}

View File

@@ -14,8 +14,8 @@ const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const os = require('os');
const { execSync } = require('child_process');
const zlib = require('zlib');
const platformPaths = require('./platform-paths');
const isWindows = platformPaths.isWindows;
@@ -31,6 +31,10 @@ const DEFAULTS = {
MAX_BACKUPS: 3,
HEALTH_TIMEOUT: 60000,
DOWNLOAD_TIMEOUT: 120000,
CHANNEL: 'stable',
INSTANCE_ID_FILE: platformPaths.isWindows
? path.join(platformPaths.caddyBase, 'instance-id')
: '/etc/dashcaddy/instance-id',
};
class SelfUpdater extends EventEmitter {
@@ -48,12 +52,15 @@ class SelfUpdater extends EventEmitter {
apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
instanceIdFile: options.instanceIdFile || process.env.DASHCADDY_INSTANCE_ID_FILE || DEFAULTS.INSTANCE_ID_FILE,
};
this.status = 'idle'; // idle | checking | downloading | applying | waiting
this.checkTimer = null;
this.lastCheckTime = null;
this.lastCheckResult = null;
this.instanceId = this._loadOrCreateInstanceId();
// Ensure directories exist
this._ensureDirs();
@@ -80,7 +87,7 @@ class SelfUpdater extends EventEmitter {
}
}
// ── Version Info ──
// ── Version / Identity Info ──
getLocalVersion() {
try {
@@ -88,13 +95,25 @@ class SelfUpdater extends EventEmitter {
let commit = null;
try {
commit = fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim();
} catch (_) {}
} catch { /* ignore */ }
return { version: pkg.version, commit };
} catch (e) {
return { version: '0.0.0', commit: null };
}
}
getInstanceInfo() {
return {
instanceId: this.instanceId,
channel: this.config.channel,
hostname: os.hostname(),
platform: process.platform,
arch: process.arch,
isWindows,
version: this.getLocalVersion(),
};
}
getStatus() {
return this.status;
}
@@ -122,10 +141,18 @@ class SelfUpdater extends EventEmitter {
}
const local = this.getLocalVersion();
const available = this._isNewer(local, remote);
const policy = this._evaluateReleasePolicy(local, remote);
const available = policy.eligible && policy.newer;
this.lastCheckTime = Date.now();
this.lastCheckResult = { available, local, remote, sourceUrl };
this.lastCheckResult = {
available,
local,
remote,
sourceUrl,
policy,
instance: this.getInstanceInfo(),
};
this.status = 'idle';
if (available) {
@@ -149,12 +176,17 @@ class SelfUpdater extends EventEmitter {
}
const local = this.getLocalVersion();
const policy = this._evaluateReleasePolicy(local, remoteInfo);
if (!policy.eligible) {
throw new Error(`Release not eligible for this instance: ${policy.reason}`);
}
const stagingDir = path.join(this.config.updatesDir, 'staging');
try {
// 1. Download (try primary, fallback to mirror)
this.status = 'downloading';
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version });
this.emit('update-progress', { step: 'downloading', version: remoteInfo.version, policy });
const tarballPath = path.join(this.config.updatesDir, remoteInfo.tarball);
const primaryUrl = `${this.config.updateUrl}/${remoteInfo.tarball}`;
@@ -164,7 +196,7 @@ class SelfUpdater extends EventEmitter {
} catch (dlErr) {
console.warn('[SelfUpdater] Primary download failed:', dlErr.message, '— trying mirror');
// Ensure file is fully cleaned up before mirror attempt
try { fs.unlinkSync(tarballPath); } catch (_) {}
try { fs.unlinkSync(tarballPath); } catch { /* ignore */ }
await this._downloadFile(mirrorUrl, tarballPath);
}
@@ -207,6 +239,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostApiSrc,
apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
await fsp.writeFile(
path.join(this.config.updatesDir, 'trigger.json'),
@@ -222,6 +256,8 @@ class SelfUpdater extends EventEmitter {
status: 'pending',
frontendUpdated: !!frontendSrc,
apiUpdated: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
} else if (isWindows) {
// Windows: frontend updated, API needs manual restart
@@ -233,6 +269,8 @@ class SelfUpdater extends EventEmitter {
frontendUpdated: !!frontendSrc,
apiUpdated: false,
note: 'API update requires manual container restart on Windows',
channel: this.config.channel,
instanceId: this.instanceId,
});
this.status = 'idle';
}
@@ -246,6 +284,7 @@ class SelfUpdater extends EventEmitter {
toVersion: remoteInfo.version,
frontendUpdated: !!frontendSrc,
apiUpdated: !isWindows && !!apiSrc,
policy,
};
} catch (e) {
this.status = 'idle';
@@ -255,6 +294,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(),
status: 'failed',
error: e.message,
channel: this.config.channel,
instanceId: this.instanceId,
});
throw e;
}
@@ -308,6 +349,8 @@ class SelfUpdater extends EventEmitter {
stagingDir: hostBackupDir,
apiSourceDir: this.config.apiSourceDir,
timestamp: new Date().toISOString(),
channel: this.config.channel,
instanceId: this.instanceId,
};
this.status = 'waiting';
@@ -322,6 +365,8 @@ class SelfUpdater extends EventEmitter {
timestamp: new Date().toISOString(),
status: 'pending',
rollback: true,
channel: this.config.channel,
instanceId: this.instanceId,
});
}
@@ -362,20 +407,143 @@ class SelfUpdater extends EventEmitter {
}
}
_evaluateReleasePolicy(local, remote) {
const releaseChannel = remote?.channel || remote?.releaseChannel || 'stable';
const allowedChannels = Array.isArray(remote?.channels)
? remote.channels
: Array.isArray(remote?.eligibleChannels)
? remote.eligibleChannels
: [releaseChannel];
if (!allowedChannels.includes(this.config.channel)) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `channel mismatch (${this.config.channel} not in ${allowedChannels.join(', ')})`,
releaseChannel,
allowedChannels,
};
}
if (remote?.revoked === true) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: 'release revoked',
releaseChannel,
allowedChannels,
};
}
const minUpdaterVersion = remote?.minUpdaterVersion;
if (minUpdaterVersion && this._compareVersions(local.version, minUpdaterVersion) < 0) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `requires updater >= ${minUpdaterVersion}`,
releaseChannel,
allowedChannels,
};
}
const rollout = this._normalizeRollout(remote?.rollout);
if (rollout < 100) {
const bucket = this._getRolloutBucket(this.instanceId);
if (bucket >= rollout) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `outside rollout (${bucket} >= ${rollout})`,
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: bucket,
};
}
}
const targets = remote?.targets;
if (targets && typeof targets === 'object') {
const platformKey = `${process.platform}-${process.arch}`;
const matchedTarget = targets[platformKey] || targets[process.platform] || targets.default;
if (!matchedTarget) {
return {
eligible: false,
newer: this._isNewer(local, remote),
reason: `no target for ${platformKey}`,
releaseChannel,
allowedChannels,
rollout,
};
}
}
return {
eligible: true,
newer: this._isNewer(local, remote),
reason: 'eligible',
releaseChannel,
allowedChannels,
rollout,
rolloutBucket: this._getRolloutBucket(this.instanceId),
};
}
_isNewer(local, remote) {
if (!remote || !remote.version) return false;
// Compare semver: split into [major, minor, patch]
const lv = (local.version || '0.0.0').split('.').map(Number);
const rv = remote.version.split('.').map(Number);
for (let i = 0; i < 3; i++) {
if ((rv[i] || 0) > (lv[i] || 0)) return true;
if ((rv[i] || 0) < (lv[i] || 0)) return false;
}
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true;
if (versionCompare > 0) return false;
// Same version — check commit hash
if (remote.commit && local.commit && remote.commit !== local.commit) return true;
return false;
}
_compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const len = Math.max(av.length, bv.length, 3);
for (let i = 0; i < len; i++) {
const left = av[i] || 0;
const right = bv[i] || 0;
if (left > right) return 1;
if (left < right) return -1;
}
return 0;
}
_normalizeRollout(value) {
if (value == null) return 100;
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 100;
return Math.max(0, Math.min(100, Math.floor(parsed)));
}
_getRolloutBucket(instanceId) {
const digest = crypto.createHash('sha256').update(String(instanceId || 'unknown')).digest();
return digest[0] % 100;
}
_loadOrCreateInstanceId() {
try {
if (fs.existsSync(this.config.instanceIdFile)) {
const existing = fs.readFileSync(this.config.instanceIdFile, 'utf8').trim();
if (existing) return existing;
}
} catch (_) {
// Fall through and regenerate
}
const instanceId = crypto.randomUUID();
try {
fs.mkdirSync(path.dirname(this.config.instanceIdFile), { recursive: true });
fs.writeFileSync(this.config.instanceIdFile, `${instanceId}\n`, 'utf8');
} catch (error) {
console.warn('[SelfUpdater] Failed to persist instance ID:', error.message);
}
return instanceId;
}
_addToHistory(entry) {
const history = this.getUpdateHistory();
history.unshift(entry);
@@ -473,7 +641,7 @@ class SelfUpdater extends EventEmitter {
const sub = path.join(baseDir, entry, name);
if (fs.existsSync(sub)) return sub;
}
} catch (_) {}
} catch { /* ignore */ }
return null;
}
@@ -512,7 +680,7 @@ class SelfUpdater extends EventEmitter {
async _cleanDir(dir) {
try {
await fsp.rm(dir, { recursive: true, force: true });
} catch (_) {}
} catch { /* ignore */ }
await fsp.mkdir(dir, { recursive: true });
}
}
@@ -527,6 +695,9 @@ const selfUpdater = new SelfUpdater({
hostUpdatesDir: process.env.DASHCADDY_HOST_UPDATES_DIR,
apiSourceDir: process.env.DASHCADDY_API_SOURCE_DIR,
frontendDir: process.env.DASHCADDY_FRONTEND_DIR,
channel: process.env.DASHCADDY_UPDATE_CHANNEL,
instanceIdFile: process.env.DASHCADDY_INSTANCE_ID_FILE,
});
module.exports = selfUpdater;
module.exports.SelfUpdater = SelfUpdater;

View File

@@ -67,8 +67,8 @@ process.on('uncaughtException', (error) => {
// Optional modules
let dockerMaintenance, logDigest;
try { dockerMaintenance = require('./docker-maintenance'); } catch (_) {}
try { logDigest = require('./log-digest'); } catch (_) {}
try { dockerMaintenance = require('./docker-maintenance'); } catch { /* optional */ }
try { logDigest = require('./log-digest'); } catch { /* optional */ }
log.info('server', 'Starting feature modules');
@@ -188,7 +188,7 @@ process.on('uncaughtException', (error) => {
});
// Graceful shutdown
function shutdown(signal) {
const shutdown = (signal) => {
log.info('shutdown', `${signal} received, draining connections...`);
const resourceMonitor = require('./resource-monitor');
@@ -206,12 +206,12 @@ process.on('uncaughtException', (error) => {
try {
const dockerMaintenance = require('./docker-maintenance');
dockerMaintenance.stop();
} catch (_) {}
} catch { /* optional */ }
try {
const logDigest = require('./log-digest');
logDigest.stop();
} catch (_) {}
} catch { /* optional */ }
server.close(() => {
log.info('shutdown', 'HTTP server closed');
@@ -220,7 +220,7 @@ process.on('uncaughtException', (error) => {
// Force exit after 5s if connections don't drain
setTimeout(() => process.exit(0), 5000).unref();
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View File

@@ -36,8 +36,8 @@ const { validateURL } = require('../input-validator');
// Optional modules
let dockerMaintenance, logDigest;
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) {}
try { logDigest = require('../log-digest'); } catch (_) {}
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
// Templates
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
@@ -104,8 +104,8 @@ async function createApp() {
log.warn('server', 'CA cert not found — HTTPS calls may fail', { path: CA_CERT_PATH });
}
// TOTP configuration (defaults, overridden by loadTotpConfig below)
let totpConfig = {
// TOTP configuration
const totpConfig = {
enabled: false,
sessionDuration: 'never',
isSetUp: false
@@ -124,7 +124,7 @@ async function createApp() {
}
// Tailscale configuration
let tailscaleConfig = {
const tailscaleConfig = {
enabled: false,
requireAuth: false,
allowedTailnet: null,
@@ -137,7 +137,7 @@ async function createApp() {
// Helper functions needed by middleware
function isValidContainerId(id) {
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,127}$/;
const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
return typeof id === 'string' && CONTAINER_ID_RE.test(id);
}
@@ -359,7 +359,8 @@ async function createApp() {
apiRouter.use(monitoringRoutes({
resourceMonitor: ctx.resourceMonitor,
docker: ctx.docker,
asyncHandler: ctx.asyncHandler
asyncHandler: ctx.asyncHandler,
log: ctx.log
}));
apiRouter.use(updatesRoutes({
updateManager: ctx.updateManager,
@@ -420,7 +421,7 @@ async function createApp() {
asyncHandler: ctx.asyncHandler
}));
apiRouter.use('/recipes', recipesRoutes(ctx));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler }));
apiRouter.use(themesRoutes({ asyncHandler: ctx.asyncHandler, log: ctx.log }));
apiRouter.use('/docker', dockerResourcesRoutes({
docker: ctx.docker,
asyncHandler: ctx.asyncHandler

View File

@@ -6,7 +6,7 @@ const fs = require('fs');
const { validateConfig } = require('../../config-schema');
const { CADDY } = require('../../constants');
let siteConfig = {
const siteConfig = {
tld: '.home',
caName: '',
dnsServerIp: '',

View File

@@ -226,7 +226,7 @@ async function requireDnsToken(providedToken, siteConfig, credentialManager, fet
/**
* Create DNS record
*/
async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log) {
async function createDnsRecord(subdomain, ip, siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log) {
const tokenResult = await ensureValidDnsToken(siteConfig, credentialManager, fetchT, log);
if (!tokenResult.success) {
throw new Error(`DNS token not available: ${tokenResult.error}`);
@@ -285,7 +285,7 @@ function createDnsContext(siteConfig, buildDomain, credentialManager, fetchT, ht
const require = (providedToken) => requireDnsToken(providedToken, siteConfig, credentialManager, fetchT, log);
const getForServer = (server, role) => getTokenForServer(server, siteConfig, credentialManager, fetchT, log, role);
const refresh = (username, password, server) => refreshDnsToken(username, password, server, fetchT, log);
const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, fetchT, httpsAgent, log);
const create = (subdomain, ip) => createDnsRecord(subdomain, ip, siteConfig, buildDomain, credentialManager, fetchT, httpsAgent, log);
const call = (server, apiPath, params) => callDns(server, apiPath, params, fetchT, httpsAgent);
return {

View File

@@ -167,11 +167,103 @@ button:focus-visible {
right: 0;
top: 0;
padding-top: 10px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.reload-caddy-main {
display: flex;
align-items: center;
gap: 18px;
}
.dashboard-version {
font-size: 0.78rem;
color: var(--muted);
line-height: 1;
padding: 0 2px;
user-select: text;
background: transparent;
border: none;
cursor: pointer;
transition: color 0.15s ease, opacity 0.15s ease;
}
.dashboard-version:hover {
color: var(--fg);
opacity: 0.9;
}
.version-info-modal-content {
min-width: 420px;
max-width: 620px;
}
.version-info-subtitle {
font-size: 0.85rem;
color: var(--muted);
margin: 0 0 16px;
}
.version-info-status {
font-size: 0.85rem;
color: var(--muted);
margin-bottom: 14px;
}
.version-info-grid {
display: grid;
gap: 10px;
margin-bottom: 18px;
}
.version-info-row {
display: flex;
justify-content: space-between;
gap: 18px;
padding: 10px 12px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border);
}
.version-info-label {
font-weight: 600;
color: var(--fg);
}
.version-info-value {
color: var(--muted);
text-align: right;
word-break: break-word;
}
.version-info-history h4 {
margin: 0 0 10px;
}
.version-history-entry {
padding: 10px 12px;
border-radius: 8px;
background: var(--card-bg);
border: 1px solid var(--border);
margin-bottom: 8px;
}
.version-history-status {
color: var(--muted);
font-size: 0.82rem;
margin-left: 6px;
}
.version-history-meta {
font-size: 0.78rem;
color: var(--muted);
margin-top: 4px;
}
.license-status-topbar {
display: flex;
align-items: center;

File diff suppressed because one or more lines are too long

View File

@@ -83,6 +83,7 @@
<!-- License status + Reload Caddy Button - top right corner -->
<div class="reload-caddy-container">
<div class="reload-caddy-main">
<div class="theme-toggle-group">
<button id="theme" class="theme-toggle-btn" aria-label="Cycle theme" title="Click to cycle themes">
<span id="theme-icon">🎨</span> <span id="theme-label">Dark</span>
@@ -98,6 +99,21 @@
🔄 Reload Caddy
</button>
</div>
<button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
</div>
</div>
<div id="version-info-modal" class="weather-modal">
<div class="weather-modal-content version-info-modal-content">
<h3>DashCaddy Verification Info</h3>
<p class="version-info-subtitle">Current version details and updater verification status.</p>
<div id="version-info-status" class="version-info-status">Loading…</div>
<div id="version-info-grid" class="version-info-grid" style="display:none;"></div>
<div id="version-info-history" class="version-info-history" style="display:none;"></div>
<div class="weather-modal-buttons" style="margin-top: 16px;">
<button id="version-info-close">Close</button>
</div>
</div>
</div>
<!-- Tools row below logo and weather -->
@@ -538,6 +554,117 @@
}
var yr = document.getElementById('footer-year');
if (yr) yr.textContent = new Date().getFullYear();
var versionEl = document.getElementById('dashboard-version');
var versionInfoModal = document.getElementById('version-info-modal');
var versionInfoStatus = document.getElementById('version-info-status');
var versionInfoGrid = document.getElementById('version-info-grid');
var versionInfoHistory = document.getElementById('version-info-history');
var versionInfoClose = document.getElementById('version-info-close');
function formatValue(value) {
if (value == null || value === '') return '—';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
}
function renderInfoRow(label, value) {
return '<div class="version-info-row"><span class="version-info-label">' + label + '</span><span class="version-info-value">' + formatValue(value) + '</span></div>';
}
function renderHistory(history) {
if (!Array.isArray(history) || !history.length) {
versionInfoHistory.style.display = 'none';
versionInfoHistory.innerHTML = '';
return;
}
versionInfoHistory.style.display = '';
versionInfoHistory.innerHTML = '<h4>Recent Update History</h4>' + history.slice(0, 5).map(function(entry) {
return '<div class="version-history-entry">'
+ '<div><strong>' + formatValue(entry.version) + '</strong> <span class="version-history-status">' + formatValue(entry.status) + '</span></div>'
+ '<div class="version-history-meta">From ' + formatValue(entry.fromVersion) + ' · ' + formatValue(entry.timestamp) + '</div>'
+ '</div>';
}).join('');
}
function openVersionInfo() {
if (!versionInfoModal) return;
versionInfoModal.classList.add('show');
versionInfoStatus.textContent = 'Loading…';
versionInfoGrid.style.display = 'none';
versionInfoHistory.style.display = 'none';
versionInfoGrid.innerHTML = '';
versionInfoHistory.innerHTML = '';
Promise.all([
fetch('/api/v1/system/version', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Version check failed');
return response.json();
}),
fetch('/api/v1/system/update-status', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update status failed');
return response.json();
}),
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) {
if (!response.ok) throw new Error('Update history failed');
return response.json();
})
]).then(function(results) {
var versionData = results[0] || {};
var statusData = results[1] || {};
var historyData = results[2] || {};
var lastResult = statusData.lastResult || {};
var lastPolicy = lastResult.policy || {};
versionInfoStatus.textContent = 'Verification info loaded.';
versionInfoGrid.style.display = 'grid';
versionInfoGrid.innerHTML = [
renderInfoRow('Version', versionData.version),
renderInfoRow('Commit', versionData.commit),
renderInfoRow('Updater Status', statusData.status),
renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'),
renderInfoRow('Update Available', lastResult.available),
renderInfoRow('Eligible', lastPolicy.eligible),
renderInfoRow('Policy Reason', lastPolicy.reason),
renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)),
renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId)
].join('');
renderHistory(historyData.history);
}).catch(function(error) {
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
});
}
if (versionEl) {
fetch('/api/v1/system/version', { cache: 'no-store' })
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
versionEl.textContent = 'Version ' + data.version;
}
})
.catch(function() {
versionEl.textContent = 'Version unavailable';
});
versionEl.addEventListener('click', openVersionInfo);
}
if (versionInfoClose && versionInfoModal) {
versionInfoClose.addEventListener('click', function() {
versionInfoModal.classList.remove('show');
});
versionInfoModal.addEventListener('click', function(event) {
if (event.target === versionInfoModal) {
versionInfoModal.classList.remove('show');
}
});
}
})();
</script>

View File

@@ -378,6 +378,7 @@
} else {
dcUpdateBadge.style.display = 'none';
dcUpdateDetails.style.display = 'none';
await dcLoadVersion();
if (!silent) dcShowStatus('You are running the latest version.', 'success');
}