Compare commits
5
Commits
v1.9.0
..
7bbd969fa2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
No files matched your search
+15
@@ -2,6 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Runtime state/config files (generated, not source)
|
||||
# Note: data/ subdir contains runtime state (credentials, secrets, history) — never commit
|
||||
dashcaddy-api/data/
|
||||
dashcaddy-api/credentials.json
|
||||
dashcaddy-api/.env
|
||||
.env
|
||||
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
|
||||
dashcaddy-api/update-history.json
|
||||
dashcaddy-api/dashcaddy-errors.log
|
||||
|
||||
# Auto-updater backups (created by dashcaddy-update.sh when rolling back)
|
||||
start.sh.bak*
|
||||
scripts/*.bak*
|
||||
|
||||
# Auto-updater runtime state (history + secrets + staging)
|
||||
updates/
|
||||
|
||||
# Scratch / debug scripts (left over from past sessions)
|
||||
cm_check*.js
|
||||
full_test.js
|
||||
login_test.js
|
||||
login_backup_test.js
|
||||
|
||||
# Build output
|
||||
dashcaddy-installer/build-output/
|
||||
dashcaddy-installer/dist/
|
||||
|
||||
@@ -1 +1 @@
|
||||
1.9.0
|
||||
c64bbe2
|
||||
@@ -2459,6 +2459,74 @@ const APP_TEMPLATES = {
|
||||
"World data is persisted in the data volume",
|
||||
"Requires at least 4GB RAM for smooth operation"
|
||||
]
|
||||
},
|
||||
// === FILE MANAGEMENT — HOST-SERVICE TEMPLATES ===
|
||||
// Sami Files is a host-systemd service, NOT a Docker container. The
|
||||
// template exists so users get the right metadata + category in the App
|
||||
// Selector, but the actual deployment is via `deploy/sami-files.service`
|
||||
// unit + a Caddy reverse_proxy (see README in /opt/sami-files/deploy/).
|
||||
// Service health is checked by probing the FastAPI /api/health endpoint
|
||||
// on 127.0.0.1:8765; Caddy proxies the public URL.
|
||||
"sami-files": {
|
||||
name: "Sami Files",
|
||||
description: "Multi-server SSH file manager — browse, edit, upload, and exec across all your machines from one browser tab",
|
||||
icon: "📂",
|
||||
logo: "/assets/sami-files.png",
|
||||
category: "Files",
|
||||
popularity: 80,
|
||||
difficulty: "Intermediate",
|
||||
isSystemdService: true,
|
||||
systemdUnit: "sami-files.service",
|
||||
healthCheck: "http://127.0.0.1:8765/api/health",
|
||||
healthCheckExpect: "ok",
|
||||
defaultPort: 8765,
|
||||
subdomain: "files",
|
||||
proxyPass: "http://127.0.0.1:8765",
|
||||
subpathSupport: 'none',
|
||||
externalConfig: {
|
||||
// Where the source code / config lives on the host. DashCaddy reads
|
||||
// these paths when generating a fresh setup via "Deploy" in the App
|
||||
// Selector — they are informational for the systemd variant.
|
||||
installDir: "/opt/sami-files",
|
||||
configFile: "/opt/sami-files/config/servers.yaml",
|
||||
serviceFile: "/opt/sami-files/deploy/sami-files.service",
|
||||
logFile: "/opt/sami-files/logs/backend.log",
|
||||
pythonVenv: "/usr/local/lib/hermes-agent/venv",
|
||||
repo: "git.sami/sami7777/sami-files",
|
||||
dependencies: [
|
||||
"python3 >= 3.11 with uvicorn + asyncssh + pyyaml + fastapi",
|
||||
"systemd >= 245 (for StandardOutput=append: journal syntax)"
|
||||
],
|
||||
caddySnippet: [
|
||||
"files.sami {",
|
||||
" reverse_proxy 127.0.0.1:8765",
|
||||
" import dashcaddy_auth",
|
||||
"}"
|
||||
]
|
||||
},
|
||||
setupInstructions: [
|
||||
"Clone the repo: git clone http://100.81.59.99:3030/sami7777/sami-files.git /opt/sami-files",
|
||||
"Create venv and install deps: /usr/local/lib/hermes-agent/venv/bin/pip install fastapi uvicorn asyncssh pyyaml python-multipart",
|
||||
"Copy deploy/sami-files.service to /etc/systemd/system/ and `systemctl daemon-reload`",
|
||||
"Enable + start: systemctl enable --now sami-files.service",
|
||||
"Edit /opt/sami-files/config/servers.yaml to add your SSH targets",
|
||||
"Add the Caddy snippet (above) to your Caddyfile and reload Caddy",
|
||||
"Browse to https://files.sami — log in via DashCaddy SSO"
|
||||
],
|
||||
troubleshooting: [
|
||||
{
|
||||
symptom: "Service fails to start with 'No such file or directory'",
|
||||
fix: "Verify the python venv path in the .service file matches your installation (use `which python3` and update ExecStart accordingly)."
|
||||
},
|
||||
{
|
||||
symptom: "Backend logs show 'Permission denied' on key file",
|
||||
fix: "Run `chmod 600 /root/.ssh/<key>` for each key_file listed in servers.yaml — backend refuses to load keys with looser permissions."
|
||||
},
|
||||
{
|
||||
symptom: "Browser shows 'Cannot connect' but systemctl says running",
|
||||
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,503 +0,0 @@
|
||||
/**
|
||||
* Auto-Restart Manager - Per-container restart policies with retry tracking
|
||||
*
|
||||
* When a container goes down, attempts automatic restart up to N times
|
||||
* (configurable per-service). Sends notifications on each attempt and
|
||||
* when max retries are exceeded. Integrates with HealthChecker events.
|
||||
*
|
||||
* @module auto-restart-manager
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
|
||||
|
||||
/**
|
||||
* Default policy values applied when a new policy is created.
|
||||
* @readonly
|
||||
*/
|
||||
const DEFAULT_POLICY = {
|
||||
enabled: true,
|
||||
maxRetries: 3,
|
||||
retryIntervalMs: 5000,
|
||||
windowMinutes: 10,
|
||||
currentRetries: 0,
|
||||
lastRestartAt: null,
|
||||
cooldownUntil: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages automatic container restart policies and execution.
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*
|
||||
* @fires AutoRestartManager#auto-restart-attempt
|
||||
* @fires AutoRestartManager#auto-restart-success
|
||||
* @fires AutoRestartManager#auto-restart-failed
|
||||
* @fires AutoRestartManager#auto-restart-max-reached
|
||||
*/
|
||||
class AutoRestartManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
|
||||
* @param {Object} ctx.healthChecker - HealthChecker singleton
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {Function} ctx.logError - Error logging function
|
||||
* @param {string} ctx.SERVICES_FILE - Path to services.json (used to derive data dir)
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
this.logError = ctx.logError || ((_ctx, err) => console.error(err));
|
||||
this.docker = ctx.docker;
|
||||
this.healthChecker = ctx.healthChecker;
|
||||
this.notification = ctx.notification;
|
||||
|
||||
/** @type {Map<string, Object>} serviceId -> policy */
|
||||
this.policies = new Map();
|
||||
|
||||
/** Path to the JSON file that persists policies */
|
||||
this.policiesFile = path.join(path.dirname(ctx.SERVICES_FILE), 'auto-restart-policies.json');
|
||||
|
||||
/** Track previous health status per service for transition detection */
|
||||
this._previousHealth = new Map();
|
||||
|
||||
/** Bound handlers so we can remove them on stop() */
|
||||
this._onStatusCheck = this._handleStatusCheck.bind(this);
|
||||
this._started = false;
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Load persisted policies, then wire into HealthChecker events.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async start() {
|
||||
if (this._started) return;
|
||||
|
||||
// Load persisted policies from disk
|
||||
try {
|
||||
const data = await readJsonFile(this.policiesFile, {});
|
||||
for (const [serviceId, policy] of Object.entries(data)) {
|
||||
this.policies.set(serviceId, { ...DEFAULT_POLICY, ...policy });
|
||||
}
|
||||
this.log.info('auto-restart', 'Policies loaded', { count: this.policies.size });
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to load policies', { error: err.message });
|
||||
}
|
||||
|
||||
// Listen to health checker status transitions
|
||||
if (this.healthChecker) {
|
||||
this.healthChecker.on('status-check', this._onStatusCheck);
|
||||
}
|
||||
|
||||
this._started = true;
|
||||
this.log.info('auto-restart', 'Manager started');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listeners and stop processing health events.
|
||||
*/
|
||||
stop() {
|
||||
if (!this._started) return;
|
||||
|
||||
if (this.healthChecker) {
|
||||
this.healthChecker.removeListener('status-check', this._onStatusCheck);
|
||||
}
|
||||
|
||||
this._started = false;
|
||||
this.log.info('auto-restart', 'Manager stopped');
|
||||
}
|
||||
|
||||
// ─── Policy CRUD ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create or update a restart policy for a service.
|
||||
*
|
||||
* @param {string} serviceId - Unique service identifier
|
||||
* @param {Object} policy - Partial policy fields to merge
|
||||
* @param {boolean} [policy.enabled=true]
|
||||
* @param {number} [policy.maxRetries=3]
|
||||
* @param {number} [policy.retryIntervalMs=5000]
|
||||
* @param {number} [policy.windowMinutes=10]
|
||||
* @returns {Promise<Object>} The resulting policy
|
||||
* @throws {Error} If serviceId is invalid
|
||||
*/
|
||||
async setPolicy(serviceId, policy) {
|
||||
if (!serviceId || typeof serviceId !== 'string') {
|
||||
throw new Error('serviceId is required');
|
||||
}
|
||||
|
||||
const existing = this.policies.get(serviceId) || { ...DEFAULT_POLICY, serviceId };
|
||||
|
||||
const merged = {
|
||||
...existing,
|
||||
...policy,
|
||||
serviceId,
|
||||
// Never allow caller to override runtime counters directly
|
||||
currentRetries: existing.currentRetries || 0,
|
||||
lastRestartAt: existing.lastRestartAt,
|
||||
cooldownUntil: existing.cooldownUntil,
|
||||
};
|
||||
|
||||
this.policies.set(serviceId, merged);
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Policy set', { serviceId, enabled: merged.enabled });
|
||||
return { ...merged };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the policy for a service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Object|null} Policy object or null if none exists
|
||||
*/
|
||||
getPolicy(serviceId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
return policy ? { ...policy } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all policies as an array.
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
listPolicies() {
|
||||
return Array.from(this.policies.values()).map(p => ({ ...p }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a service's restart policy.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<boolean>} true if a policy was removed
|
||||
*/
|
||||
async removePolicy(serviceId) {
|
||||
if (!this.policies.has(serviceId)) return false;
|
||||
|
||||
this.policies.delete(serviceId);
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Policy removed', { serviceId });
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── Core Restart Logic ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Called when a container is detected as down.
|
||||
*
|
||||
* Checks policy, cooldown, and retry count, then either attempts a
|
||||
* Docker restart or notifies that max retries were exceeded.
|
||||
*
|
||||
* @param {string} serviceId - Service identifier
|
||||
* @param {string} containerId - Docker container ID to restart
|
||||
* @returns {Promise<Object>} Result of the operation
|
||||
*/
|
||||
async handleContainerDown(serviceId, containerId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
if (!policy) {
|
||||
return { action: 'ignored', reason: 'no-policy' };
|
||||
}
|
||||
|
||||
if (!policy.enabled) {
|
||||
return { action: 'ignored', reason: 'disabled' };
|
||||
}
|
||||
|
||||
// Check cooldown window
|
||||
const now = Date.now();
|
||||
if (policy.cooldownUntil && now < policy.cooldownUntil) {
|
||||
this.log.info('auto-restart', 'Skipping — cooldown active', {
|
||||
serviceId,
|
||||
cooldownUntil: new Date(policy.cooldownUntil).toISOString(),
|
||||
});
|
||||
return { action: 'skipped', reason: 'cooldown' };
|
||||
}
|
||||
|
||||
// Max retries exceeded — notify and enter cooldown
|
||||
if (policy.currentRetries >= policy.maxRetries) {
|
||||
const cooldownMs = policy.windowMinutes * 60 * 1000;
|
||||
policy.cooldownUntil = now + cooldownMs;
|
||||
policy.currentRetries = 0; // Reset so next window can try again
|
||||
await this._savePolicies();
|
||||
|
||||
const eventData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
maxRetries: policy.maxRetries,
|
||||
cooldownUntil: policy.cooldownUntil,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-max-reached
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-max-reached', eventData);
|
||||
|
||||
// Send notification
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `⛔ Max auto-restart retries (${policy.maxRetries}) exceeded for "${serviceId}". Cooldown until ${new Date(policy.cooldownUntil).toISOString()}.`,
|
||||
...eventData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
return { action: 'max-reached', ...eventData };
|
||||
}
|
||||
|
||||
// Wait for the configured retry interval before attempting
|
||||
if (policy.retryIntervalMs > 0 && policy.lastRestartAt) {
|
||||
const elapsed = now - new Date(policy.lastRestartAt).getTime();
|
||||
if (elapsed < policy.retryIntervalMs) {
|
||||
const waitMs = policy.retryIntervalMs - elapsed;
|
||||
this.log.info('auto-restart', 'Waiting for retry interval', { serviceId, waitMs });
|
||||
await new Promise(resolve => setTimeout(resolve, waitMs));
|
||||
}
|
||||
}
|
||||
|
||||
// Attempt restart
|
||||
policy.currentRetries += 1;
|
||||
const attemptNum = policy.currentRetries;
|
||||
const maxRetries = policy.maxRetries;
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-attempt
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-attempt', {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (!this.docker?.client) {
|
||||
throw new Error('Docker client not available');
|
||||
}
|
||||
|
||||
const container = this.docker.client.getContainer(containerId);
|
||||
await container.start();
|
||||
|
||||
policy.lastRestartAt = new Date().toISOString();
|
||||
await this._savePolicies();
|
||||
|
||||
const successData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-success
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-success', successData);
|
||||
|
||||
// Notify
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `🔄 Auto-restart attempt ${attemptNum}/${maxRetries} succeeded for "${serviceId}".`,
|
||||
...successData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
this.log.info('auto-restart', 'Container restarted', {
|
||||
serviceId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
});
|
||||
|
||||
return { action: 'restarted', ...successData };
|
||||
} catch (restartErr) {
|
||||
policy.lastRestartAt = new Date().toISOString();
|
||||
await this._savePolicies();
|
||||
|
||||
const failData = {
|
||||
serviceId,
|
||||
containerId,
|
||||
attempt: attemptNum,
|
||||
maxRetries,
|
||||
error: restartErr.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
/**
|
||||
* @event AutoRestartManager#auto-restart-failed
|
||||
* @type {Object}
|
||||
*/
|
||||
this.emit('auto-restart-failed', failData);
|
||||
|
||||
// Notify
|
||||
try {
|
||||
await this._notify('auto-restart', {
|
||||
containerName: serviceId,
|
||||
message: `❌ Auto-restart attempt ${attemptNum}/${maxRetries} failed for "${serviceId}": ${restartErr.message}`,
|
||||
...failData,
|
||||
});
|
||||
} catch (notifErr) {
|
||||
this.log.error('auto-restart', 'Notification failed', { error: notifErr.message });
|
||||
}
|
||||
|
||||
this.log.error('auto-restart', 'Restart failed', {
|
||||
serviceId,
|
||||
attempt: attemptNum,
|
||||
error: restartErr.message,
|
||||
});
|
||||
|
||||
return { action: 'failed', ...failData };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a container recovers to healthy state.
|
||||
* Resets the retry counter for the associated service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async handleContainerUp(serviceId) {
|
||||
const policy = this.policies.get(serviceId);
|
||||
if (!policy) return;
|
||||
|
||||
if (policy.currentRetries > 0) {
|
||||
policy.currentRetries = 0;
|
||||
policy.cooldownUntil = null;
|
||||
await this._savePolicies();
|
||||
|
||||
this.log.info('auto-restart', 'Retries reset after recovery', { serviceId });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Health Event Bridge ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Internal handler for HealthChecker `status-check` events.
|
||||
* Detects healthy→unhealthy and unhealthy→healthy transitions for tracked services.
|
||||
*
|
||||
* @param {Object} status - HealthChecker status object
|
||||
* @param {string} status.serviceId
|
||||
* @param {string} status.status - "up" or "down"
|
||||
* @private
|
||||
*/
|
||||
async _handleStatusCheck(status) {
|
||||
const { serviceId, status: currentStatus } = status;
|
||||
if (!serviceId) return;
|
||||
|
||||
// Only process services that have a restart policy
|
||||
if (!this.policies.has(serviceId)) return;
|
||||
|
||||
const previousStatus = this._previousHealth.get(serviceId);
|
||||
this._previousHealth.set(serviceId, currentStatus);
|
||||
|
||||
// Transition: healthy → unhealthy
|
||||
if (previousStatus === 'up' && currentStatus === 'down') {
|
||||
// Find the containerId from the health checker config or status details
|
||||
const containerId = this._resolveContainerId(serviceId, status);
|
||||
if (containerId) {
|
||||
try {
|
||||
await this.handleContainerDown(serviceId, containerId);
|
||||
} catch (err) {
|
||||
this.logError('auto-restart-health-bridge', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transition: unhealthy → healthy (recovery)
|
||||
if (previousStatus === 'down' && currentStatus === 'up') {
|
||||
try {
|
||||
await this.handleContainerUp(serviceId);
|
||||
} catch (err) {
|
||||
this.logError('auto-restart-health-bridge', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to find the containerId for a service from various sources.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @param {Object} status - The status-check event data
|
||||
* @returns {string|null}
|
||||
* @private
|
||||
*/
|
||||
_resolveContainerId(serviceId, status) {
|
||||
// Check if it's in the status details (some health checks embed it)
|
||||
if (status.details?.containerId) return status.details.containerId;
|
||||
|
||||
// Look in the health checker config
|
||||
const hcService = this.healthChecker?.config?.services?.[serviceId];
|
||||
if (hcService?.containerId) return hcService.containerId;
|
||||
|
||||
// Try to look it up from the services state manager
|
||||
try {
|
||||
const servicesStateManager = this.ctx.servicesStateManager;
|
||||
if (servicesStateManager) {
|
||||
const readResult = servicesStateManager.read();
|
||||
if (readResult && typeof readResult.then === 'function') {
|
||||
// It returns a promise — fire-and-forget lookup
|
||||
readResult.then(list => {
|
||||
const found = (list || []).find(s => s.id === serviceId);
|
||||
return found?.containerId || null;
|
||||
}).catch(() => null);
|
||||
} else {
|
||||
const found = (readResult || []).find(s => s.id === serviceId);
|
||||
if (found?.containerId) return found.containerId;
|
||||
}
|
||||
}
|
||||
} catch (_) { /* best effort */ }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Persistence ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persist current policies to disk.
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _savePolicies() {
|
||||
try {
|
||||
const obj = {};
|
||||
for (const [serviceId, policy] of this.policies.entries()) {
|
||||
obj[serviceId] = { ...policy };
|
||||
}
|
||||
await writeJsonFile(this.policiesFile, obj);
|
||||
} catch (err) {
|
||||
this.log.error('auto-restart', 'Failed to save policies', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a notification via the notification manager.
|
||||
*
|
||||
* @param {string} event - Event type (e.g. 'auto-restart')
|
||||
* @param {Object} data - Notification payload
|
||||
* @returns {Promise<Object>}
|
||||
* @private
|
||||
*/
|
||||
async _notify(event, data) {
|
||||
if (this.notification?.send) {
|
||||
return this.notification.send(event, data);
|
||||
}
|
||||
return { success: false, reason: 'no-notification-manager' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AutoRestartManager, DEFAULT_POLICY };
|
||||
+82
-328
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const EventEmitter = require('events');
|
||||
|
||||
// Format bytes to human readable string
|
||||
function formatBytes(bytes) {
|
||||
if (bytes === 0 || bytes === undefined || bytes === null) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
const BACKUP_CONFIG_FILE = process.env.BACKUP_CONFIG_FILE || path.join(__dirname, 'backup-config.json');
|
||||
const BACKUP_HISTORY_FILE = process.env.BACKUP_HISTORY_FILE || path.join(__dirname, 'backup-history.json');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
@@ -20,14 +29,6 @@ class BackupManager extends EventEmitter {
|
||||
this.history = this.loadHistory();
|
||||
this.scheduledJobs = new Map();
|
||||
this.running = false;
|
||||
this.notificationManager = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the notification manager for sending backup notifications
|
||||
*/
|
||||
setNotificationManager(nm) {
|
||||
this.notificationManager = nm;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,7 +84,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) {
|
||||
@@ -92,7 +93,6 @@ class BackupManager extends EventEmitter {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
|
||||
await this.cleanupOldBackups(name, backup.retention);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
|
||||
});
|
||||
// Enforce storage limit (delete oldest until within maxStorageBytes)
|
||||
if (backup.maxStorageBytes) {
|
||||
await this.enforceStorageLimit(name, backup.maxStorageBytes);
|
||||
}
|
||||
|
||||
this.emit('backup-complete', historyEntry);
|
||||
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
|
||||
|
||||
return historyEntry;
|
||||
@@ -209,14 +206,7 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
this.addToHistory(historyEntry);
|
||||
this.emit('backup-failed', historyEntry);
|
||||
|
||||
// Send notification if manager is configured
|
||||
if (this.notificationManager) {
|
||||
this.notificationManager.sendBackupFailed(historyEntry).catch(err => {
|
||||
console.error('[BackupManager] Failed to send backup-failed notification:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -566,42 +556,17 @@ class BackupManager extends EventEmitter {
|
||||
switch (destination.type) {
|
||||
case 'local':
|
||||
return await this.saveToLocal(data, destination, backupId);
|
||||
case 'dropbox':
|
||||
return await this.saveToDropbox(data, destination, backupId);
|
||||
case 'webdav':
|
||||
return await this.saveToWebDAV(data, destination, backupId);
|
||||
case 'sftp':
|
||||
return await this.saveToSFTP(data, destination, backupId);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${destination.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load encrypted backup blob from a destination location.
|
||||
* Returns a Buffer that can be passed to decryptBackup/decompressBackup.
|
||||
*/
|
||||
async loadFromDestination(location) {
|
||||
switch (location.type) {
|
||||
case 'local':
|
||||
return fs.readFileSync(location.path);
|
||||
case 'dropbox':
|
||||
return await this.loadFromDropbox(location);
|
||||
case 'webdav':
|
||||
return await this.loadFromWebDAV(location);
|
||||
case 'sftp':
|
||||
return await this.loadFromSFTP(location);
|
||||
default:
|
||||
throw new Error(`Unsupported destination type: ${location.type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save to local filesystem
|
||||
*/
|
||||
async saveToLocal(data, destination, backupId) {
|
||||
const backupDir = destination.path || DEFAULT_BACKUP_DIR;
|
||||
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(backupDir)) {
|
||||
fs.mkdirSync(backupDir, { recursive: true });
|
||||
@@ -609,9 +574,9 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
const filename = `${backupId}.backup`;
|
||||
const filepath = path.join(backupDir, filename);
|
||||
|
||||
|
||||
fs.writeFileSync(filepath, data);
|
||||
|
||||
|
||||
return {
|
||||
type: 'local',
|
||||
path: filepath,
|
||||
@@ -619,257 +584,6 @@ class BackupManager extends EventEmitter {
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== CLOUD DESTINATIONS ====================
|
||||
|
||||
/**
|
||||
* Resolve credentials for a given provider via the credentialManager.
|
||||
* Throws if required fields are missing.
|
||||
*/
|
||||
async _getCloudCredentials(provider) {
|
||||
const credentialManager = require('./credential-manager');
|
||||
const creds = {};
|
||||
if (provider === 'dropbox') {
|
||||
creds.token = await credentialManager.retrieve('backup.dropbox.token');
|
||||
if (!creds.token) throw new Error('Dropbox token not configured');
|
||||
} else if (provider === 'webdav') {
|
||||
creds.url = await credentialManager.retrieve('backup.webdav.url');
|
||||
creds.username = await credentialManager.retrieve('backup.webdav.username');
|
||||
creds.password = await credentialManager.retrieve('backup.webdav.password');
|
||||
if (!creds.url || !creds.username || !creds.password) {
|
||||
throw new Error('WebDAV credentials incomplete (need url, username, password)');
|
||||
}
|
||||
} else if (provider === 'sftp') {
|
||||
creds.host = await credentialManager.retrieve('backup.sftp.host');
|
||||
const portStr = await credentialManager.retrieve('backup.sftp.port');
|
||||
creds.port = parseInt(portStr || '22', 10);
|
||||
creds.username = await credentialManager.retrieve('backup.sftp.username');
|
||||
creds.password = await credentialManager.retrieve('backup.sftp.password');
|
||||
creds.privateKey = await credentialManager.retrieve('backup.sftp.privateKey');
|
||||
if (!creds.host || !creds.username || (!creds.password && !creds.privateKey)) {
|
||||
throw new Error('SFTP credentials incomplete (need host, username, and either password or privateKey)');
|
||||
}
|
||||
}
|
||||
return creds;
|
||||
}
|
||||
|
||||
// ----- Dropbox -----
|
||||
|
||||
async saveToDropbox(data, destination, backupId) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
|
||||
await dbx.filesUpload({
|
||||
path: remotePath,
|
||||
contents: data,
|
||||
mode: { '.tag': 'overwrite' },
|
||||
autorename: false,
|
||||
mute: true
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'dropbox',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromDropbox(location) {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
const result = await dbx.filesDownload({ path: location.path });
|
||||
// Node SDK returns fileBinary on the result
|
||||
const fileBinary = result.result.fileBinary || result.result.fileBlob;
|
||||
if (Buffer.isBuffer(fileBinary)) return fileBinary;
|
||||
return Buffer.from(fileBinary);
|
||||
}
|
||||
|
||||
// ----- WebDAV -----
|
||||
|
||||
async saveToWebDAV(data, destination, backupId) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
|
||||
// Ensure folder exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.createDirectory(folder, { recursive: true });
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.putFileContents(remotePath, data, { overwrite: true });
|
||||
|
||||
return {
|
||||
type: 'webdav',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
}
|
||||
|
||||
async loadFromWebDAV(location) {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, {
|
||||
username: creds.username,
|
||||
password: creds.password
|
||||
});
|
||||
const data = await client.getFileContents(location.path);
|
||||
return Buffer.isBuffer(data) ? data : Buffer.from(data);
|
||||
}
|
||||
|
||||
// ----- SFTP -----
|
||||
|
||||
async saveToSFTP(data, destination, backupId) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
|
||||
const folder = (destination.path || '/dashcaddy-backups').replace(/\/+$/, '');
|
||||
// Ensure remote dir exists
|
||||
try {
|
||||
const exists = await client.exists(folder);
|
||||
if (!exists) await client.mkdir(folder, true);
|
||||
} catch (_) {
|
||||
// best-effort
|
||||
}
|
||||
|
||||
const remotePath = `${folder}/${backupId}.backup`;
|
||||
await client.put(Buffer.from(data), remotePath);
|
||||
|
||||
return {
|
||||
type: 'sftp',
|
||||
path: remotePath,
|
||||
size: data.length
|
||||
};
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
async loadFromSFTP(location) {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
const buffer = await client.get(location.path);
|
||||
return Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a destination is reachable + writable + deletable.
|
||||
* Performs a small write/read/delete probe.
|
||||
*/
|
||||
async testDestination(destination) {
|
||||
const probeId = `test-${Date.now()}`;
|
||||
const probeData = Buffer.from(`dashcaddy-test-${probeId}`);
|
||||
const start = Date.now();
|
||||
|
||||
try {
|
||||
const location = await this.saveToDestination(probeData, destination, probeId);
|
||||
|
||||
// Read it back
|
||||
let readBack = null;
|
||||
try {
|
||||
readBack = await this.loadFromDestination(location);
|
||||
} catch (_) {
|
||||
// Some providers (e.g. local) we already trust the file system; skip
|
||||
}
|
||||
|
||||
// Delete the probe
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (_) { /* ignore */ }
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
return {
|
||||
success: true,
|
||||
type: destination.type,
|
||||
elapsedMs: elapsed,
|
||||
verified: readBack ? readBack.equals(probeData) : null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
type: destination.type,
|
||||
error: error.message,
|
||||
elapsedMs: Date.now() - start
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup from a destination location
|
||||
*/
|
||||
async _deleteFromDestination(location) {
|
||||
if (location.type === 'local') {
|
||||
if (fs.existsSync(location.path)) fs.unlinkSync(location.path);
|
||||
return;
|
||||
}
|
||||
if (location.type === 'dropbox') {
|
||||
const { Dropbox } = require('dropbox');
|
||||
const creds = await this._getCloudCredentials('dropbox');
|
||||
const dbx = new Dropbox({ accessToken: creds.token });
|
||||
try { await dbx.filesDeleteV2({ path: location.path }); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'webdav') {
|
||||
const { createClient } = require('webdav');
|
||||
const creds = await this._getCloudCredentials('webdav');
|
||||
const client = createClient(creds.url, { username: creds.username, password: creds.password });
|
||||
try { await client.deleteFile(location.path); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
if (location.type === 'sftp') {
|
||||
const SftpClient = require('ssh2-sftp-client');
|
||||
const creds = await this._getCloudCredentials('sftp');
|
||||
const client = new SftpClient();
|
||||
try {
|
||||
await client.connect({
|
||||
host: creds.host,
|
||||
port: creds.port,
|
||||
username: creds.username,
|
||||
password: creds.password || undefined,
|
||||
privateKey: creds.privateKey || undefined
|
||||
});
|
||||
try { await client.delete(location.path); } catch (_) { /* ignore */ }
|
||||
} finally {
|
||||
try { await client.end(); } catch (_) { /* ignore */ }
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify backup integrity
|
||||
*/
|
||||
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
|
||||
throw new Error(`Backup not found: ${backupId}`);
|
||||
}
|
||||
|
||||
// Load backup data — try each destination location until one succeeds
|
||||
const location = backup.locations[0]; // Primary location
|
||||
let data;
|
||||
try {
|
||||
data = await this.loadFromDestination(location);
|
||||
} catch (loadErr) {
|
||||
// Fall back to other locations if available
|
||||
let recovered = false;
|
||||
for (let i = 1; i < backup.locations.length; i++) {
|
||||
try {
|
||||
data = await this.loadFromDestination(backup.locations[i]);
|
||||
recovered = true;
|
||||
console.log(`[BackupManager] Loaded backup from fallback location ${backup.locations[i].type}`);
|
||||
break;
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
if (!recovered) throw loadErr;
|
||||
}
|
||||
// Load backup data
|
||||
const location = backup.locations[0]; // Use first location
|
||||
let data = fs.readFileSync(location.path);
|
||||
|
||||
// Decrypt if needed
|
||||
if (backup.encrypted && options.encryptionKey) {
|
||||
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
|
||||
console.log('[BackupManager] Stats restored');
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce storage limit by deleting oldest backups until total is within limit
|
||||
*/
|
||||
async enforceStorageLimit(name, maxBytes) {
|
||||
const maxStr = formatBytes(maxBytes);
|
||||
console.log("[BackupManager] Enforcing storage limit: " + maxStr + " for \"" + name + "\"");
|
||||
|
||||
const backups = this.history
|
||||
.filter(b => b.name === name && b.status === 'success')
|
||||
.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
|
||||
|
||||
let totalSize = 0;
|
||||
const locationsMap = {};
|
||||
|
||||
for (const backup of backups) {
|
||||
for (const loc of backup.locations || []) {
|
||||
if (loc.type === 'local' && loc.path) {
|
||||
totalSize += loc.size || 0;
|
||||
locationsMap[backup.id] = locationsMap[backup.id] || [];
|
||||
locationsMap[backup.id].push(loc.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[BackupManager] Current total size: " + formatBytes(totalSize) + ", limit: " + maxStr);
|
||||
|
||||
if (totalSize <= maxBytes) {
|
||||
console.log("[BackupManager] Storage limit OK (" + formatBytes(totalSize) + " <= " + maxStr + ")");
|
||||
return;
|
||||
}
|
||||
|
||||
let freed = 0;
|
||||
for (const backup of backups) {
|
||||
if (totalSize <= maxBytes) break;
|
||||
|
||||
const paths = locationsMap[backup.id] || [];
|
||||
for (const path of paths) {
|
||||
try {
|
||||
if (fs.existsSync(path)) {
|
||||
fs.unlinkSync(path);
|
||||
const sz = backup.size || 0;
|
||||
totalSize -= sz;
|
||||
freed += sz;
|
||||
console.log("[BackupManager] Deleted " + formatBytes(sz) + ": " + path);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[BackupManager] Error deleting " + path + ": " + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
}
|
||||
|
||||
this.saveHistory();
|
||||
console.log("[BackupManager] Storage limit enforced. Freed " + formatBytes(freed) + ", now " + formatBytes(totalSize));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old backups based on retention policy
|
||||
*/
|
||||
@@ -1032,18 +788,16 @@ class BackupManager extends EventEmitter {
|
||||
|
||||
for (const backup of toDelete) {
|
||||
try {
|
||||
// Delete from all locations (local + cloud)
|
||||
// Delete from all locations
|
||||
for (const location of backup.locations) {
|
||||
try {
|
||||
await this._deleteFromDestination(location);
|
||||
} catch (delErr) {
|
||||
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
|
||||
if (location.type === 'local' && fs.existsSync(location.path)) {
|
||||
fs.unlinkSync(location.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from history
|
||||
this.history = this.history.filter(b => b.id !== backup.id);
|
||||
|
||||
|
||||
console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
|
||||
} catch (error) {
|
||||
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
|
||||
|
||||
@@ -1,376 +0,0 @@
|
||||
/**
|
||||
* Config Drift Detector - Compares services.json with live Docker state
|
||||
*
|
||||
* Detects discrepancies between the configured service list and what is
|
||||
* actually running in Docker, including missing containers, unknown
|
||||
* containers, port mismatches, state mismatches, and stale records.
|
||||
*
|
||||
* @module config-drift-detector
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/**
|
||||
* @typedef {Object} DriftReport
|
||||
* @property {string} checkedAt - ISO timestamp of the check
|
||||
* @property {Object[]} missingContainers - Services with containerId but container absent in Docker
|
||||
* @property {Object[]} unknownContainers - Running Docker containers with sami.managed label but not in services.json
|
||||
* @property {Object[]} portMismatch - Service port != container mapped port
|
||||
* @property {Object[]} stateMismatch - Service expected up but container stopped/absent
|
||||
* @property {Object[]} staleRecords - Services with containerId pointing to removed containers
|
||||
* @property {boolean} hasDrift - Whether any drift category is non-empty
|
||||
*/
|
||||
|
||||
/**
|
||||
* Detects and reports configuration drift between services.json and Docker.
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*
|
||||
* @fires ConfigDriftDetector#drift-detected
|
||||
*/
|
||||
class ConfigDriftDetector extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.docker - Docker client wrapper ({ client: Dockerode })
|
||||
* @param {Object} ctx.servicesStateManager - StateManager for services.json
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {Function} ctx.logError - Error logging function
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
this.logError = ctx.logError || ((_c, err) => console.error(err));
|
||||
this.docker = ctx.docker;
|
||||
this.servicesStateManager = ctx.servicesStateManager;
|
||||
this.notification = ctx.notification;
|
||||
|
||||
/** @type {DriftReport|null} Cached report from last detection */
|
||||
this.lastReport = null;
|
||||
|
||||
/** @type {NodeJS.Timeout|null} Polling timer reference */
|
||||
this._pollTimer = null;
|
||||
|
||||
/** Whether polling is currently active */
|
||||
this._polling = false;
|
||||
}
|
||||
|
||||
// ─── Detection ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run a full drift detection and return the report.
|
||||
*
|
||||
* Reads services from servicesStateManager and live containers from Docker,
|
||||
* then compares them across five drift categories.
|
||||
*
|
||||
* @returns {Promise<DriftReport>}
|
||||
*/
|
||||
async detect() {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
// Gather configured services
|
||||
let services = [];
|
||||
try {
|
||||
const data = await this.servicesStateManager.read();
|
||||
services = Array.isArray(data) ? data : (data.services || []);
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to read services', { error: err.message });
|
||||
}
|
||||
|
||||
// Gather live Docker containers
|
||||
let containers = [];
|
||||
try {
|
||||
containers = await this.docker.client.listContainers({ all: true });
|
||||
} catch (err) {
|
||||
this.log.error('drift', 'Failed to list containers', { error: err.message });
|
||||
}
|
||||
|
||||
// Build lookup maps
|
||||
const containerById = new Map(); // containerId (short or long) → container info
|
||||
const containerByName = new Map(); // container name → container info
|
||||
|
||||
for (const c of containers) {
|
||||
// Store by full ID
|
||||
containerById.set(c.Id, c);
|
||||
// Store by short ID (first 12 chars)
|
||||
if (c.Id && c.Id.length >= 12) {
|
||||
containerById.set(c.Id.substring(0, 12), c);
|
||||
}
|
||||
// Store by name (strip leading /)
|
||||
for (const name of (c.Names || [])) {
|
||||
containerByName.set(name.replace(/^\//, ''), c);
|
||||
}
|
||||
}
|
||||
|
||||
// Build set of service containerIds for reverse lookup
|
||||
const serviceContainerIds = new Set();
|
||||
const serviceByContainerId = new Map();
|
||||
|
||||
for (const svc of services) {
|
||||
if (svc.containerId) {
|
||||
serviceContainerIds.add(svc.containerId);
|
||||
// Index by both full and short ID
|
||||
serviceByContainerId.set(svc.containerId, svc);
|
||||
if (svc.containerId.length >= 12) {
|
||||
serviceByContainerId.set(svc.containerId.substring(0, 12), svc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const missingContainers = [];
|
||||
const portMismatch = [];
|
||||
const stateMismatch = [];
|
||||
const staleRecords = [];
|
||||
|
||||
for (const svc of services) {
|
||||
if (!svc.containerId) continue;
|
||||
|
||||
// Look up the container
|
||||
const container = containerById.get(svc.containerId)
|
||||
|| containerById.get(svc.containerId.substring(0, 12));
|
||||
|
||||
if (!container) {
|
||||
// Container ID referenced but not found in Docker at all
|
||||
staleRecords.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
containerId: svc.containerId,
|
||||
reason: 'Container not found in Docker',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Missing container — service expects it but it's not running
|
||||
if (container.State !== 'running') {
|
||||
missingContainers.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
containerId: svc.containerId,
|
||||
containerState: container.State,
|
||||
containerStatus: container.Status,
|
||||
});
|
||||
|
||||
// Also a state mismatch if the service is expected to be up
|
||||
stateMismatch.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
expectedState: 'running',
|
||||
actualState: container.State,
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
}
|
||||
|
||||
// Port mismatch detection
|
||||
if (svc.port && container.State === 'running') {
|
||||
const actualPorts = this._extractContainerPorts(container);
|
||||
if (actualPorts.length > 0 && !actualPorts.includes(svc.port)) {
|
||||
portMismatch.push({
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
configuredPort: svc.port,
|
||||
actualPorts,
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown managed containers: Docker containers with sami.managed label
|
||||
// that are NOT in services.json
|
||||
const unknownContainers = [];
|
||||
for (const c of containers) {
|
||||
const isManaged = c.Labels && c.Labels['sami.managed'] === 'true';
|
||||
if (!isManaged) continue;
|
||||
|
||||
const isInServices = serviceByContainerId.has(c.Id)
|
||||
|| serviceByContainerId.has(c.Id.substring(0, 12));
|
||||
|
||||
if (!isInServices) {
|
||||
unknownContainers.push({
|
||||
containerId: c.Id,
|
||||
name: (c.Names && c.Names[0] || '').replace(/^\//, ''),
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
app: c.Labels?.['sami.app'] || null,
|
||||
subdomain: c.Labels?.['sami.subdomain'] || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const report = {
|
||||
checkedAt,
|
||||
missingContainers,
|
||||
unknownContainers,
|
||||
portMismatch,
|
||||
stateMismatch,
|
||||
staleRecords,
|
||||
hasDrift: missingContainers.length > 0
|
||||
|| unknownContainers.length > 0
|
||||
|| portMismatch.length > 0
|
||||
|| stateMismatch.length > 0
|
||||
|| staleRecords.length > 0,
|
||||
};
|
||||
|
||||
// Cache for quick API access
|
||||
this.lastReport = report;
|
||||
|
||||
// Emit and notify if drift detected
|
||||
if (report.hasDrift) {
|
||||
/**
|
||||
* @event ConfigDriftDetector#drift-detected
|
||||
* @type {DriftReport}
|
||||
*/
|
||||
this.emit('drift-detected', report);
|
||||
|
||||
try {
|
||||
await this._sendDriftNotification(report);
|
||||
} catch (notifErr) {
|
||||
this.log.error('drift', 'Failed to send drift notification', {
|
||||
error: notifErr.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.log.info('drift', 'Detection complete', {
|
||||
hasDrift: report.hasDrift,
|
||||
missing: report.missingContainers.length,
|
||||
unknown: report.unknownContainers.length,
|
||||
portMismatch: report.portMismatch.length,
|
||||
stateMismatch: report.stateMismatch.length,
|
||||
stale: report.staleRecords.length,
|
||||
});
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
// ─── Auto-fix ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Attempt to auto-fix drift:
|
||||
* - Remove stale records (services referencing removed containers)
|
||||
* - Flag unknown containers for review
|
||||
*
|
||||
* @returns {Promise<{ staleRemoved: number, unknownFlagged: number }>}
|
||||
*/
|
||||
async autoFix() {
|
||||
const report = await this.detect();
|
||||
let staleRemoved = 0;
|
||||
|
||||
// Remove stale records from services.json
|
||||
if (report.staleRecords.length > 0) {
|
||||
const staleIds = new Set(report.staleRecords.map(r => r.serviceId));
|
||||
await this.servicesStateManager.update(services => {
|
||||
const before = services.length;
|
||||
const cleaned = services.filter(s => !staleIds.has(s.id));
|
||||
staleRemoved = before - cleaned.length;
|
||||
return cleaned;
|
||||
});
|
||||
}
|
||||
|
||||
const unknownFlagged = report.unknownContainers.length;
|
||||
|
||||
this.log.info('drift', 'Auto-fix applied', { staleRemoved, unknownFlagged });
|
||||
|
||||
return { staleRemoved, unknownFlagged };
|
||||
}
|
||||
|
||||
// ─── Polling ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Start periodic drift detection.
|
||||
*
|
||||
* @param {number} [intervalMs=300000] - Polling interval in milliseconds (default 5 min)
|
||||
*/
|
||||
startPolling(intervalMs = 300000) {
|
||||
this.stopPolling();
|
||||
|
||||
this._polling = true;
|
||||
this._pollTimer = setInterval(async () => {
|
||||
try {
|
||||
await this.detect();
|
||||
} catch (err) {
|
||||
this.logError('drift-poll', err);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
this.log.info('drift', 'Polling started', { intervalMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic drift detection.
|
||||
*/
|
||||
stopPolling() {
|
||||
if (this._pollTimer) {
|
||||
clearInterval(this._pollTimer);
|
||||
this._pollTimer = null;
|
||||
}
|
||||
this._polling = false;
|
||||
this.log.info('drift', 'Polling stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether polling is currently active.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isPolling() {
|
||||
return this._polling;
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract mapped host ports from a Docker container info object.
|
||||
*
|
||||
* @param {Object} container - Dockerode container info
|
||||
* @returns {number[]} Array of host port numbers
|
||||
* @private
|
||||
*/
|
||||
_extractContainerPorts(container) {
|
||||
const ports = [];
|
||||
if (!container.Ports) return ports;
|
||||
|
||||
for (const p of container.Ports) {
|
||||
if (p.PublicPort) {
|
||||
ports.push(p.PublicPort);
|
||||
}
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about detected drift.
|
||||
*
|
||||
* @param {DriftReport} report
|
||||
* @returns {Promise<Object>}
|
||||
* @private
|
||||
*/
|
||||
async _sendDriftNotification(report) {
|
||||
if (!this.notification?.send) {
|
||||
return { success: false, reason: 'no-notification-manager' };
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
if (report.missingContainers.length > 0) {
|
||||
parts.push(`Missing containers: ${report.missingContainers.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.unknownContainers.length > 0) {
|
||||
parts.push(`Unknown managed containers: ${report.unknownContainers.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.portMismatch.length > 0) {
|
||||
parts.push(`Port mismatches: ${report.portMismatch.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
if (report.staleRecords.length > 0) {
|
||||
parts.push(`Stale records: ${report.staleRecords.map(c => c.name).join(', ')}`);
|
||||
}
|
||||
|
||||
return this.notification.send('drift-detected', {
|
||||
text: `⚠️ Configuration drift detected:\n${parts.join('\n')}`,
|
||||
report,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { ConfigDriftDetector };
|
||||
@@ -0,0 +1,109 @@
|
||||
[
|
||||
{
|
||||
"id": "router",
|
||||
"name": "Router UI",
|
||||
"logo": "/assets/router.png",
|
||||
"url": "https://router.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "chat",
|
||||
"name": "Chat",
|
||||
"logo": "/assets/chat.png",
|
||||
"url": "https://chat.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "sync",
|
||||
"name": "Syncthing",
|
||||
"logo": "/assets/syncthing.png",
|
||||
"url": "https://sync.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "torrent",
|
||||
"name": "qBittorrent",
|
||||
"logo": "/assets/qBittorrent.png",
|
||||
"url": "https://torrent.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:55.246Z"
|
||||
},
|
||||
{
|
||||
"id": "sonarr",
|
||||
"name": "Sonarr",
|
||||
"logo": "/assets/sonarr.png",
|
||||
"url": "https://sonarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T06:04:56.612Z"
|
||||
},
|
||||
{
|
||||
"id": "radarr",
|
||||
"name": "Radarr",
|
||||
"logo": "/assets/radarr.png",
|
||||
"url": "https://radarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:12.359Z"
|
||||
},
|
||||
{
|
||||
"id": "prowlarr",
|
||||
"name": "Prowlarr",
|
||||
"logo": "/assets/prowlarr.png",
|
||||
"url": "https://prowlarr.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-01-18T08:28:13.739Z"
|
||||
},
|
||||
{
|
||||
"id": "ca",
|
||||
"name": "DashCA",
|
||||
"logo": "/assets/certificate-icon.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "dashca",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-11T11:47:08.383Z",
|
||||
"url": "https://ca.sami"
|
||||
},
|
||||
{
|
||||
"id": "plex",
|
||||
"name": "Plex",
|
||||
"logo": "/assets/plex.png",
|
||||
"containerId": null,
|
||||
"appTemplate": "plex",
|
||||
"tailscaleOnly": false,
|
||||
"deployedAt": "2026-02-12T02:18:36.067Z",
|
||||
"url": "https://plex.sami"
|
||||
},
|
||||
{
|
||||
"id": "requests",
|
||||
"name": "Seerr",
|
||||
"logo": "/assets/seerr.png",
|
||||
"url": "https://requests.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "git",
|
||||
"name": "Gitea",
|
||||
"logo": "/assets/gitea.png",
|
||||
"url": "https://git.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"name": "Sami Files",
|
||||
"logo": "/assets/sami-files.png",
|
||||
"url": "https://files.sami",
|
||||
"ip": "localhost",
|
||||
"tailscaleOnly": false,
|
||||
"containerId": null,
|
||||
"appTemplate": "sami-files",
|
||||
"deployedAt": "2026-06-19T00:00:00.000Z"
|
||||
}
|
||||
]
|
||||
@@ -1,605 +0,0 @@
|
||||
/**
|
||||
* Dependency Manager - Service dependency tracking with ordered restart chains
|
||||
*
|
||||
* Manages directed acyclic graph (DAG) of service dependencies. Services can
|
||||
* declare which other services they depend on, and this manager provides:
|
||||
* - Full dependency graph inspection
|
||||
* - Topological ordering for safe restart chains
|
||||
* - Circular dependency detection
|
||||
* - Health-aware restart with per-service polling
|
||||
*
|
||||
* Dependencies are stored directly on service objects in services.json:
|
||||
* { id, name, ..., dependsOn: ['service-id-1', 'service-id-2'] }
|
||||
*
|
||||
* @module dependency-manager
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/** Maximum seconds to wait for a single container to become healthy after restart */
|
||||
const HEALTH_CHECK_TIMEOUT_MS = 30_000;
|
||||
|
||||
/** Interval between container health polls */
|
||||
const HEALTH_CHECK_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* @typedef {Object} ServiceNode
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {string|null} containerId
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyEdge
|
||||
* @property {string} from - The service that depends
|
||||
* @property {string} to - The service being depended upon
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyGraph
|
||||
* @property {ServiceNode[]} nodes
|
||||
* @property {DependencyEdge[]} edges
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} DependencyStatusEntry
|
||||
* @property {string} serviceId
|
||||
* @property {string} name
|
||||
* @property {boolean} isUp
|
||||
* @property {string} [error]
|
||||
*/
|
||||
|
||||
/**
|
||||
* DependencyManager — tracks service dependencies and orchestrates ordered restarts.
|
||||
*
|
||||
* Events emitted:
|
||||
* - `dependency-restart-start` ({ serviceId, chain: string[] })
|
||||
* - `dependency-restart-progress` ({ serviceId, currentServiceId, index, total })
|
||||
* - `dependency-restart-complete` ({ serviceId, chain: string[], results: Array })
|
||||
* - `dependency-restart-failed` ({ serviceId, failedServiceId, error, chain: string[] })
|
||||
*
|
||||
* @extends EventEmitter
|
||||
*/
|
||||
class DependencyManager extends EventEmitter {
|
||||
/**
|
||||
* @param {Object} ctx - Application context
|
||||
* @param {Object} ctx.servicesStateManager - StateManager for services.json
|
||||
* @param {Object} ctx.docker - Docker context ({ client: Dockerode })
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
/** @private */
|
||||
this.ctx = ctx;
|
||||
/** @private */
|
||||
this._servicesStateManager = ctx.servicesStateManager;
|
||||
/** @private */
|
||||
this._docker = ctx.docker;
|
||||
/** @private */
|
||||
this._notification = ctx.notification;
|
||||
/** @private */
|
||||
this._log = ctx.log || console;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Load all services from the state manager.
|
||||
* @private
|
||||
* @returns {Promise<Object[]>}
|
||||
*/
|
||||
async _loadServices() {
|
||||
const data = await this._servicesStateManager.read();
|
||||
return Array.isArray(data) ? data : (data.services || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single service by ID.
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
async _findService(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.find(s => s.id === serviceId) || null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph queries
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Return the full dependency graph for visualisation.
|
||||
*
|
||||
* @returns {Promise<DependencyGraph>}
|
||||
*/
|
||||
async getDependencyGraph() {
|
||||
const services = await this._loadServices();
|
||||
|
||||
const nodes = services.map(s => ({
|
||||
serviceId: s.id,
|
||||
name: s.name,
|
||||
containerId: s.containerId || null,
|
||||
}));
|
||||
|
||||
const edges = [];
|
||||
for (const service of services) {
|
||||
const deps = service.dependsOn || [];
|
||||
for (const depId of deps) {
|
||||
edges.push({ from: service.id, to: depId });
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the services that depend on the given service (reverse deps).
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services whose `dependsOn` includes `serviceId`.
|
||||
*/
|
||||
async getDependents(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
return services.filter(s => (s.dependsOn || []).includes(serviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the direct dependencies for a service.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<Object[]>} Services that `serviceId` depends on.
|
||||
*/
|
||||
async getDependencies(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) return [];
|
||||
const depIds = service.dependsOn || [];
|
||||
return services.filter(s => depIds.includes(s.id));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Topological sort
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build an adjacency list for the current dependency graph.
|
||||
* Edge direction: service → its dependencies (i.e. what it depends on).
|
||||
*
|
||||
* @private
|
||||
* @param {Object[]} services
|
||||
* @returns {Map<string, string[]>}
|
||||
*/
|
||||
_buildAdjacencyList(services) {
|
||||
const adj = new Map();
|
||||
for (const service of services) {
|
||||
adj.set(service.id, (service.dependsOn || []).slice());
|
||||
}
|
||||
return adj;
|
||||
}
|
||||
|
||||
/**
|
||||
* DFS-based topological sort with cycle detection (white/gray/black coloring).
|
||||
*
|
||||
* Returns services in restart order: dependencies first, dependents last.
|
||||
* The target service is included at the end.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId - Target service (will be last in the result).
|
||||
* @param {Object[]} services - All services.
|
||||
* @param {Map<string, string[]>} adj - Adjacency list (service → deps).
|
||||
* @returns {string[]} Ordered service IDs for restart.
|
||||
* @throws {Error} If a circular dependency is detected.
|
||||
*/
|
||||
_topologicalSort(serviceId, services, adj) {
|
||||
// Collect only the reachable sub-graph from serviceId
|
||||
const visited = new Set();
|
||||
const reachable = new Set();
|
||||
|
||||
const collectReachable = (id) => {
|
||||
if (reachable.has(id)) return;
|
||||
reachable.add(id);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
collectReachable(dep);
|
||||
}
|
||||
};
|
||||
collectReachable(serviceId);
|
||||
|
||||
// DFS topological sort on the reachable sub-graph
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const id of reachable) color.set(id, WHITE);
|
||||
|
||||
const result = [];
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
result.push(id);
|
||||
};
|
||||
|
||||
// Visit the target last so it ends up at the end of the result
|
||||
// Actually, we want deps *first* then the target.
|
||||
// The DFS naturally puts deps before dependents, so starting from
|
||||
// serviceId will place it last (which is correct for restart order).
|
||||
dfs(serviceId);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the topologically ordered restart chain for a service.
|
||||
*
|
||||
* The returned array lists all services that must be restarted,
|
||||
* starting with leaf dependencies and ending with the target service.
|
||||
*
|
||||
* @param {string} serviceId - The service to build the chain for.
|
||||
* @returns {Promise<string[]>} Ordered service IDs.
|
||||
* @throws {Error} If `serviceId` doesn't exist or a circular dependency is found.
|
||||
*/
|
||||
async getOrderedRestartChain(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
const adj = this._buildAdjacencyList(services);
|
||||
return this._topologicalSort(serviceId, services, adj);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a proposed set of dependencies for a service.
|
||||
*
|
||||
* Checks:
|
||||
* - All referenced service IDs exist.
|
||||
* - Adding these dependencies would not create a circular dependency.
|
||||
* - A service cannot depend on itself.
|
||||
*
|
||||
* @param {string} serviceId - The service to set dependencies on.
|
||||
* @param {string[]} dependsOn - Proposed dependency IDs.
|
||||
* @returns {Promise<{ valid: boolean, errors: string[] }>}
|
||||
*/
|
||||
async validateDependencies(serviceId, dependsOn) {
|
||||
const errors = [];
|
||||
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
return { valid: false, errors: ['dependsOn must be an array'] };
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const allIds = new Set(services.map(s => s.id));
|
||||
|
||||
// Service must exist
|
||||
if (!allIds.has(serviceId)) {
|
||||
return { valid: false, errors: [`Service "${serviceId}" not found`] };
|
||||
}
|
||||
|
||||
// Self-dependency
|
||||
if (dependsOn.includes(serviceId)) {
|
||||
errors.push(`Service "${serviceId}" cannot depend on itself`);
|
||||
}
|
||||
|
||||
// Existence check
|
||||
for (const depId of dependsOn) {
|
||||
if (!allIds.has(depId)) {
|
||||
errors.push(`Dependency service "${depId}" does not exist`);
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return { valid: false, errors };
|
||||
}
|
||||
|
||||
// Circular dependency check: temporarily set the proposed dependsOn
|
||||
// and attempt a topological sort.
|
||||
const tempServices = services.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
return { ...s, dependsOn: dependsOn.slice() };
|
||||
}
|
||||
return { ...s };
|
||||
});
|
||||
|
||||
const adj = this._buildAdjacencyList(tempServices);
|
||||
|
||||
// Check every node for cycles with the new edges
|
||||
try {
|
||||
const WHITE = 0, GRAY = 1, BLACK = 2;
|
||||
const color = new Map();
|
||||
for (const s of tempServices) color.set(s.id, WHITE);
|
||||
|
||||
const dfs = (id) => {
|
||||
if (color.get(id) === BLACK) return;
|
||||
if (color.get(id) === GRAY) {
|
||||
throw new Error(`Circular dependency detected involving service "${id}"`);
|
||||
}
|
||||
color.set(id, GRAY);
|
||||
for (const dep of (adj.get(id) || [])) {
|
||||
dfs(dep);
|
||||
}
|
||||
color.set(id, BLACK);
|
||||
};
|
||||
|
||||
for (const s of tempServices) {
|
||||
if (color.get(s.id) === WHITE) {
|
||||
dfs(s.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(err.message);
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Get the current container status for a service and all its transitive dependencies.
|
||||
*
|
||||
* @param {string} serviceId
|
||||
* @returns {Promise<DependencyStatusEntry[]>}
|
||||
* @throws {Error} If `serviceId` doesn't exist.
|
||||
*/
|
||||
async getDependencyStatus(serviceId) {
|
||||
const services = await this._loadServices();
|
||||
const service = services.find(s => s.id === serviceId);
|
||||
if (!service) {
|
||||
throw new Error(`Service "${serviceId}" not found`);
|
||||
}
|
||||
|
||||
// Collect all transitive dependencies via BFS
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
const visited = new Set();
|
||||
const queue = [serviceId];
|
||||
const allRelated = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const currentId = queue.shift();
|
||||
if (visited.has(currentId)) continue;
|
||||
visited.add(currentId);
|
||||
|
||||
const svc = serviceMap.get(currentId);
|
||||
if (!svc) continue;
|
||||
|
||||
allRelated.push(svc);
|
||||
|
||||
for (const depId of (svc.dependsOn || [])) {
|
||||
if (!visited.has(depId)) {
|
||||
queue.push(depId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query container status for each
|
||||
const results = [];
|
||||
for (const svc of allRelated) {
|
||||
const entry = {
|
||||
serviceId: svc.id,
|
||||
name: svc.name,
|
||||
isUp: false,
|
||||
};
|
||||
|
||||
if (!svc.containerId) {
|
||||
entry.error = 'No container associated with this service';
|
||||
results.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
const info = await container.inspect();
|
||||
entry.isUp = info.State?.Running === true;
|
||||
} catch (err) {
|
||||
entry.error = err.message || 'Unable to inspect container';
|
||||
}
|
||||
|
||||
results.push(entry);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Restart with dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Wait for a container to report as running after a restart.
|
||||
*
|
||||
* @private
|
||||
* @param {string} containerId
|
||||
* @param {number} [timeoutMs=30000]
|
||||
* @returns {Promise<boolean>} `true` if healthy, `false` if timed out.
|
||||
*/
|
||||
async _waitForContainerHealthy(containerId, timeoutMs = HEALTH_CHECK_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
try {
|
||||
const container = this._docker.client.getContainer(containerId);
|
||||
const info = await container.inspect();
|
||||
if (info.State?.Running === true) {
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// Container might not be inspectable during restart — keep polling
|
||||
}
|
||||
await new Promise(r => setTimeout(r, HEALTH_CHECK_INTERVAL_MS));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart a service and all its dependencies in topological order.
|
||||
*
|
||||
* Emits progress events and sends a notification on completion/failure.
|
||||
* This method is designed to be called from the route handler and
|
||||
* **does not throw** — errors are reported via events and notifications.
|
||||
*
|
||||
* @param {string} serviceId - Target service to restart (with deps).
|
||||
* @returns {Promise<{ success: boolean, chain: string[], results: Array }>}
|
||||
*/
|
||||
async restartWithDependencies(serviceId) {
|
||||
const service = await this._findService(serviceId);
|
||||
if (!service) {
|
||||
const err = new Error(`Service "${serviceId}" not found`);
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
let chain;
|
||||
try {
|
||||
chain = await this.getOrderedRestartChain(serviceId);
|
||||
} catch (err) {
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: serviceId,
|
||||
error: err.message,
|
||||
chain: [],
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
const services = await this._loadServices();
|
||||
const serviceMap = new Map(services.map(s => [s.id, s]));
|
||||
|
||||
this._log.info('dependency', 'Starting dependency restart chain', {
|
||||
serviceId,
|
||||
chain,
|
||||
});
|
||||
|
||||
this.emit('dependency-restart-start', { serviceId, chain });
|
||||
|
||||
const results = [];
|
||||
const total = chain.length;
|
||||
|
||||
for (let i = 0; i < total; i++) {
|
||||
const currentId = chain[i];
|
||||
const svc = serviceMap.get(currentId);
|
||||
|
||||
this.emit('dependency-restart-progress', {
|
||||
serviceId,
|
||||
currentServiceId: currentId,
|
||||
index: i,
|
||||
total,
|
||||
});
|
||||
|
||||
if (!svc || !svc.containerId) {
|
||||
const msg = !svc
|
||||
? `Service "${currentId}" not found in state`
|
||||
: `Service "${currentId}" has no container — skipping restart`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: false, skipped: true, reason: msg });
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const container = this._docker.client.getContainer(svc.containerId);
|
||||
this._log.info('dependency', `Restarting container for service "${currentId}"`, {
|
||||
containerId: svc.containerId,
|
||||
});
|
||||
await container.restart();
|
||||
|
||||
// Wait for it to come back up
|
||||
const healthy = await this._waitForContainerHealthy(svc.containerId);
|
||||
if (!healthy) {
|
||||
const msg = `Container for service "${currentId}" did not become healthy within ${HEALTH_CHECK_TIMEOUT_MS / 1000}s`;
|
||||
this._log.warn('dependency', msg);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: false, error: msg });
|
||||
|
||||
// Abort chain — dependency didn't come back
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
|
||||
this._log.info('dependency', `Service "${currentId}" is healthy after restart`);
|
||||
results.push({ serviceId: currentId, restarted: true, healthy: true });
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Unknown error during restart';
|
||||
this._log.error('dependency', `Failed to restart service "${currentId}"`, {
|
||||
error: msg,
|
||||
});
|
||||
results.push({ serviceId: currentId, restarted: false, error: msg });
|
||||
|
||||
this.emit('dependency-restart-failed', {
|
||||
serviceId,
|
||||
failedServiceId: currentId,
|
||||
error: msg,
|
||||
chain,
|
||||
});
|
||||
await this._notifyRestartResult(serviceId, false, chain, results, currentId);
|
||||
return { success: false, chain, results };
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('dependency-restart-complete', { serviceId, chain, results });
|
||||
await this._notifyRestartResult(serviceId, true, chain, results);
|
||||
return { success: true, chain, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification about the restart result.
|
||||
*
|
||||
* @private
|
||||
* @param {string} serviceId
|
||||
* @param {boolean} success
|
||||
* @param {string[]} chain
|
||||
* @param {Array} results
|
||||
* @param {string} [failedServiceId]
|
||||
*/
|
||||
async _notifyRestartResult(serviceId, success, chain, results, failedServiceId) {
|
||||
if (!this._notification) return;
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
await this._notification.send('dependency-restart-complete', {
|
||||
text: `✅ Dependency restart chain completed for "${serviceId}". Restarted: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
} else {
|
||||
await this._notification.send('dependency-restart-failed', {
|
||||
text: `❌ Dependency restart chain failed for "${serviceId}" at "${failedServiceId}". Chain: ${chain.join(' → ')}`,
|
||||
serviceId,
|
||||
failedServiceId,
|
||||
chain,
|
||||
results,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this._log.error('dependency', 'Failed to send restart notification', {
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DependencyManager;
|
||||
@@ -1,273 +0,0 @@
|
||||
/**
|
||||
* DNS Propagation Checker
|
||||
* Verifies DNS record propagation by querying multiple resolvers.
|
||||
* Runs as background jobs with configurable timeout and interval.
|
||||
*
|
||||
* @module dns-propagation
|
||||
*/
|
||||
|
||||
const dns = require('dns').promises;
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/** Default verification options */
|
||||
const DEFAULT_OPTIONS = {
|
||||
timeout: 300000, // 5 minutes
|
||||
interval: 10000, // 10 seconds
|
||||
resolvers: ['1.1.1.1', '8.8.8.8', '9.9.9.9']
|
||||
};
|
||||
|
||||
/** Maximum age for stored verification results (1 hour) */
|
||||
const MAX_RESULT_AGE_MS = 3600000;
|
||||
|
||||
class DNSPropagationChecker extends EventEmitter {
|
||||
/**
|
||||
* Create a DNSPropagationChecker instance.
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
|
||||
/** @type {Map<string, Object>} domain → verification status */
|
||||
this.verifications = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that a DNS record has propagated by querying multiple resolvers.
|
||||
* Retries every `interval` ms until `timeout` is reached.
|
||||
*
|
||||
* @param {string} domain - The domain to check (e.g., 'test.sami')
|
||||
* @param {string} expectedIp - The expected IP address
|
||||
* @param {Object} [options={}] - Verification options
|
||||
* @param {number} [options.timeout=300000] - Maximum time to wait (ms)
|
||||
* @param {number} [options.interval=10000] - Time between retries (ms)
|
||||
* @param {string[]} [options.resolvers] - DNS resolvers to query
|
||||
* @returns {Promise<Object>} Verification result
|
||||
*/
|
||||
async verifyRecord(domain, expectedIp, options = {}) {
|
||||
const startTime = Date.now();
|
||||
const {
|
||||
timeout = DEFAULT_OPTIONS.timeout,
|
||||
interval = DEFAULT_OPTIONS.interval,
|
||||
resolvers = DEFAULT_OPTIONS.resolvers
|
||||
} = options;
|
||||
|
||||
const allResults = [];
|
||||
let propagated = false;
|
||||
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const roundResults = [];
|
||||
|
||||
for (const resolver of resolvers) {
|
||||
const checkStart = Date.now();
|
||||
try {
|
||||
// Use dns.resolve4 with a custom resolver
|
||||
const resolverInstance = new dns.Resolver();
|
||||
resolverInstance.setServers([resolver]);
|
||||
resolverInstance.setTimeout(5000);
|
||||
|
||||
const addresses = await resolverInstance.resolve4(domain);
|
||||
const matched = addresses.includes(expectedIp);
|
||||
|
||||
const result = {
|
||||
resolver,
|
||||
ips: addresses,
|
||||
matched,
|
||||
checkedAt: new Date().toISOString(),
|
||||
responseTime: Date.now() - checkStart
|
||||
};
|
||||
|
||||
roundResults.push(result);
|
||||
|
||||
if (matched) {
|
||||
propagated = true;
|
||||
}
|
||||
} catch (err) {
|
||||
roundResults.push({
|
||||
resolver,
|
||||
ips: [],
|
||||
matched: false,
|
||||
checkedAt: new Date().toISOString(),
|
||||
error: err.code || err.message,
|
||||
responseTime: Date.now() - checkStart
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
allResults.push(...roundResults);
|
||||
|
||||
// Emit progress event
|
||||
this.emit('propagation-check', {
|
||||
domain,
|
||||
expectedIp,
|
||||
roundResults,
|
||||
elapsed: Date.now() - startTime,
|
||||
propagated
|
||||
});
|
||||
|
||||
if (propagated) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Wait before next attempt
|
||||
await new Promise(resolve => setTimeout(resolve, interval));
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
domain,
|
||||
expectedIp,
|
||||
propagated,
|
||||
results: allResults,
|
||||
totalTime,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a background DNS propagation verification.
|
||||
* Does not block — returns immediately with the job reference.
|
||||
*
|
||||
* @param {string} domain - The domain to verify
|
||||
* @param {string} expectedIp - The expected IP address
|
||||
* @param {Object} [options={}] - Verification options
|
||||
* @returns {Object} Job status object
|
||||
*/
|
||||
startVerification(domain, expectedIp, options = {}) {
|
||||
// If there's already a running verification for this domain, return it
|
||||
const existing = this.verifications.get(domain);
|
||||
if (existing && existing.status === 'running') {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const job = {
|
||||
domain,
|
||||
expectedIp,
|
||||
status: 'running',
|
||||
startedAt: new Date().toISOString(),
|
||||
progress: [],
|
||||
result: null
|
||||
};
|
||||
|
||||
this.verifications.set(domain, job);
|
||||
|
||||
// Run verification in background (non-blocking)
|
||||
this.verifyRecord(domain, expectedIp, options)
|
||||
.then(result => {
|
||||
job.status = 'completed';
|
||||
job.result = result;
|
||||
job.completedAt = new Date().toISOString();
|
||||
|
||||
if (result.propagated) {
|
||||
this.emit('propagation-complete', result);
|
||||
|
||||
if (this.ctx.notification) {
|
||||
this.ctx.notification.send('dns-propagation', {
|
||||
text: `✅ DNS record for ${domain} propagated successfully to ${expectedIp}`,
|
||||
domain,
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'success').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send propagation notification', {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.emit('propagation-timeout', result);
|
||||
|
||||
if (this.ctx.notification) {
|
||||
this.ctx.notification.send('dns-propagation', {
|
||||
text: `⏱️ DNS propagation timeout for ${domain} — expected ${expectedIp} not found after ${Math.round(result.totalTime / 1000)}s`,
|
||||
domain,
|
||||
expectedIp,
|
||||
totalTime: result.totalTime
|
||||
}, 'warning').catch(err => {
|
||||
this.log.error('dns-propagation', 'Failed to send timeout notification', {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
job.status = 'error';
|
||||
job.error = err.message;
|
||||
job.completedAt = new Date().toISOString();
|
||||
|
||||
this.log.error('dns-propagation', `Verification failed for ${domain}`, {
|
||||
error: err.message
|
||||
});
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current verification status for a domain.
|
||||
*
|
||||
* @param {string} domain - The domain to look up
|
||||
* @returns {Object|null} Verification status or null if not found
|
||||
*/
|
||||
getVerificationStatus(domain) {
|
||||
const job = this.verifications.get(domain);
|
||||
if (!job) return null;
|
||||
return {
|
||||
domain: job.domain,
|
||||
expectedIp: job.expectedIp,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt || null,
|
||||
result: job.result || null,
|
||||
error: job.error || null
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recent verifications.
|
||||
*
|
||||
* @returns {Object[]} Array of verification statuses
|
||||
*/
|
||||
getAllVerifications() {
|
||||
const results = [];
|
||||
for (const [domain, job] of this.verifications.entries()) {
|
||||
results.push({
|
||||
domain,
|
||||
expectedIp: job.expectedIp,
|
||||
status: job.status,
|
||||
startedAt: job.startedAt,
|
||||
completedAt: job.completedAt || null,
|
||||
propagated: job.result?.propagated || null,
|
||||
totalTime: job.result?.totalTime || null,
|
||||
error: job.error || null
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove verifications older than 1 hour.
|
||||
*/
|
||||
cleanup() {
|
||||
const now = Date.now();
|
||||
for (const [domain, job] of this.verifications.entries()) {
|
||||
const completedAt = job.completedAt ? new Date(job.completedAt).getTime() : null;
|
||||
const startedAt = new Date(job.startedAt).getTime();
|
||||
|
||||
// Clean up completed/error jobs older than 1 hour
|
||||
// Also clean up stale running jobs that started over 2 hours ago
|
||||
const age = completedAt ? (now - completedAt) : (now - startedAt);
|
||||
const maxAge = job.status === 'running' ? MAX_RESULT_AGE_MS * 2 : MAX_RESULT_AGE_MS;
|
||||
|
||||
if (age > maxAge) {
|
||||
this.verifications.delete(domain);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DNSPropagationChecker;
|
||||
@@ -9,9 +9,24 @@ const http = require('http');
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const paths = require('./platform-paths');
|
||||
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.json');
|
||||
// Persist health config + history alongside the other state files (services.json,
|
||||
// config.json) rather than next to the source. In a container that data dir is the
|
||||
// mounted /app/data volume, so uptime history survives container recreates/updates;
|
||||
// previously these defaulted to __dirname (unmounted /app) and every recreate wiped
|
||||
// the accumulated history, blanking the dashboard uptime bars. Explicit env vars
|
||||
// still override.
|
||||
const HEALTH_DATA_DIR = process.env.HEALTH_DATA_DIR || path.dirname(paths.configFile);
|
||||
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(HEALTH_DATA_DIR, 'health-config.json');
|
||||
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(HEALTH_DATA_DIR, 'health-history.json');
|
||||
|
||||
// Legacy locations (next to the source) used before the data-dir default. Read these
|
||||
// once on first load if the new files are absent, so upgrading installs migrate their
|
||||
// accumulated history/config instead of starting empty. The next save() rewrites to
|
||||
// the new location.
|
||||
const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
|
||||
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
||||
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
|
||||
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_CONFIG_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
|
||||
: (HEALTH_CONFIG_FILE !== LEGACY_HEALTH_CONFIG_FILE && fs.existsSync(LEGACY_HEALTH_CONFIG_FILE) ? LEGACY_HEALTH_CONFIG_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading config: ${error.message}`);
|
||||
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
|
||||
*/
|
||||
loadHistory() {
|
||||
try {
|
||||
if (fs.existsSync(HEALTH_HISTORY_FILE)) {
|
||||
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8'));
|
||||
const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
|
||||
: (HEALTH_HISTORY_FILE !== LEGACY_HEALTH_HISTORY_FILE && fs.existsSync(LEGACY_HEALTH_HISTORY_FILE) ? LEGACY_HEALTH_HISTORY_FILE : null);
|
||||
if (file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
}
|
||||
} catch (error) {
|
||||
this.emit('log', 'error', `Error loading history: ${error.message}`);
|
||||
|
||||
@@ -304,6 +304,15 @@ module.exports = function configureMiddleware(app, {
|
||||
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
|
||||
{ path: '/api/v1/config', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/services/status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/health-checks/status', exact: true, method: 'GET' },
|
||||
// Read-only update/version info shown on the dashboard view (verification
|
||||
// modal, topbar version, update badges). Mutating actions — update-apply,
|
||||
// rollback (POST) — are NOT listed here and stay TOTP-protected.
|
||||
{ path: '/api/v1/system/version', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-status', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-history', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-check', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/updates/available', exact: true, method: 'GET' },
|
||||
{ path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dashcaddy-api",
|
||||
"version": "1.9.0",
|
||||
"version": "1.7.8",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* Auto-Restart Policy Routes
|
||||
*
|
||||
* CRUD endpoints for per-container auto-restart policies.
|
||||
* Also provides a dry-run test endpoint.
|
||||
*
|
||||
* @module routes/auto-restart
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Auto-restart route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.autoRestartManager - AutoRestartManager instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function ({ autoRestartManager, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /auto-restart/policies
|
||||
* List all configured auto-restart policies.
|
||||
*/
|
||||
router.get('/policies', asyncHandler(async (_req, res) => {
|
||||
const policies = autoRestartManager.listPolicies();
|
||||
success(res, { policies });
|
||||
}, 'auto-restart-list'));
|
||||
|
||||
/**
|
||||
* GET /auto-restart/policies/:serviceId
|
||||
* Get the restart policy for a single service.
|
||||
*/
|
||||
router.get('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const policy = autoRestartManager.getPolicy(serviceId);
|
||||
if (!policy) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, { policy });
|
||||
}, 'auto-restart-get'));
|
||||
|
||||
/**
|
||||
* POST /auto-restart/policies/:serviceId
|
||||
* Create or update a restart policy.
|
||||
*
|
||||
* Body: { enabled, maxRetries, retryIntervalMs, windowMinutes }
|
||||
*/
|
||||
router.post('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const { enabled, maxRetries, retryIntervalMs, windowMinutes } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
throw new ValidationError('enabled must be a boolean');
|
||||
}
|
||||
if (maxRetries !== undefined) {
|
||||
if (!Number.isInteger(maxRetries) || maxRetries < 0 || maxRetries > 100) {
|
||||
throw new ValidationError('maxRetries must be an integer between 0 and 100');
|
||||
}
|
||||
}
|
||||
if (retryIntervalMs !== undefined) {
|
||||
if (!Number.isInteger(retryIntervalMs) || retryIntervalMs < 0 || retryIntervalMs > 3600000) {
|
||||
throw new ValidationError('retryIntervalMs must be an integer between 0 and 3600000');
|
||||
}
|
||||
}
|
||||
if (windowMinutes !== undefined) {
|
||||
if (!Number.isInteger(windowMinutes) || windowMinutes < 0 || windowMinutes > 1440) {
|
||||
throw new ValidationError('windowMinutes must be an integer between 0 and 1440');
|
||||
}
|
||||
}
|
||||
|
||||
const policy = await autoRestartManager.setPolicy(serviceId, {
|
||||
...(enabled !== undefined && { enabled }),
|
||||
...(maxRetries !== undefined && { maxRetries }),
|
||||
...(retryIntervalMs !== undefined && { retryIntervalMs }),
|
||||
...(windowMinutes !== undefined && { windowMinutes }),
|
||||
});
|
||||
|
||||
success(res, { policy, message: `Policy ${serviceId} saved` });
|
||||
}, 'auto-restart-set'));
|
||||
|
||||
/**
|
||||
* DELETE /auto-restart/policies/:serviceId
|
||||
* Remove a restart policy.
|
||||
*/
|
||||
router.delete('/policies/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const removed = await autoRestartManager.removePolicy(serviceId);
|
||||
if (!removed) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, { message: `Policy for "${serviceId}" removed` });
|
||||
}, 'auto-restart-delete'));
|
||||
|
||||
/**
|
||||
* POST /auto-restart/policies/:serviceId/test
|
||||
* Dry-run: simulate a restart attempt without actually restarting.
|
||||
* Returns what *would* happen given the current policy state.
|
||||
*/
|
||||
router.post('/policies/:serviceId/test', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
if (!serviceId || !/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,100}$/.test(serviceId)) {
|
||||
throw new ValidationError('Invalid service ID format');
|
||||
}
|
||||
|
||||
const policy = autoRestartManager.getPolicy(serviceId);
|
||||
if (!policy) {
|
||||
throw new NotFoundError(`Auto-restart policy for "${serviceId}"`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const inCooldown = policy.cooldownUntil && now < policy.cooldownUntil;
|
||||
const wouldRetry = !inCooldown && policy.currentRetries < policy.maxRetries;
|
||||
const nextAttempt = policy.currentRetries + 1;
|
||||
|
||||
success(res, {
|
||||
dryRun: true,
|
||||
serviceId,
|
||||
policy: {
|
||||
enabled: policy.enabled,
|
||||
currentRetries: policy.currentRetries,
|
||||
maxRetries: policy.maxRetries,
|
||||
cooldownUntil: policy.cooldownUntil,
|
||||
inCooldown,
|
||||
},
|
||||
wouldRestart: policy.enabled && wouldRetry,
|
||||
wouldMaxOut: !wouldRetry && !inCooldown,
|
||||
nextAttempt: wouldRetry ? nextAttempt : null,
|
||||
message: !policy.enabled
|
||||
? 'Policy is disabled — no restart would occur'
|
||||
: inCooldown
|
||||
? `In cooldown until ${new Date(policy.cooldownUntil).toISOString()} — would skip`
|
||||
: wouldRetry
|
||||
? `Would attempt restart ${nextAttempt}/${policy.maxRetries}`
|
||||
: `Max retries (${policy.maxRetries}) already reached — would enter cooldown`,
|
||||
});
|
||||
}, 'auto-restart-test'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,9 +1,13 @@
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const fs = require('fs');
|
||||
const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { success } = require('../response-helpers');
|
||||
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||
: 0;
|
||||
|
||||
/**
|
||||
* Backups routes factory
|
||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
runImmediately: backup.runImmediately || false,
|
||||
destination: backup.destination || 'local',
|
||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: backup.maxStorageBytes || null,
|
||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||
@@ -52,8 +57,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
// Create or update a scheduled backup for an app
|
||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
||||
|
||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||
|
||||
if (!appId) {
|
||||
const { ValidationError } = require('../errors');
|
||||
throw new ValidationError('appId is required');
|
||||
@@ -61,7 +66,12 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
if (!config.backups) config.backups = {};
|
||||
|
||||
|
||||
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||
const parsedMaxStorage = maxStorageBytes
|
||||
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||
: null;
|
||||
|
||||
// Build the backup config for this app
|
||||
const backupConfig = {
|
||||
enabled: enabled !== undefined ? enabled : true,
|
||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
destination: destination || 'local',
|
||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||
include: ['all'],
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||
maxStorageBytes: parsedMaxStorage
|
||||
};
|
||||
|
||||
config.backups[appId] = backupConfig;
|
||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
success(res, { history });
|
||||
}, 'backups-history'));
|
||||
|
||||
// Get storage info for backups destination
|
||||
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||
const storageInfo = await getStorageInfo();
|
||||
success(res, storageInfo);
|
||||
}, 'backups-storage-info'));
|
||||
|
||||
// Schedule a backup
|
||||
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||
|
||||
if (!name || !schedule) {
|
||||
return res.status(400).json({ error: 'name and schedule are required' });
|
||||
}
|
||||
|
||||
const config = backupManager.getConfig();
|
||||
|
||||
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||
? maxStorageBytes
|
||||
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||
|
||||
config.backups[name] = {
|
||||
...backupConfig,
|
||||
enabled: true,
|
||||
schedule,
|
||||
maxStorageBytes: maxBytes,
|
||||
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||
};
|
||||
|
||||
backupManager.updateConfig(config);
|
||||
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||
}, 'backups-schedule'));
|
||||
|
||||
// Restore from backup
|
||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||
@@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
||||
*/
|
||||
function calculateNextRun(lastRun, schedule) {
|
||||
if (!lastRun) return null;
|
||||
|
||||
|
||||
const intervals = {
|
||||
'hourly': 60 * 60 * 1000,
|
||||
'daily': 24 * 60 * 60 * 1000,
|
||||
@@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) {
|
||||
};
|
||||
|
||||
const baseInterval = intervals[schedule];
|
||||
|
||||
|
||||
if (baseInterval) {
|
||||
return new Date(lastRun.getTime() + baseInterval);
|
||||
}
|
||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get storage information for the backup directory
|
||||
*/
|
||||
async function getStorageInfo() {
|
||||
const result = {
|
||||
destination: DEFAULT_BACKUP_DIR,
|
||||
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||
usedBytes: 0,
|
||||
availableBytes: 0,
|
||||
usagePercent: 0,
|
||||
backupCount: 0,
|
||||
oldestBackup: null,
|
||||
newestBackup: null
|
||||
};
|
||||
|
||||
try {
|
||||
// Get disk space info
|
||||
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||
result.availableBytes = diskSpace.available;
|
||||
|
||||
// Scan for backup files
|
||||
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||
} else {
|
||||
result.maxStorageBytes = diskSpace.total || 0;
|
||||
}
|
||||
|
||||
let totalSize = 0;
|
||||
let oldestTime = null;
|
||||
let newestTime = null;
|
||||
|
||||
try {
|
||||
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||
for (const entry of entries) {
|
||||
if (entry.endsWith('.backup')) {
|
||||
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||
try {
|
||||
const stats = await fsp.stat(filePath);
|
||||
totalSize += stats.size;
|
||||
result.backupCount++;
|
||||
|
||||
const fileTime = new Date(stats.mtime);
|
||||
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||
} catch (e) {
|
||||
// Skip files we can't stat
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Backup directory might not exist yet
|
||||
}
|
||||
|
||||
result.usedBytes = totalSize;
|
||||
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||
|
||||
// Calculate available (total limit - used), or from disk space if no limit set
|
||||
if (result.maxStorageBytes > 0) {
|
||||
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||
} else if (diskSpace.total) {
|
||||
result.availableBytes = diskSpace.available;
|
||||
result.usagePercent = diskSpace.total > 0
|
||||
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||
: 0;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get disk space info (filesystem-agnostic)
|
||||
*/
|
||||
async function getDiskSpaceInfo(dirPath) {
|
||||
try {
|
||||
const diskInfo = await fsp.statfs(dirPath);
|
||||
return {
|
||||
total: diskInfo.blocks * diskInfo.bsize,
|
||||
available: diskInfo.bfree * diskInfo.bsize,
|
||||
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||
};
|
||||
} catch (error) {
|
||||
// Directory might not exist or be accessible
|
||||
return { total: 0, available: 0, used: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse storage size string like "10GB" to bytes
|
||||
*/
|
||||
function parseStorageSize(sizeStr) {
|
||||
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||
|
||||
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||
if (!match) return 0;
|
||||
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = (match[2] || 'B').toUpperCase();
|
||||
|
||||
const multipliers = {
|
||||
'B': 1,
|
||||
'K': 1024,
|
||||
'KB': 1024,
|
||||
'M': 1024 * 1024,
|
||||
'MB': 1024 * 1024,
|
||||
'G': 1024 * 1024 * 1024,
|
||||
'GB': 1024 * 1024 * 1024,
|
||||
'T': 1024 * 1024 * 1024 * 1024,
|
||||
'TB': 1024 * 1024 * 1024 * 1024
|
||||
};
|
||||
|
||||
return Math.floor(value * (multipliers[unit] || 1));
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* Config Drift Detection Routes
|
||||
*
|
||||
* API endpoints for running drift detection, reading cached reports,
|
||||
* auto-fixing drift, and controlling periodic polling.
|
||||
*
|
||||
* @module routes/config-drift
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success } = require('../response-helpers');
|
||||
const { ValidationError, NotFoundError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Config-drift route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.driftDetector - ConfigDriftDetector instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function ({ driftDetector, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /config-drift/report
|
||||
* Run a fresh drift detection and return the full report.
|
||||
*/
|
||||
router.get('/report', asyncHandler(async (_req, res) => {
|
||||
const report = await driftDetector.detect();
|
||||
success(res, { report });
|
||||
}, 'drift-report'));
|
||||
|
||||
/**
|
||||
* GET /config-drift/last
|
||||
* Return the last cached drift report (no re-detection).
|
||||
*/
|
||||
router.get('/last', asyncHandler(async (_req, res) => {
|
||||
if (!driftDetector.lastReport) {
|
||||
throw new NotFoundError('No cached drift report — run detection first');
|
||||
}
|
||||
|
||||
success(res, { report: driftDetector.lastReport });
|
||||
}, 'drift-last'));
|
||||
|
||||
/**
|
||||
* POST /config-drift/fix
|
||||
* Auto-fix detected drift: remove stale records, flag unknown containers.
|
||||
*/
|
||||
router.post('/fix', asyncHandler(async (_req, res) => {
|
||||
const result = await driftDetector.autoFix();
|
||||
success(res, {
|
||||
message: 'Auto-fix applied',
|
||||
staleRemoved: result.staleRemoved,
|
||||
unknownFlagged: result.unknownFlagged,
|
||||
});
|
||||
}, 'drift-fix'));
|
||||
|
||||
/**
|
||||
* POST /config-drift/polling
|
||||
* Enable or disable periodic drift detection polling.
|
||||
*
|
||||
* Body: { enabled: boolean, intervalMs?: number }
|
||||
*/
|
||||
router.post('/polling', asyncHandler(async (req, res) => {
|
||||
const { enabled, intervalMs } = req.body;
|
||||
|
||||
if (typeof enabled !== 'boolean') {
|
||||
throw new ValidationError('enabled must be a boolean');
|
||||
}
|
||||
|
||||
if (intervalMs !== undefined) {
|
||||
if (!Number.isInteger(intervalMs) || intervalMs < 10000 || intervalMs > 86400000) {
|
||||
throw new ValidationError('intervalMs must be an integer between 10000 and 86400000 (10s – 24h)');
|
||||
}
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
driftDetector.startPolling(intervalMs || 300000);
|
||||
success(res, {
|
||||
message: 'Drift polling enabled',
|
||||
intervalMs: intervalMs || 300000,
|
||||
});
|
||||
} else {
|
||||
driftDetector.stopPolling();
|
||||
success(res, { message: 'Drift polling disabled' });
|
||||
}
|
||||
}, 'drift-polling'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -1,235 +0,0 @@
|
||||
/**
|
||||
* Dependencies Route — REST API for service dependency tracking
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /dependencies/graph Full dependency graph
|
||||
* GET /dependencies/validate Validate a proposed dep chain
|
||||
* GET /dependencies/:serviceId Direct deps for one service
|
||||
* GET /dependencies/:serviceId/chain Ordered restart chain
|
||||
* GET /dependencies/:serviceId/status Dependency health status
|
||||
* POST /dependencies/:serviceId Set dependencies
|
||||
* DELETE /dependencies/:serviceId Remove all dependencies
|
||||
* POST /dependencies/:serviceId/restart Restart with dependency chain
|
||||
*
|
||||
* @module routes/dependencies
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../response-helpers');
|
||||
const { NotFoundError, ValidationError } = require('../errors');
|
||||
|
||||
/**
|
||||
* Dependencies route factory
|
||||
*
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.dependencyManager - DependencyManager instance
|
||||
* @param {Object} deps.servicesStateManager - State manager for services.json
|
||||
* @param {Object} deps.docker - Docker client wrapper
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Function} deps.resyncHealthChecker - Health checker resync function
|
||||
* @param {Object} deps.log - Logger instance
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({
|
||||
dependencyManager,
|
||||
servicesStateManager,
|
||||
docker,
|
||||
asyncHandler,
|
||||
logError,
|
||||
resyncHealthChecker,
|
||||
log,
|
||||
}) {
|
||||
const router = express.Router();
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/graph — Full dependency graph
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/graph', asyncHandler(async (req, res) => {
|
||||
const graph = await dependencyManager.getDependencyGraph();
|
||||
success(res, { graph });
|
||||
}, 'dep-graph'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/validate — Validate a proposed dep chain (query params)
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/validate', asyncHandler(async (req, res) => {
|
||||
const { serviceId, dependsOn } = req.query;
|
||||
|
||||
if (!serviceId) {
|
||||
throw new ValidationError('serviceId query parameter is required');
|
||||
}
|
||||
|
||||
// dependsOn may be a comma-separated string or already an array
|
||||
let parsed;
|
||||
if (Array.isArray(dependsOn)) {
|
||||
parsed = dependsOn;
|
||||
} else if (typeof dependsOn === 'string' && dependsOn.length > 0) {
|
||||
parsed = dependsOn.split(',').map(s => s.trim()).filter(Boolean);
|
||||
} else {
|
||||
parsed = [];
|
||||
}
|
||||
|
||||
const result = await dependencyManager.validateDependencies(serviceId, parsed);
|
||||
success(res, result);
|
||||
}, 'dep-validate'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId — Direct deps for one service
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const dependencies = await dependencyManager.getDependencies(serviceId);
|
||||
const dependents = await dependencyManager.getDependents(serviceId);
|
||||
|
||||
// Read the service's current dependsOn array
|
||||
const services = await servicesStateManager.read();
|
||||
const allServices = Array.isArray(services) ? services : (services.services || []);
|
||||
const service = allServices.find(s => s.id === serviceId);
|
||||
|
||||
if (!service) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
success(res, {
|
||||
serviceId,
|
||||
dependsOn: service.dependsOn || [],
|
||||
dependencies,
|
||||
dependents: dependents.map(d => ({ id: d.id, name: d.name })),
|
||||
});
|
||||
}, 'dep-get'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId/chain — Ordered restart chain
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId/chain', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
||||
success(res, { serviceId, chain });
|
||||
}, 'dep-chain'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GET /dependencies/:serviceId/status — Dependency health status
|
||||
// -------------------------------------------------------------------------
|
||||
router.get('/:serviceId/status', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const statuses = await dependencyManager.getDependencyStatus(serviceId);
|
||||
success(res, { serviceId, statuses });
|
||||
}, 'dep-status'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /dependencies/:serviceId — Set dependencies
|
||||
// -------------------------------------------------------------------------
|
||||
router.post('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const { dependsOn } = req.body;
|
||||
|
||||
if (!Array.isArray(dependsOn)) {
|
||||
throw new ValidationError('Request body must include dependsOn as an array of service IDs');
|
||||
}
|
||||
|
||||
// Validate first
|
||||
const validation = await dependencyManager.validateDependencies(serviceId, dependsOn);
|
||||
if (!validation.valid) {
|
||||
return errorResponse(res, validation.errors.join('; '), 400);
|
||||
}
|
||||
|
||||
// Update the service
|
||||
let found = false;
|
||||
await servicesStateManager.update(services => {
|
||||
const arr = Array.isArray(services) ? services : [];
|
||||
return arr.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
found = true;
|
||||
return { ...s, dependsOn: dependsOn.slice() };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
log.info('dependency', 'Dependencies updated', { serviceId, dependsOn });
|
||||
|
||||
success(res, {
|
||||
message: `Dependencies updated for "${serviceId}"`,
|
||||
serviceId,
|
||||
dependsOn,
|
||||
});
|
||||
}, 'dep-set'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// DELETE /dependencies/:serviceId — Remove all dependencies for a service
|
||||
// -------------------------------------------------------------------------
|
||||
router.delete('/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
let found = false;
|
||||
await servicesStateManager.update(services => {
|
||||
const arr = Array.isArray(services) ? services : [];
|
||||
return arr.map(s => {
|
||||
if (s.id === serviceId) {
|
||||
found = true;
|
||||
const updated = { ...s };
|
||||
delete updated.dependsOn;
|
||||
return updated;
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
log.info('dependency', 'Dependencies removed', { serviceId });
|
||||
|
||||
success(res, {
|
||||
message: `All dependencies removed for "${serviceId}"`,
|
||||
serviceId,
|
||||
});
|
||||
}, 'dep-delete'));
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// POST /dependencies/:serviceId/restart — Restart with dependency chain
|
||||
// -------------------------------------------------------------------------
|
||||
router.post('/:serviceId/restart', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Verify the service exists
|
||||
const services = await servicesStateManager.read();
|
||||
const allServices = Array.isArray(services) ? services : (services.services || []);
|
||||
if (!allServices.find(s => s.id === serviceId)) {
|
||||
throw new NotFoundError(`Service "${serviceId}"`);
|
||||
}
|
||||
|
||||
// Get the chain first for the response (before async restart begins)
|
||||
let chain;
|
||||
try {
|
||||
chain = await dependencyManager.getOrderedRestartChain(serviceId);
|
||||
} catch (err) {
|
||||
return errorResponse(res, err.message, 400);
|
||||
}
|
||||
|
||||
// Respond immediately with the chain order
|
||||
success(res, {
|
||||
message: `Dependency restart initiated for "${serviceId}"`,
|
||||
serviceId,
|
||||
chain,
|
||||
});
|
||||
|
||||
// Run the restart chain asynchronously so the client doesn't block
|
||||
dependencyManager.restartWithDependencies(serviceId).catch(err => {
|
||||
if (log) {
|
||||
log.error('dependency', 'Async dependency restart failed', {
|
||||
serviceId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
}, 'dep-restart'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -26,8 +26,7 @@ module.exports = function({
|
||||
log,
|
||||
safeErrorMessage,
|
||||
fetchT,
|
||||
credentialManager,
|
||||
dnsPropagationChecker
|
||||
credentialManager
|
||||
}) {
|
||||
const router = express.Router();
|
||||
|
||||
@@ -140,14 +139,6 @@ module.exports = function({
|
||||
});
|
||||
|
||||
if (result.status === 'ok') {
|
||||
// Start DNS propagation verification in background
|
||||
if (dnsPropagationChecker && ip) {
|
||||
const fullDomain = domain;
|
||||
dnsPropagationChecker.startVerification(fullDomain, ip).catch(err => {
|
||||
log('DNS propagation check start failed:', err.message);
|
||||
});
|
||||
}
|
||||
|
||||
success(res, { message: `DNS record ${domain} -> ${ip} created` });
|
||||
} else {
|
||||
// Error handled by middleware
|
||||
@@ -650,68 +641,5 @@ module.exports = function({
|
||||
}
|
||||
}, 'dns-update'));
|
||||
|
||||
// ===== DNS PROPAGATION =====
|
||||
|
||||
// GET /propagation — Get all recent DNS propagation checks
|
||||
router.get('/propagation', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return success(res, { verifications: [], message: 'DNS propagation checker not available' });
|
||||
}
|
||||
|
||||
// Cleanup old entries
|
||||
dnsPropagationChecker.cleanup();
|
||||
|
||||
const verifications = dnsPropagationChecker.getAllVerifications();
|
||||
success(res, { verifications });
|
||||
}, 'dns-propagation-all'));
|
||||
|
||||
// POST /propagation/verify — Manually trigger DNS propagation verification
|
||||
router.post('/propagation/verify', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return errorResponse(res, 'DNS propagation checker not available', 503);
|
||||
}
|
||||
|
||||
const { domain, expectedIp } = req.body;
|
||||
|
||||
if (!domain || !expectedIp) {
|
||||
throw new ValidationError('domain and expectedIp are required');
|
||||
}
|
||||
|
||||
// Validate domain format
|
||||
if (!REGEX.DOMAIN.test(domain)) {
|
||||
throw new ValidationError('[DC-301] Invalid domain format');
|
||||
}
|
||||
|
||||
// Validate IP address
|
||||
const validatorLib = require('validator');
|
||||
if (!validatorLib.isIP(expectedIp)) {
|
||||
throw new ValidationError('[DC-210] Invalid IP address');
|
||||
}
|
||||
|
||||
const job = dnsPropagationChecker.startVerification(domain, expectedIp);
|
||||
success(res, {
|
||||
message: 'DNS propagation verification started',
|
||||
domain,
|
||||
expectedIp,
|
||||
status: job.status
|
||||
});
|
||||
}, 'dns-propagation-verify'));
|
||||
|
||||
// GET /propagation/:domain — Get propagation status for a specific domain
|
||||
router.get('/propagation/:domain', asyncHandler(async (req, res) => {
|
||||
if (!dnsPropagationChecker) {
|
||||
return success(res, { verification: null, message: 'DNS propagation checker not available' });
|
||||
}
|
||||
|
||||
const { domain } = req.params;
|
||||
const status = dnsPropagationChecker.getVerificationStatus(domain);
|
||||
|
||||
if (!status) {
|
||||
throw new NotFoundError(`No propagation check found for domain: ${domain}`);
|
||||
}
|
||||
|
||||
success(res, { verification: status });
|
||||
}, 'dns-propagation-domain'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -8,10 +8,9 @@ const express = require('express');
|
||||
* @param {Object} deps.healthChecker - Health checker
|
||||
* @param {Object} deps.updateManager - Update manager
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @param {Object} deps.dependencyManager - Dependency manager for restart chain events
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError, dependencyManager, autoRestartManager, driftDetector, sslMonitor, dnsPropagationChecker }) {
|
||||
module.exports = function({ resourceMonitor, healthChecker, updateManager, logError }) {
|
||||
const router = express.Router();
|
||||
const clients = new Set();
|
||||
|
||||
@@ -75,48 +74,6 @@ module.exports = function({ resourceMonitor, healthChecker, updateManager, logEr
|
||||
});
|
||||
}
|
||||
|
||||
// Dependency manager events
|
||||
if (dependencyManager) {
|
||||
dependencyManager.on('dependency-restart-start', (data) => {
|
||||
broadcast('dependency-restart-start', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-progress', (data) => {
|
||||
broadcast('dependency-restart-progress', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-complete', (data) => {
|
||||
broadcast('dependency-restart-complete', data);
|
||||
});
|
||||
dependencyManager.on('dependency-restart-failed', (data) => {
|
||||
broadcast('dependency-restart-failed', data);
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-restart manager events
|
||||
if (autoRestartManager) {
|
||||
autoRestartManager.on('auto-restart-attempt', (data) => broadcast('auto-restart-attempt', data));
|
||||
autoRestartManager.on('auto-restart-success', (data) => broadcast('auto-restart-success', data));
|
||||
autoRestartManager.on('auto-restart-failed', (data) => broadcast('auto-restart-failed', data));
|
||||
autoRestartManager.on('auto-restart-max-reached', (data) => broadcast('auto-restart-max-reached', data));
|
||||
}
|
||||
|
||||
// Config drift detector events
|
||||
if (driftDetector) {
|
||||
driftDetector.on('drift-detected', (data) => broadcast('drift-detected', data));
|
||||
}
|
||||
|
||||
// SSL monitor events
|
||||
if (sslMonitor) {
|
||||
sslMonitor.on('cert-expiring', (data) => broadcast('cert-expiring', data));
|
||||
sslMonitor.on('cert-critical', (data) => broadcast('cert-critical', data));
|
||||
}
|
||||
|
||||
// DNS propagation checker events
|
||||
if (dnsPropagationChecker) {
|
||||
dnsPropagationChecker.on('propagation-check', (data) => broadcast('dns-propagation-check', data));
|
||||
dnsPropagationChecker.on('propagation-complete', (data) => broadcast('dns-propagation-complete', data));
|
||||
dnsPropagationChecker.on('propagation-timeout', (data) => broadcast('dns-propagation-timeout', data));
|
||||
}
|
||||
|
||||
// SSE endpoint
|
||||
router.get('/stream', (req, res) => {
|
||||
res.writeHead(200, {
|
||||
|
||||
@@ -372,7 +372,7 @@ module.exports = function({
|
||||
// Add a new service
|
||||
router.post('/services', asyncHandler(async (req, res) => {
|
||||
try {
|
||||
const { id, name, logo, category, containerId, port, ip, tailscaleOnly } = req.body;
|
||||
const { id, name, logo } = req.body;
|
||||
|
||||
if (!id || !name) {
|
||||
throw new ValidationError('id and name are required');
|
||||
@@ -391,14 +391,7 @@ module.exports = function({
|
||||
throw new ConflictError(`Service "${id}" already exists`, id);
|
||||
}
|
||||
|
||||
const newService = { id, name, logo: logo || `/assets/${id}.png` };
|
||||
// Persist optional metadata fields if provided
|
||||
if (category) newService.category = category;
|
||||
if (containerId) newService.containerId = containerId;
|
||||
if (port) newService.port = port;
|
||||
if (ip) newService.ip = ip;
|
||||
if (typeof tailscaleOnly === 'boolean') newService.tailscaleOnly = tailscaleOnly;
|
||||
services.push(newService);
|
||||
services.push({ id, name, logo: logo || `/assets/${id}.png` });
|
||||
return services;
|
||||
});
|
||||
|
||||
@@ -549,8 +542,6 @@ module.exports = function({
|
||||
};
|
||||
if (name) services[serviceIndex].name = name;
|
||||
if (logo) services[serviceIndex].logo = logo;
|
||||
// Allow category update via update endpoint too (optional body field)
|
||||
if (req.body.category !== undefined) services[serviceIndex].category = req.body.category || undefined;
|
||||
results.services = 'updated';
|
||||
} else {
|
||||
results.services = 'not found';
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
/**
|
||||
* SSL Monitor Routes
|
||||
* REST API endpoints for SSL certificate monitoring.
|
||||
*
|
||||
* @module routes/ssl-monitor
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse, notFound } = require('../response-helpers');
|
||||
|
||||
/**
|
||||
* SSL Monitor route factory
|
||||
* @param {Object} deps - Explicit dependencies
|
||||
* @param {Object} deps.sslMonitor - SSLMonitor instance
|
||||
* @param {Function} deps.asyncHandler - Async route handler wrapper
|
||||
* @param {Function} deps.logError - Error logging function
|
||||
* @returns {express.Router}
|
||||
*/
|
||||
module.exports = function({ sslMonitor, asyncHandler, logError }) {
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /ssl/certificates
|
||||
* Get all SSL certificate statuses
|
||||
*/
|
||||
router.get('/certificates', asyncHandler(async (req, res) => {
|
||||
const status = sslMonitor.getStatus();
|
||||
success(res, { certificates: status });
|
||||
}, 'ssl-certificates'));
|
||||
|
||||
/**
|
||||
* GET /ssl/certificates/:serviceId
|
||||
* Get SSL certificate status for a specific service
|
||||
*/
|
||||
router.get('/certificates/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
const certStatus = sslMonitor.getServiceCertStatus(serviceId);
|
||||
|
||||
if (!certStatus) {
|
||||
return notFound(res, `No SSL certificate status found for service: ${serviceId}`);
|
||||
}
|
||||
|
||||
success(res, { certificate: certStatus });
|
||||
}, 'ssl-certificate-service'));
|
||||
|
||||
/**
|
||||
* POST /ssl/check
|
||||
* Trigger an on-demand check of all SSL certificates
|
||||
*/
|
||||
router.post('/check', asyncHandler(async (req, res) => {
|
||||
const results = await sslMonitor.checkAll();
|
||||
success(res, { certificates: results, message: 'SSL check completed' });
|
||||
}, 'ssl-check-all'));
|
||||
|
||||
/**
|
||||
* POST /ssl/check/:serviceId
|
||||
* Check the SSL certificate for a specific service
|
||||
*/
|
||||
router.post('/check/:serviceId', asyncHandler(async (req, res) => {
|
||||
const { serviceId } = req.params;
|
||||
|
||||
// Look up the existing cert status to find the hostname
|
||||
const existingCert = sslMonitor.getServiceCertStatus(serviceId);
|
||||
if (!existingCert) {
|
||||
return notFound(res, `No HTTPS URL found for service: ${serviceId}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sslMonitor.checkCert(existingCert.hostname, existingCert.port);
|
||||
success(res, { certificate: { ...result, serviceId } });
|
||||
} catch (err) {
|
||||
errorResponse(res, `Failed to check SSL certificate: ${err.message}`, 500);
|
||||
}
|
||||
}, 'ssl-check-service'));
|
||||
|
||||
/**
|
||||
* GET /ssl/config
|
||||
* Get current SSL monitoring configuration
|
||||
*/
|
||||
router.get('/config', asyncHandler(async (req, res) => {
|
||||
const config = sslMonitor.getConfig();
|
||||
success(res, { config });
|
||||
}, 'ssl-config-get'));
|
||||
|
||||
/**
|
||||
* POST /ssl/config
|
||||
* Update SSL monitoring configuration
|
||||
* Body: { enabled: boolean, intervalMs: number }
|
||||
*/
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { enabled, intervalMs } = req.body;
|
||||
|
||||
// Validate inputs
|
||||
if (enabled !== undefined && typeof enabled !== 'boolean') {
|
||||
return errorResponse(res, 'enabled must be a boolean', 400);
|
||||
}
|
||||
if (intervalMs !== undefined) {
|
||||
if (typeof intervalMs !== 'number' || intervalMs < 60000) {
|
||||
return errorResponse(res, 'intervalMs must be a number >= 60000 (1 minute)', 400);
|
||||
}
|
||||
}
|
||||
|
||||
const updates = {};
|
||||
if (enabled !== undefined) updates.enabled = enabled;
|
||||
if (intervalMs !== undefined) updates.intervalMs = intervalMs;
|
||||
|
||||
sslMonitor.updateConfig(updates);
|
||||
const config = sslMonitor.getConfig();
|
||||
success(res, { config, message: 'SSL monitoring config updated' });
|
||||
}, 'ssl-config-update'));
|
||||
|
||||
return router;
|
||||
};
|
||||
Regular → Executable
+6
-1
@@ -134,11 +134,16 @@ restart_container() {
|
||||
# Stop and remove existing container so new env var is applied
|
||||
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
|
||||
|
||||
# Re-create with same volumes and the SERVICES_FILE env var
|
||||
# Re-create with same volumes. CRITICAL: must include CREDENTIALS_FILE +
|
||||
# ENCRYPTION_KEY_FILE pointing at /app/data/ so the container reads the TOTP
|
||||
# secret from the bind-mounted host data dir (not image-local /app/credentials.json
|
||||
# which gets a fresh encryption key on every container recreate = TOTP breaks).
|
||||
docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
|
||||
-p 127.0.0.1:3001:3001 \
|
||||
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CREDENTIALS_FILE=/app/d...son \
|
||||
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
|
||||
"$image"
|
||||
log "Container restarted with fresh env"
|
||||
}
|
||||
|
||||
+17
-74
@@ -77,15 +77,6 @@ const themesRoutes = require('../routes/themes');
|
||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||
const eventsRoutes = require('../routes/events');
|
||||
const workflowsRoutes = require('../routes/workflows');
|
||||
const dependenciesRoutes = require('../routes/dependencies');
|
||||
const DependencyManager = require('../dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||
const { AutoRestartManager } = require('../auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('../config-drift-detector');
|
||||
const { SSLMonitor } = require('../ssl-monitor');
|
||||
const { DNSPropagationChecker } = require('../dns-propagation');
|
||||
|
||||
// Constants
|
||||
const { APP } = require('../constants');
|
||||
@@ -173,6 +164,21 @@ async function createApp() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Back-compat: reverse-proxy SSO snippets (Caddy forward_auth + per-service
|
||||
// auto-login pages) historically call these endpoints under the pre-1.5.0
|
||||
// prefix `/api/auth/...`. The canonical mount is `/api/v1`. Hand-maintained
|
||||
// Caddyfiles have repeatedly drifted back to the old prefix and 404'd the SSO
|
||||
// gate (breaking Plex/Jellyfin/Emby/chat). Transparently rewrite ONLY these two
|
||||
// auth paths to the v1 mount so the gate is tolerant of that drift. Must run
|
||||
// before configureMiddleware() so CSRF/auth see the canonical path. This is
|
||||
// deliberately narrow — NOT a general `/api` -> `/api/v1` alias.
|
||||
app.use((req, res, next) => {
|
||||
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/auth/app-token/')) {
|
||||
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Configure middleware
|
||||
const middlewareResult = configureMiddleware(app, {
|
||||
siteConfig: config.siteConfig,
|
||||
@@ -344,39 +350,6 @@ async function createApp() {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize dependency manager
|
||||
const dependencyManager = new DependencyManager({
|
||||
servicesStateManager,
|
||||
docker: ctx.docker,
|
||||
notification: ctx.notification,
|
||||
log,
|
||||
});
|
||||
ctx.dependencyManager = dependencyManager;
|
||||
log.info('app', 'Dependency manager initialized');
|
||||
|
||||
// Initialize auto-restart manager
|
||||
const autoRestartManager = new AutoRestartManager(ctx);
|
||||
ctx.autoRestartManager = autoRestartManager;
|
||||
autoRestartManager.start();
|
||||
log.info('app', 'Auto-restart manager initialized');
|
||||
|
||||
// Initialize config drift detector
|
||||
const driftDetector = new ConfigDriftDetector(ctx);
|
||||
ctx.driftDetector = driftDetector;
|
||||
driftDetector.startPolling(300000); // 5 min
|
||||
log.info('app', 'Config drift detector initialized');
|
||||
|
||||
// Initialize SSL monitor
|
||||
const sslMonitor = new SSLMonitor(ctx);
|
||||
ctx.sslMonitor = sslMonitor;
|
||||
sslMonitor.start(3600000); // 1 hour
|
||||
log.info('app', 'SSL monitor initialized');
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
log.info('app', 'DNS propagation checker initialized');
|
||||
|
||||
// Build versioned API router
|
||||
const apiRouter = express.Router();
|
||||
|
||||
@@ -417,8 +390,7 @@ async function createApp() {
|
||||
log: ctx.log,
|
||||
safeErrorMessage: ctx.safeErrorMessage,
|
||||
fetchT: ctx.fetchT,
|
||||
credentialManager: ctx.credentialManager,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||
credentialManager: ctx.credentialManager
|
||||
}));
|
||||
apiRouter.use('/notifications', notificationRoutes({
|
||||
notification: ctx.notification,
|
||||
@@ -532,42 +504,13 @@ async function createApp() {
|
||||
resourceMonitor: ctx.resourceMonitor,
|
||||
healthChecker: ctx.healthChecker,
|
||||
updateManager: ctx.updateManager,
|
||||
logError: ctx.logError,
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
driftDetector: ctx.driftDetector,
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
dnsPropagationChecker: ctx.dnsPropagationChecker
|
||||
logError: ctx.logError
|
||||
}));
|
||||
apiRouter.use(workflowsRoutes({
|
||||
workflowEngine: ctx.workflowEngine,
|
||||
licenseManager: ctx.licenseManager,
|
||||
asyncHandler: ctx.asyncHandler
|
||||
}));
|
||||
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||
dependencyManager: ctx.dependencyManager,
|
||||
servicesStateManager: ctx.servicesStateManager,
|
||||
docker: ctx.docker,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
resyncHealthChecker: ctx.resyncHealthChecker,
|
||||
log: ctx.log,
|
||||
}));
|
||||
apiRouter.use(autoRestartRoutes({
|
||||
autoRestartManager: ctx.autoRestartManager,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(configDriftRoutes({
|
||||
driftDetector: ctx.driftDetector,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use(sslMonitorRoutes({
|
||||
sslMonitor: ctx.sslMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
|
||||
// Inline API routes
|
||||
apiRouter.get('/health', (req, res) => {
|
||||
|
||||
@@ -1,411 +0,0 @@
|
||||
/**
|
||||
* SSL Certificate Monitor
|
||||
* Periodically checks SSL certificates on services with HTTPS URLs.
|
||||
* Alerts at 30, 14, and 7 days before expiry.
|
||||
*
|
||||
* @module ssl-monitor
|
||||
*/
|
||||
|
||||
const tls = require('tls');
|
||||
const EventEmitter = require('events');
|
||||
const path = require('path');
|
||||
const { readJsonFile, writeJsonFile } = require('./fs-helpers');
|
||||
const { resolveServiceUrl } = require('./url-resolver');
|
||||
|
||||
/** Default check interval: 1 hour */
|
||||
const DEFAULT_INTERVAL_MS = 3600000;
|
||||
|
||||
/** Alert thresholds in days */
|
||||
const THRESHOLDS = {
|
||||
WARNING: 30,
|
||||
URGENT: 14,
|
||||
CRITICAL: 7
|
||||
};
|
||||
|
||||
/** TLS connection timeout in milliseconds */
|
||||
const TLS_TIMEOUT_MS = 10000;
|
||||
|
||||
class SSLMonitor extends EventEmitter {
|
||||
/**
|
||||
* Create an SSLMonitor instance.
|
||||
* @param {Object} ctx - Shared application context
|
||||
* @param {Object} ctx.servicesStateManager - State manager for reading services
|
||||
* @param {Function} ctx.buildServiceUrl - URL builder helper
|
||||
* @param {Object} ctx.siteConfig - Site configuration
|
||||
* @param {Object} ctx.notification - NotificationManager instance
|
||||
* @param {Object} ctx.log - Logger instance
|
||||
* @param {string} [ctx.SSL_CACHE_FILE] - Path to persist SSL cache
|
||||
*/
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.log = ctx.log || console;
|
||||
|
||||
/** @type {Map<string, Object>} hostname → last cert check result */
|
||||
this.certStatus = new Map();
|
||||
|
||||
/** @type {Map<string, number>} hostname → last notified threshold level */
|
||||
this.notifiedThresholds = new Map();
|
||||
|
||||
/** @type {Map<string, string>} hostname → service ID mapping */
|
||||
this.hostnameToServiceId = new Map();
|
||||
|
||||
/** @type {NodeJS.Timeout|null} */
|
||||
this.intervalHandle = null;
|
||||
|
||||
/** Current config */
|
||||
this.config = {
|
||||
enabled: true,
|
||||
intervalMs: DEFAULT_INTERVAL_MS
|
||||
};
|
||||
|
||||
/** Cache file path */
|
||||
this.cacheFile = ctx.SSL_CACHE_FILE ||
|
||||
path.join(path.dirname(ctx.SERVICES_FILE || './data'), 'ssl-cache.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the SSL certificate for a given hostname and port.
|
||||
* Connects via TLS with rejectUnauthorized: false to retrieve certificate info.
|
||||
*
|
||||
* @param {string} hostname - The hostname to check
|
||||
* @param {number} [port=443] - The port to connect to
|
||||
* @returns {Promise<Object>} Certificate information
|
||||
*/
|
||||
async checkCert(hostname, port = 443) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = tls.connect({
|
||||
host: hostname,
|
||||
port,
|
||||
rejectUnauthorized: false,
|
||||
servername: hostname,
|
||||
timeout: TLS_TIMEOUT_MS
|
||||
}, () => {
|
||||
try {
|
||||
const cert = socket.getPeerCertificate();
|
||||
|
||||
if (!cert || Object.keys(cert).length === 0) {
|
||||
socket.destroy();
|
||||
return reject(new Error(`No certificate returned for ${hostname}:${port}`));
|
||||
}
|
||||
|
||||
const validFrom = new Date(cert.valid_from);
|
||||
const validTo = new Date(cert.valid_to);
|
||||
const now = new Date();
|
||||
const msRemaining = validTo.getTime() - now.getTime();
|
||||
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
|
||||
|
||||
const result = {
|
||||
hostname,
|
||||
port,
|
||||
subject: cert.subject?.CN || cert.subject?.O || 'Unknown',
|
||||
issuer: cert.issuer?.CN || cert.issuer?.O || 'Unknown',
|
||||
validFrom: cert.valid_from,
|
||||
validTo: cert.valid_to,
|
||||
daysRemaining,
|
||||
fingerprint: cert.fingerprint || null,
|
||||
isExpiring: daysRemaining <= THRESHOLDS.WARNING,
|
||||
checkedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
socket.destroy();
|
||||
resolve(result);
|
||||
} catch (err) {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
reject(new Error(`TLS connect error for ${hostname}:${port}: ${err.message}`));
|
||||
});
|
||||
|
||||
socket.setTimeout(TLS_TIMEOUT_MS, () => {
|
||||
socket.destroy(new Error(`TLS connection timeout for ${hostname}:${port}`));
|
||||
reject(new Error(`TLS connection timeout for ${hostname}:${port}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check SSL certificates for all services that have HTTPS URLs.
|
||||
* Reads services from ctx.servicesStateManager, resolves URLs, and checks each HTTPS cert.
|
||||
*
|
||||
* @returns {Promise<Object>} Map of hostname → cert status
|
||||
*/
|
||||
async checkAll() {
|
||||
if (!this.config.enabled) {
|
||||
this.log.info('ssl-monitor', 'SSL monitoring is disabled, skipping check');
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
let servicesData;
|
||||
try {
|
||||
servicesData = await this.ctx.servicesStateManager.read();
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to read services', { error: err.message });
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
const services = Array.isArray(servicesData) ? servicesData : (servicesData.services || []);
|
||||
|
||||
for (const service of services) {
|
||||
const serviceId = service.id || service.name?.toLowerCase();
|
||||
if (!serviceId) continue;
|
||||
|
||||
try {
|
||||
const url = resolveServiceUrl(serviceId, service, this.ctx.siteConfig, this.ctx.buildServiceUrl);
|
||||
if (!url) continue;
|
||||
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== 'https:') continue;
|
||||
|
||||
const hostname = parsed.hostname;
|
||||
const port = parseInt(parsed.port) || 443;
|
||||
|
||||
// Map hostname back to service ID
|
||||
this.hostnameToServiceId.set(hostname, serviceId);
|
||||
|
||||
const result = await this.checkCert(hostname, port);
|
||||
|
||||
// Store result
|
||||
this.certStatus.set(hostname, result);
|
||||
|
||||
// Emit check event
|
||||
this.emit('cert-check', { serviceId, hostname, result });
|
||||
|
||||
// Check alert thresholds
|
||||
await this._checkAndNotify(hostname, result, serviceId);
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', `Failed to check cert for service ${serviceId}`, {
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Persist results
|
||||
await this._saveCache();
|
||||
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic SSL certificate checking.
|
||||
*
|
||||
* @param {number} [intervalMs=3600000] - Check interval in milliseconds
|
||||
*/
|
||||
start(intervalMs) {
|
||||
if (intervalMs !== undefined) {
|
||||
this.config.intervalMs = intervalMs;
|
||||
}
|
||||
if (this.intervalHandle) {
|
||||
this.log.warn('ssl-monitor', 'SSL monitor is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
this.config.enabled = true;
|
||||
|
||||
// Load cached data
|
||||
this._loadCache().catch(err => {
|
||||
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
|
||||
});
|
||||
|
||||
// Initial check (non-blocking)
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Initial SSL check failed', { error: err.message });
|
||||
});
|
||||
|
||||
// Schedule periodic checks
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
|
||||
this.log.info('ssl-monitor', 'SSL monitoring started', {
|
||||
intervalMs: this.config.intervalMs
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop periodic SSL certificate checking.
|
||||
*/
|
||||
stop() {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
this.config.enabled = false;
|
||||
this.log.info('ssl-monitor', 'SSL monitoring stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current SSL certificate status for all checked hostnames.
|
||||
*
|
||||
* @returns {Object} Map of hostname → cert status
|
||||
*/
|
||||
getStatus() {
|
||||
const status = {};
|
||||
for (const [hostname, cert] of this.certStatus.entries()) {
|
||||
status[hostname] = { ...cert };
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SSL certificate status for a specific service.
|
||||
*
|
||||
* @param {string} serviceId - The service ID to look up
|
||||
* @returns {Object|null} Certificate status or null if not found
|
||||
*/
|
||||
getServiceCertStatus(serviceId) {
|
||||
// Find hostname mapped to this service
|
||||
for (const [hostname, id] of this.hostnameToServiceId.entries()) {
|
||||
if (id === serviceId) {
|
||||
const cert = this.certStatus.get(hostname);
|
||||
return cert ? { ...cert, serviceId } : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current monitoring configuration.
|
||||
*
|
||||
* @returns {Object} Config with interval and enabled state
|
||||
*/
|
||||
getConfig() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update monitoring configuration.
|
||||
*
|
||||
* @param {Object} updates - Config updates
|
||||
* @param {boolean} [updates.enabled] - Enable/disable monitoring
|
||||
* @param {number} [updates.intervalMs] - Check interval in milliseconds
|
||||
*/
|
||||
updateConfig(updates) {
|
||||
if (typeof updates.enabled === 'boolean') {
|
||||
this.config.enabled = updates.enabled;
|
||||
if (!updates.enabled && this.intervalHandle) {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
if (typeof updates.intervalMs === 'number' && updates.intervalMs >= 60000) {
|
||||
this.config.intervalMs = updates.intervalMs;
|
||||
// Restart interval if running
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.checkAll().catch(err => {
|
||||
this.log.error('ssl-monitor', 'Periodic SSL check failed', { error: err.message });
|
||||
});
|
||||
}, this.config.intervalMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Private Methods =====
|
||||
|
||||
/**
|
||||
* Check alert thresholds and send notifications if thresholds are crossed.
|
||||
* Only sends one notification per threshold per hostname.
|
||||
*
|
||||
* @param {string} hostname
|
||||
* @param {Object} certResult
|
||||
* @param {string} serviceId
|
||||
*/
|
||||
async _checkAndNotify(hostname, certResult, serviceId) {
|
||||
const { daysRemaining } = certResult;
|
||||
const key = hostname;
|
||||
const lastNotified = this.notifiedThresholds.get(key) || Infinity;
|
||||
|
||||
let level = null;
|
||||
let eventType = null;
|
||||
let message = null;
|
||||
|
||||
if (daysRemaining <= THRESHOLDS.CRITICAL) {
|
||||
level = THRESHOLDS.CRITICAL;
|
||||
eventType = 'cert-critical';
|
||||
message = `🔒 CRITICAL: SSL certificate for ${hostname} expires in ${daysRemaining} days!`;
|
||||
} else if (daysRemaining <= THRESHOLDS.URGENT) {
|
||||
level = THRESHOLDS.URGENT;
|
||||
eventType = 'cert-expiring';
|
||||
message = `⚠️ URGENT: SSL certificate for ${hostname} expires in ${daysRemaining} days`;
|
||||
} else if (daysRemaining <= THRESHOLDS.WARNING) {
|
||||
level = THRESHOLDS.WARNING;
|
||||
eventType = 'cert-expiring';
|
||||
message = `⚠️ SSL certificate for ${hostname} expires in ${daysRemaining} days`;
|
||||
}
|
||||
|
||||
if (level !== null && level < lastNotified) {
|
||||
// New threshold crossed — send notification
|
||||
this.notifiedThresholds.set(key, level);
|
||||
this.emit(eventType, { hostname, serviceId, daysRemaining, level });
|
||||
|
||||
if (this.ctx.notification) {
|
||||
try {
|
||||
await this.ctx.notification.send('ssl-cert-expiry', {
|
||||
text: message,
|
||||
hostname,
|
||||
serviceId,
|
||||
daysRemaining,
|
||||
level,
|
||||
validTo: certResult.validTo
|
||||
}, level <= THRESHOLDS.CRITICAL ? 'error' : 'warning');
|
||||
} catch (err) {
|
||||
this.log.error('ssl-monitor', 'Failed to send SSL notification', { error: err.message });
|
||||
}
|
||||
}
|
||||
} else if (level === null) {
|
||||
// Cert is healthy — reset notification tracking
|
||||
this.notifiedThresholds.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist cert status cache to disk.
|
||||
*/
|
||||
async _saveCache() {
|
||||
try {
|
||||
const data = {
|
||||
lastChecked: new Date().toISOString(),
|
||||
certs: {},
|
||||
hostnameToServiceId: Object.fromEntries(this.hostnameToServiceId)
|
||||
};
|
||||
for (const [hostname, cert] of this.certStatus.entries()) {
|
||||
data.certs[hostname] = cert;
|
||||
}
|
||||
await writeJsonFile(this.cacheFile, data);
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', 'Failed to save SSL cache', { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load cert status cache from disk.
|
||||
*/
|
||||
async _loadCache() {
|
||||
try {
|
||||
const data = await readJsonFile(this.cacheFile, null);
|
||||
if (data && data.certs) {
|
||||
for (const [hostname, cert] of Object.entries(data.certs)) {
|
||||
this.certStatus.set(hostname, cert);
|
||||
}
|
||||
if (data.hostnameToServiceId) {
|
||||
for (const [hostname, serviceId] of Object.entries(data.hostnameToServiceId)) {
|
||||
this.hostnameToServiceId.set(hostname, serviceId);
|
||||
}
|
||||
}
|
||||
this.log.info('ssl-monitor', 'Loaded SSL cache', {
|
||||
certCount: this.certStatus.size
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
this.log.warn('ssl-monitor', 'Failed to load SSL cache', { error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SSLMonitor;
|
||||
@@ -155,16 +155,47 @@
|
||||
return b.toFixed(1) + ' ' + units[i];
|
||||
}
|
||||
|
||||
function setServicesCard() {
|
||||
const total = (window.APPS || []).length;
|
||||
let up = 0;
|
||||
document.querySelectorAll('#cards .card').forEach(c => {
|
||||
if (c.dataset.status === 'on') up++;
|
||||
});
|
||||
// ----- Robust services count -----
|
||||
// Read from multiple sources so we always have a number:
|
||||
// 1. window.APPS (populated by grid.js after loadServices)
|
||||
// 2. #cards .card elements (post-buildGrid)
|
||||
// 3. live fetch /api/v1/services (last-resort fallback if grid hasn't run)
|
||||
async function fetchServicesCount() {
|
||||
// Source 1+2: window.APPS / DOM cards
|
||||
if (Array.isArray(window.APPS) && window.APPS.length > 0) {
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: window.APPS.length, up, source: 'APPS' };
|
||||
}
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
if (cards.length > 0) {
|
||||
const up = Array.from(cards).filter(c => c.dataset.status === 'on').length;
|
||||
return { total: cards.length, up, source: 'DOM' };
|
||||
}
|
||||
// Source 3: fetch live (endpoints may return {success, services:[...]} OR raw array)
|
||||
try {
|
||||
const r = await fetch('/api/v1/services', { cache: 'no-store' });
|
||||
if (!r.ok) return { total: 0, up: 0, source: 'fetch-fail' };
|
||||
const body = await r.json();
|
||||
const list = (body && Array.isArray(body.services)) ? body.services
|
||||
: (Array.isArray(body)) ? body
|
||||
: [];
|
||||
// Persist for the grid so this fallback only fires once
|
||||
if (Array.isArray(window.APPS) || typeof window.APPS === 'undefined') window.APPS = list;
|
||||
const up = document.querySelectorAll('#cards .card[data-status="on"]').length;
|
||||
return { total: list.length, up, source: 'fetch' };
|
||||
} catch (_) {
|
||||
return { total: 0, up: 0, source: 'fetch-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function setServicesCard() {
|
||||
const { total, up } = await fetchServicesCount();
|
||||
const el = document.getElementById('dc-monitor-services');
|
||||
const sub = document.getElementById('dc-monitor-services-sub');
|
||||
if (el) el.textContent = `${up} / ${total}`;
|
||||
if (sub) sub.textContent = total === 0 ? 'no services yet' : `${up} online · ${total - up} offline`;
|
||||
if (sub) sub.textContent = total === 0
|
||||
? 'no services yet'
|
||||
: `${up} online · ${total - up} offline`;
|
||||
}
|
||||
|
||||
function applyHealthSummary(data) {
|
||||
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
|
||||
readonly DASHBOARD_DIR="${SITES_DIR}/status"
|
||||
readonly CONTAINER_NAME="dashcaddy-api"
|
||||
readonly CADDY_ADMIN_PORT=2019
|
||||
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
|
||||
readonly DEFAULT_MAX_STORAGE_BYTES=""
|
||||
|
||||
# ---- Tunables (overridable via flags) --------------------------------------
|
||||
API_PORT=3001
|
||||
LOCAL_PORT=8080
|
||||
BACKUP_DIR=""
|
||||
BACKUP_LIMIT=""
|
||||
|
||||
# ---- Runtime state ---------------------------------------------------------
|
||||
DOMAIN_MODE="" # public | custom-tld | local
|
||||
@@ -389,6 +393,7 @@ EOF
|
||||
create_directories() {
|
||||
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets"
|
||||
mkdir -p /opt/dashcaddy/updates /opt/dashcaddy/scripts
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
ok "Directories created"
|
||||
}
|
||||
|
||||
@@ -626,7 +631,41 @@ CEOF
|
||||
# Docker Compose
|
||||
# ============================================================================
|
||||
|
||||
# Parse size string like "10GB" or "1TB" to bytes
|
||||
parse_size_to_bytes() {
|
||||
local size="$1"
|
||||
local value unit
|
||||
|
||||
# Strip whitespace
|
||||
size=$(echo "$size" | tr -d ' ')
|
||||
|
||||
# Extract numeric value and unit
|
||||
if [[ $size =~ ^([0-9.]+)([kmgtKMGT][bb]?|[bB]?)$ ]]; then
|
||||
value="${BASH_REMATCH[1]}"
|
||||
unit="${BASH_REMATCH[2]}"
|
||||
|
||||
# Normalize unit to uppercase without 'B' suffix for simplicity
|
||||
unit=$(echo "$unit" | tr '[:lower:]' '[:upper:]')
|
||||
case "$unit" in
|
||||
K|KB) echo $((value * 1024)) ;;
|
||||
M|MB) echo $((value * 1024 * 1024)) ;;
|
||||
G|GB) echo $((value * 1024 * 1024 * 1024)) ;;
|
||||
T|TB) echo $((value * 1024 * 1024 * 1024 * 1024)) ;;
|
||||
*) echo "$value" ;;
|
||||
esac
|
||||
else
|
||||
# Not recognized, treat as raw bytes
|
||||
echo "$size"
|
||||
fi
|
||||
}
|
||||
|
||||
generate_docker_compose() {
|
||||
# Convert BACKUP_LIMIT to bytes if set (e.g., "10GB" -> 10737418240)
|
||||
local backup_limit_bytes=""
|
||||
if [[ -n "$BACKUP_LIMIT" ]]; then
|
||||
backup_limit_bytes=$(parse_size_to_bytes "$BACKUP_LIMIT")
|
||||
fi
|
||||
|
||||
cat > "${API_DIR}/docker-compose.yml" <<DCEOF
|
||||
services:
|
||||
dashcaddy-api:
|
||||
@@ -648,6 +687,7 @@ services:
|
||||
- ${DASHBOARD_DIR}:/app/dashboard:rw
|
||||
- /opt/dashcaddy/updates:/app/updates:rw
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- CADDYFILE_PATH=/caddyfile
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
|
||||
@@ -665,6 +705,10 @@ services:
|
||||
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
|
||||
- DASHCADDY_API_SOURCE_DIR=${API_DIR}
|
||||
- DASHCADDY_FRONTEND_DIR=/app/dashboard
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES=${backup_limit_bytes:-0}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
restart: unless-stopped
|
||||
@@ -673,6 +717,14 @@ services:
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: ${BACKUP_DIR}
|
||||
DCEOF
|
||||
|
||||
ok "docker-compose.yml generated"
|
||||
@@ -880,6 +932,8 @@ parse_args() {
|
||||
--skip-caddy) SKIP_CADDY=true; shift ;;
|
||||
--uninstall) UNINSTALL=true; shift ;;
|
||||
--keep-config) KEEP_CONFIG=true; shift ;;
|
||||
--backup-dir) BACKUP_DIR="${2:-}"; shift; shift ;;
|
||||
--backup-limit) BACKUP_LIMIT="${2:-}"; shift; shift ;;
|
||||
--yes|-y) AUTO_YES=true; shift ;;
|
||||
--help|-h) print_help; exit 0 ;;
|
||||
*) warn "Unknown option: $1 (ignored)"; shift ;;
|
||||
@@ -914,6 +968,8 @@ print_help() {
|
||||
--source PATH Use local source files
|
||||
--skip-docker Already have Docker
|
||||
--skip-caddy Already have Caddy
|
||||
--backup-dir PATH Backup directory (default: /etc/dashcaddy/backups)
|
||||
--backup-limit SIZE Storage limit for backups (e.g., 10GB, 1TB)
|
||||
--uninstall Remove DashCaddy
|
||||
--keep-config Keep configs during uninstall
|
||||
--yes Skip confirmations
|
||||
|
||||
@@ -226,17 +226,34 @@ class ConfigManager {
|
||||
* @returns {Promise<Object>} Disk space info
|
||||
*/
|
||||
async getDiskSpace(testPath) {
|
||||
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
|
||||
try {
|
||||
const stats = await fs.stat(testPath);
|
||||
|
||||
const fsPromises = require('fs').promises;
|
||||
const pathModule = require('path');
|
||||
|
||||
// Ensure directory exists
|
||||
await fsPromises.mkdir(testPath, { recursive: true });
|
||||
|
||||
// Use statfs for true disk space (works on all filesystems: ext4, Btrfs, XFS, ZFS, APFS, NTFS)
|
||||
const stats = await fsPromises.statfs(testPath);
|
||||
|
||||
const totalBytes = stats.blocks * stats.bsize;
|
||||
const freeBytes = stats.bfree * stats.bsize;
|
||||
const availableBytes = stats.bavail * stats.bsize; // Available to non-root users
|
||||
const usedBytes = totalBytes - freeBytes;
|
||||
|
||||
return {
|
||||
available: true,
|
||||
path: testPath
|
||||
path: testPath,
|
||||
total: totalBytes,
|
||||
used: usedBytes,
|
||||
free: freeBytes,
|
||||
availableBytes: availableBytes,
|
||||
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
available: false,
|
||||
path: testPath,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +62,11 @@ const state = {
|
||||
installPath: '',
|
||||
health: null
|
||||
},
|
||||
// Backup configuration
|
||||
backup: {
|
||||
maxStorageGB: 10,
|
||||
backupDir: ''
|
||||
},
|
||||
// Uninstall mode
|
||||
uninstallMode: false,
|
||||
uninstall: {
|
||||
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
|
||||
if (field === 'primaryColor') render();
|
||||
}
|
||||
|
||||
// Backup functions
|
||||
function updateBackup(field, value) {
|
||||
state.backup[field] = value;
|
||||
render();
|
||||
}
|
||||
|
||||
async function selectBackupDir() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFolder();
|
||||
if (result.success && result.path) {
|
||||
state.backup.backupDir = result.path;
|
||||
render();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Backup dir selection failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectLogo() {
|
||||
try {
|
||||
const result = await window.electronAPI.selectFile({
|
||||
@@ -420,6 +443,10 @@ async function startInstallation() {
|
||||
password: state.dns.password,
|
||||
token: state.dns.token
|
||||
} : null,
|
||||
backup: {
|
||||
maxStorageGB: state.backup.maxStorageGB,
|
||||
backupDir: state.backup.backupDir || null
|
||||
},
|
||||
autoStart: true
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
|
||||
<p class="hint">Port for the DashCaddy API server (default: 3001)</p>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="folder-input">
|
||||
<label>Backup Storage Limit (GB)</label>
|
||||
<div class="input-row">
|
||||
<input type="number"
|
||||
value="${state.backup.maxStorageGB}"
|
||||
min="1" max="10240"
|
||||
oninput="updateBackup('maxStorageGB', parseInt(this.value) || 10)">
|
||||
</div>
|
||||
<p class="hint">Maximum storage for backups in GB (default: 10, max: 10TB)</p>
|
||||
</div>
|
||||
|
||||
${state.tier !== 'basic' ? `
|
||||
<div class="folder-input">
|
||||
<label>Backup Directory</label>
|
||||
<div class="input-row">
|
||||
<input type="text"
|
||||
value="${escapeHtml(state.backup.backupDir)}"
|
||||
readonly
|
||||
placeholder="Default: $INSTALL_DIR/backups">
|
||||
<button class="btn-browse" onclick="selectBackupDir()">Browse...</button>
|
||||
</div>
|
||||
<p class="hint">Where backup files are stored on the host</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -7,15 +7,28 @@ services:
|
||||
volumes:
|
||||
- {{API_PATH}}:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- dashcaddy-backups:/app/backups
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT={{API_PORT}}
|
||||
- SERVICES_FILE=/app/services.json
|
||||
- CADDY_ADMIN_URL=http://host.docker.internal:2019
|
||||
- BACKUP_DIR=/app/backups
|
||||
- BACKUP_MAX_STORAGE_BYTES={{BACKUP_MAX_STORAGE_BYTES}}
|
||||
- BACKUP_CONFIG_FILE=/app/backup-config.json
|
||||
- BACKUP_HISTORY_FILE=/app/backup-history.json
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dashcaddy
|
||||
|
||||
volumes:
|
||||
dashcaddy-backups:
|
||||
driver: local
|
||||
driver_opts:
|
||||
type: none
|
||||
o: bind
|
||||
device: {{BACKUP_DIR}}
|
||||
|
||||
networks:
|
||||
dashcaddy:
|
||||
driver: bridge
|
||||
@@ -1,272 +0,0 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
|
||||
/**
|
||||
* OpenClaw management routes
|
||||
* Proxies gateway API calls through DashCaddy so the token never leaves the server.
|
||||
*
|
||||
* GET /openclaw/status → container info + gateway health
|
||||
* POST /openclaw/deploy → deploy OpenClaw container
|
||||
* GET /openclaw/proxy/* → proxy GET to gateway
|
||||
* POST /openclaw/proxy/* → proxy POST to gateway
|
||||
* DELETE /openclaw → remove container
|
||||
*/
|
||||
module.exports = function openClawRoutes(ctx) {
|
||||
const router = express.Router();
|
||||
const docker = ctx.docker;
|
||||
const asyncHandler = ctx.asyncHandler;
|
||||
const log = ctx.log || console;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function findOpenClawContainer() {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
return containers.find(function(c) {
|
||||
return c.Image === 'ghcr.io/nousresearch/openclaw:latest' ||
|
||||
(c.Labels && c.Labels['dashcaddy.managed'] === 'true' &&
|
||||
c.Names.some(function(n) { return n.includes('openclaw'); }));
|
||||
}) || null;
|
||||
}
|
||||
|
||||
async function getGatewayToken(containerId) {
|
||||
try {
|
||||
const info = await docker.client.containerInfo(containerId);
|
||||
const entry = (info.Config.Env || []).find(function(e) {
|
||||
return e.startsWith('OPENCLAW_GATEWAY_TOKEN=');
|
||||
});
|
||||
return entry ? entry.split('=')[1] : null;
|
||||
} catch(err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getContainerPort(containerId) {
|
||||
try {
|
||||
const containers = await docker.client.listContainers({ all: true });
|
||||
const c = containers.find(function(x) {
|
||||
return x.Id === containerId || x.Id.startsWith(containerId);
|
||||
});
|
||||
if (c && c.Ports) {
|
||||
const p = c.Ports.find(function(x) { return x.PrivatePort === 18792; });
|
||||
if (p && p.PublicPort) return String(p.PublicPort);
|
||||
}
|
||||
return '18792';
|
||||
} catch(err) {
|
||||
return '18792';
|
||||
}
|
||||
}
|
||||
|
||||
async function gatewayHealth(baseUrl, token) {
|
||||
return new Promise(function(resolve) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
const req = http.get(baseUrl + '/health', { headers: headers }, function(res) {
|
||||
let data = '';
|
||||
res.on('data', function(d) { data += d; });
|
||||
res.on('end', function() {
|
||||
try { resolve({ ok: true, data: JSON.parse(data) }); }
|
||||
catch(e) { resolve({ ok: true, data: data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', function(e) { resolve({ ok: false, error: e.message }); });
|
||||
req.setTimeout(5000, function() { req.destroy(); resolve({ ok: false, error: 'timeout' }); });
|
||||
});
|
||||
}
|
||||
|
||||
function proxyRequest(req, res, targetBase, path, token) {
|
||||
const headers = {};
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
headers['X-Forwarded-For'] = req.ip;
|
||||
headers['X-Forwarded-Proto'] = req.protocol;
|
||||
|
||||
const url = targetBase + '/' + path;
|
||||
const method = req.method;
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
const body = JSON.stringify(req.body);
|
||||
headers['Content-Type'] = 'application/json';
|
||||
headers['Content-Length'] = Buffer.byteLength(body);
|
||||
|
||||
const proxyReq = http.request(url, { method: method, headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
proxyReq.write(body);
|
||||
proxyReq.end();
|
||||
} else {
|
||||
const proxyReq = http.get(url, { headers: headers }, function(proxyRes) {
|
||||
res.set(proxyRes.headers);
|
||||
res.status(proxyRes.statusCode);
|
||||
proxyRes.on('data', function(d) { res.write(d); });
|
||||
proxyRes.on('end', function() { res.end(); });
|
||||
});
|
||||
proxyReq.on('error', function(e) { res.status(502).json({ success: false, error: e.message }); });
|
||||
proxyReq.setTimeout(15000, function() { proxyReq.destroy(); res.status(504).json({ success: false, error: 'gateway timeout' }); });
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /openclaw/status ────────────────────────────────────────────────
|
||||
|
||||
router.get('/status', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
|
||||
if (!container) {
|
||||
return res.json({ success: true, deployed: false });
|
||||
}
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const health = await gatewayHealth(baseUrl, token);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: {
|
||||
id: container.Id.slice(0, 12),
|
||||
name: container.Name,
|
||||
state: container.State,
|
||||
status: container.Status,
|
||||
created: container.Created,
|
||||
image: container.Image
|
||||
},
|
||||
gateway: {
|
||||
url: baseUrl,
|
||||
port: port,
|
||||
healthy: health.ok,
|
||||
healthData: health.data || null,
|
||||
tokenSet: !!token
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/deploy ───────────────────────────────────────────────
|
||||
|
||||
router.post('/deploy', asyncHandler(async function(req, res) {
|
||||
const existing = await findOpenClawContainer();
|
||||
if (existing) {
|
||||
return res.status(409).json({ success: false, error: 'OpenClaw is already deployed' });
|
||||
}
|
||||
|
||||
const image = 'ghcr.io/nousresearch/openclaw:latest';
|
||||
const name = 'openclaw-' + Date.now();
|
||||
const gatewayToken = generateToken();
|
||||
|
||||
// Pull image
|
||||
log.info('Pulling ' + image + '...');
|
||||
try {
|
||||
await new Promise(function(resolve, reject) {
|
||||
docker.client.pull(image, function(err, stream) {
|
||||
if (err) return reject(err);
|
||||
docker.client.modem.followProgress(stream, function(err2) {
|
||||
if (err2) return reject(err2);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw pull failed: ' + e.message);
|
||||
return res.status(500).json({ success: false, error: 'Failed to pull image: ' + e.message });
|
||||
}
|
||||
|
||||
// Create + start container
|
||||
try {
|
||||
const container = await docker.client.createContainer({
|
||||
name: name,
|
||||
Image: image,
|
||||
Env: [
|
||||
'OPENCLAW_GATEWAY_MODE=local',
|
||||
'OPENCLAW_GATEWAY_TOKEN=' + gatewayToken
|
||||
],
|
||||
HostConfig: {
|
||||
PortBindings: { '18792/tcp': [{ HostPort: '18792' }] },
|
||||
RestartPolicy: { Name: 'unless-stopped' },
|
||||
Labels: {
|
||||
'dashcaddy.managed': 'true',
|
||||
'dashcaddy.app': 'openclaw'
|
||||
}
|
||||
},
|
||||
ExposedPorts: { '18792/tcp': {} }
|
||||
});
|
||||
|
||||
await container.start();
|
||||
log.info('OpenClaw deployed: ' + container.id.slice(0, 12));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deployed: true,
|
||||
container: { id: container.id.slice(0, 12), name: name },
|
||||
gateway: {
|
||||
url: 'http://localhost:18792',
|
||||
token: gatewayToken
|
||||
}
|
||||
});
|
||||
} catch(e) {
|
||||
log.error('OpenClaw deploy failed: ' + e.message);
|
||||
res.status(500).json({ success: false, error: 'Deploy failed: ' + e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
// ── GET /openclaw/proxy/* ───────────────────────────────────────────────
|
||||
|
||||
router.get('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── POST /openclaw/proxy/* ──────────────────────────────────────────────
|
||||
|
||||
router.post('/proxy/*', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
const token = await getGatewayToken(container.Id);
|
||||
const port = await getContainerPort(container.Id);
|
||||
const baseUrl = 'http://localhost:' + port;
|
||||
const path = req.params[0];
|
||||
|
||||
proxyRequest(req, res, baseUrl, path, token);
|
||||
}));
|
||||
|
||||
// ── DELETE /openclaw ───────────────────────────────────────────────────
|
||||
|
||||
router.delete('/', asyncHandler(async function(req, res) {
|
||||
const container = await findOpenClawContainer();
|
||||
if (!container) return res.status(404).json({ success: false, error: 'OpenClaw not deployed' });
|
||||
|
||||
try {
|
||||
const c = docker.client.container(container.Id);
|
||||
await c.stop().catch(function() {});
|
||||
await c.remove({ force: true });
|
||||
log.info('OpenClaw container ' + container.Id.slice(0, 12) + ' removed');
|
||||
res.json({ success: true, message: 'OpenClaw removed' });
|
||||
} catch(e) {
|
||||
log.error('Failed to remove OpenClaw: ' + e.message);
|
||||
res.status(500).json({ success: false, error: e.message });
|
||||
}
|
||||
}));
|
||||
|
||||
return router;
|
||||
};
|
||||
|
||||
// ── token generator ──────────────────────────────────────────────────────────
|
||||
|
||||
function generateToken() {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
for (let i = 0; i < 32; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# DashCaddy Gitea — Off-host backup to Dropbox
|
||||
# =============================================================================
|
||||
# - Stops gitea container briefly to ensure SQLite DB consistency
|
||||
# - Syncs /var/lib/docker/volumes/gitea-data to dropbox:/Apps/dashcaddy-gitea-backups/<date>/
|
||||
# - Date-stamped snapshots (one per day), kept for 7 days locally
|
||||
# - Restarts gitea even if sync fails
|
||||
# - Logs to /var/log/gitea-backup.log
|
||||
# =============================================================================
|
||||
set -u # don't use -e: we want to always restart gitea
|
||||
|
||||
LOG=/var/log/gitea-backup.log
|
||||
DATA_SRC=/var/lib/docker/volumes/gitea-data/_data
|
||||
DEST="dropbox:/Apps/dashcaddy-gitea-backups"
|
||||
TODAY=$(date -u +%Y-%m-%d)
|
||||
BACKUP_PATH="${DEST}/${TODAY}"
|
||||
RETENTION_DAYS=7
|
||||
|
||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*" | tee -a "$LOG"; }
|
||||
|
||||
log "=== Backup start ==="
|
||||
|
||||
# 0. Sanity checks
|
||||
if [ ! -d "$DATA_SRC" ]; then
|
||||
log "ERROR: data dir $DATA_SRC missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Stop gitea to flush SQLite
|
||||
log "Stopping gitea container..."
|
||||
docker stop gitea >> "$LOG" 2>&1
|
||||
STOP_RC=$?
|
||||
if [ $STOP_RC -ne 0 ]; then
|
||||
log "WARNING: docker stop returned $STOP_RC — container may not be running"
|
||||
fi
|
||||
|
||||
# 2. Sync (use copy so source files are preserved as-is, no --delete)
|
||||
log "Syncing $DATA_SRC -> $BACKUP_PATH"
|
||||
rclone copy "$DATA_SRC" "$BACKUP_PATH" \
|
||||
--transfers 4 \
|
||||
--checkers 8 \
|
||||
--retries 3 \
|
||||
--low-level-retries 10 \
|
||||
--stats 30s \
|
||||
--log-file "$LOG" \
|
||||
--log-level INFO
|
||||
SYNC_RC=$?
|
||||
|
||||
# 3. Always restart gitea
|
||||
log "Starting gitea container..."
|
||||
docker start gitea >> "$LOG" 2>&1
|
||||
START_RC=$?
|
||||
|
||||
# Wait for gitea to be ready
|
||||
for i in {1..30}; do
|
||||
if curl -sf http://localhost:3000/api/v1/version > /dev/null 2>&1; then
|
||||
log "Gitea is up after ${i}s"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# 4. Cleanup old backups (older than RETENTION_DAYS)
|
||||
log "Pruning local + remote snapshots older than ${RETENTION_DAYS} days..."
|
||||
CUTOFF=$(date -u -d "${RETENTION_DAYS} days ago" +%Y-%m-%d)
|
||||
rclone lsf "$DEST/" --dirs-only 2>/dev/null | while read -r d; do
|
||||
# rclone returns names with trailing /
|
||||
name="${d%/}"
|
||||
if [[ "$name" < "$CUTOFF" ]]; then
|
||||
log " removing old: $name"
|
||||
rclone purge "${DEST}/${name}" >> "$LOG" 2>&1
|
||||
fi
|
||||
done
|
||||
|
||||
# 5. Report
|
||||
if [ $SYNC_RC -eq 0 ] && [ $START_RC -eq 0 ]; then
|
||||
log "=== Backup OK ==="
|
||||
exit 0
|
||||
else
|
||||
log "=== Backup completed with errors (sync=$SYNC_RC, start=$START_RC) ==="
|
||||
exit 1
|
||||
fi
|
||||
@@ -1,76 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Samihost fail2ban watchdog — auto-unban whitelisted IPs and keep ignoreip list in sync.
|
||||
# Deployed to /usr/local/bin/samihost-fail2ban-watchdog.sh on 194.163.161.162
|
||||
# Cron: every 30 min (0,30 * * * *)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
JAIL_LOCAL=/etc/fail2ban/jail.local
|
||||
BACKUP=/etc/fail2ban/jail.local.watchdog.bak
|
||||
EXPECTED_IGNOREIP="127.0.0.1/8 ::1 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 fc00::/7 fe80::/10 100.64.0.0/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162"
|
||||
LOG=/var/log/samihost-fail2ban-watchdog.log
|
||||
TELEGRAM_LOG=/tmp/fail2ban-watchdog-last-action
|
||||
|
||||
ts() { date -u +"%Y-%m-%dT%H:%M:%SZ"; }
|
||||
log() { echo "$(ts) $*" | tee -a "$LOG"; }
|
||||
|
||||
mkdir -p "$(dirname "$LOG")"
|
||||
touch "$LOG"
|
||||
|
||||
# --- 1. Verify ignoreip line is intact and matches expected ---
|
||||
CURRENT=$(grep '^ignoreip' "$JAIL_LOCAL" | sed 's/^ignoreip[[:space:]]*=[[:space:]]*//' || true)
|
||||
EXPECTED_NORMALIZED=$(echo "$EXPECTED_IGNOREIP" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
CURRENT_NORMALIZED=$(echo "$CURRENT" | tr ' ' '\n' | sort -u | tr '\n' ' ' | sed 's/ $//')
|
||||
|
||||
if [ "$CURRENT_NORMALIZED" != "$EXPECTED_NORMALIZED" ]; then
|
||||
log "ALERT: ignoreip line drifted. Restoring."
|
||||
cp "$JAIL_LOCAL" "$BACKUP"
|
||||
sed -i "s|^ignoreip = .*|ignoreip = $EXPECTED_IGNOREIP|" "$JAIL_LOCAL"
|
||||
fail2ban-client reload
|
||||
echo "ignoreip restored at $(ts)" > "$TELEGRAM_LOG"
|
||||
log "ignoreip restored, fail2ban reloaded"
|
||||
fi
|
||||
|
||||
# --- 2. Unban any currently-banned IPs that match our trusted set ---
|
||||
BANNED=$(fail2ban-client status sshd 2>/dev/null | awk -F: '/Banned IP list/{print $2}' | tr ' ' '\n' | grep -v '^$' || true)
|
||||
UNBANNED=0
|
||||
for ip in $BANNED; do
|
||||
# Match against any trusted network
|
||||
is_trusted=0
|
||||
for net in 127.0.0.0/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 ::1 fc00::/7 fe80::/10 100.121.150.22 100.85.236.10 100.71.97.12 100.81.59.99 100.98.123.59 194.233.88.206 173.212.201.200 194.163.161.162; do
|
||||
if [[ "$net" == *"/"* ]]; then
|
||||
# CIDR match (simple IPv4 only — IPv6 needs python or ipcalc, skip for now)
|
||||
base="${net%/*}"
|
||||
mask="${net#*/}"
|
||||
if [[ "$ip" == "$base"* ]] || python3 -c "import ipaddress,sys; sys.exit(0 if ipaddress.ip_address('$ip') in ipaddress.ip_network('$net', strict=False) else 1)" 2>/dev/null; then
|
||||
is_trusted=1
|
||||
break
|
||||
fi
|
||||
else
|
||||
if [ "$ip" = "$net" ]; then
|
||||
is_trusted=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$is_trusted" = "1" ]; then
|
||||
if fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1; then
|
||||
log "auto-unbanned trusted IP: $ip"
|
||||
UNBANNED=$((UNBANNED+1))
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$UNBANNED" -gt 0 ] && echo "auto-unbanned $UNBANNED trusted IPs at $(ts)" > "$TELEGRAM_LOG"
|
||||
|
||||
# --- 3. Cap the ban count — if more than 200 are banned, mass-unban stale ones ---
|
||||
TOTAL_BANNED=$(fail2ban-client status sshd 2>/dev/null | awk '/Currently banned/{print $NF}' || echo 0)
|
||||
if [ "$TOTAL_BANNED" -gt 200 ]; then
|
||||
log "ALERT: $TOTAL_BANNED IPs banned. Mass-unbanning all."
|
||||
for ip in $BANNED; do
|
||||
fail2ban-client set sshd unbanip "$ip" >/dev/null 2>&1 || true
|
||||
done
|
||||
echo "mass-unbanned $TOTAL_BANNED stale bans at $(ts)" > "$TELEGRAM_LOG"
|
||||
fi
|
||||
|
||||
log "watchdog run complete (unbanned=$UNBANNED, total_banned=$TOTAL_BANNED)"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Vendored
+1
-1
@@ -127,7 +127,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${g.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};h==="homelab"?(x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",x.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-pub
Line truncated
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const f=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:f,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
|
||||
Vendored
+19
-19
@@ -1,4 +1,4 @@
|
||||
(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f(Line truncated
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(d=>{window.APPS.find(o=>o.id===d.id)||window.APPS.push(d)})}catch(a){console.warn("Failed to load custom services:",a)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((a,d)=>{a.style.transitionDelay=`${Math.min(d*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(a=>a.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(a=>{console.warn("[init] Service worker registration failed:",a)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await g(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();a.success&&window._updateAuthCard(a.config.enabled&&a.config.isSetUp,a.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/driver.min.css",document.head.appendChild(a)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const a=document.createElement("link");a.rel="stylesheet",a.href="/css/onboarding.css",document.head.appendChild(a)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let a={};try{a=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(d=>{const o=d.dataset.section,n=d.querySelector(".tools-section-header");n&&(a[o]&&(d.classList.add("open"),n.setAttribute("aria-expanded","true")),n.addEventListener("click",c=>{c.preventDefault();const r=d.classList.toggle("open");n.setAttribute("aria-expanded",r?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(e=>{m[e.dataset.section]=e.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const o=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(o&&o.tourCompleted)}catch{}const a=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!a)return;const d=document.createElement("button");d.id="restart-tour-btn",d.textContent=i?"Help Tour":"\u{1F393} Help Tour",d.title="Restart the onboarding tour",d.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const o=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(o),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(o),5e3)}},a.appendChild(d)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function g(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const a=await i.json();a&&a.categories&&(window.DC_CATEGORIES=a.categories,typeof DC<"u"&&(DC.CATEGORIES=a.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(a=>{const d=a.dataset.current||"",o=a.querySelector('option[value=""]');if(a.innerHTML="",o)a.appendChild(o);else{const n=document.createElement("option");n.value="",n.textContent="\u2014 Select category \u2014",a.appendChild(n)}Object.entries(i).forEach(([n,c])=>{const r=document.createElement("option");r.value=n,r.textContent=`${c.icon||""} ${n}`.trim(),n===d&&(r.selected=!0),a.appendChild(r)})})}window.populateCategorySelects=q,window.loadTemplateCategories=g,(async()=>{try{const a=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(a.success&&a.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(i){console.warn("TOTP check failed, proceeding normally:",i)}f(Line truncated
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
|
||||
</div>
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<n.length-1;)e/=1024,c++;return e.toFixed(1)+" "+n[c]}function C(){const e=(window.APPS||[]).length;let n=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
`,k.parentNode.insertBefore(u,k);function h(o,n){const c=document.getElementById(o);if(!c)return;const r=Math.max(0,Math.min(100,Number(n)||0));c.style.width=r+"%",c.classList.remove("warn","bad"),r>=85?c.classList.add("bad"):r>=65&&c.classList.add("warn")}function f(o){return o==null||isNaN(o)?"\u2014":Math.round(o*10)/10+"%"}function b(o){if(o==null||isNaN(o))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;o>=1024&&c<n.length-1;)o/=1024,c++;return o.toFixed(1)+" "+n[c]}async function C(){if(Array.isArray(window.APPS)&&window.APPS.length>0){const n=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:window.APPS.length,up:n,source:"APPS"}}const o=document.querySelectorAll("#cards .card");if(o.length>0){const n=Array.from(o).filter(c=>c.dataset.status==="on").length;return{total:o.length,up:n,source:"DOM"}}try{const n=await fetch("/api/v1/services",{cache:"no-store"});if(!n.ok)return{total:0,up:0,source:"fetch-fail"};const c=await n.json(),r=c&&Array.isArray(c.services)?c.services:Array.isArray(c)?c:[];(Array.isArray(window.APPS)||typeof window.APPS>"u")&&(window.APPS=r);const m=document.querySelectorAll('#cards .card[data-status="on"]').length;return{total:r.length,up:m,source:"fetch"}}catch{return{total:0,up:0,source:"fetch-error"}}}async function E(){const{total:o,up:n}=await C(),c=document.getElementById("dc-monitor-services"),r=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${o}`),r&&(r.textContent=o===0?"no services yet":`${n} online \xB7 ${o-n} offline`)}function S(o){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!o||o.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const r=o.summary,m=r.healthy??r.up??0,e=r.unhealthy??r.down??0,t=r.total??m+e;n.textContent=`${m}/${t}`,c&&(e===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':e<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${e} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${e} down</span>`)}async function g(){try{const o=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!o.ok)return null;const n=await o.json();return n&&n.stats?n.stats:null}catch{return null}}async function q(){try{const o=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return o.ok?await o.json():null}catch{return null}}function i(o){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),r=document.getElementById("dc-monitor-cpu"),m=document.getElementById("dc-monitor-mem");if(!o){n&&(n.textContent="\u2014"),r&&(r.textContent="\u2014"),m&&(m.textContent="\u2014");return}const e=Object.values(o);if(e.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),r&&(r.textContent="0%"),m&&(m.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,s=0,p=0,l=0,y=0;e.forEach(w=>{if(w.cpu!=null){const x=Number(w.cpu);isNaN(x)||(t+=x>1?x:x*100,l++)}if(w.memory!=null){const x=Number(w.memory);isNaN(x)||(s+=x,p+=Number(w.memoryUsage||0),y++)}});const A=l?t/l:0,L=y?s/y:0;if(n&&(n.textContent=String(e.length)),c){const w=p?` \xB7 ${b(p)} RAM`:"";c.textContent=`running${w}`}r&&(r.textContent=f(A)),m&&(m.textContent=f(L)),h("dc-monitor-cpu-bar",A),h("dc-monitor-mem-bar",L)}let a=!1;async function d(){if(!a){a=!0;try{E();const[o,n]=await Promise.all([g(),q()]);i(o),S(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const r=new Date;c.textContent=`updated ${r.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=d,setInterval(d,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(d,200)})(),(function(){"use strict";const v=(...e)=>{window.DASHCADDY_DEBUG&&console.log(...e)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(e){console.warn("[Keyboard Shortcuts] Failed to initialize:",e.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">\u{1F50D}</span>
|
||||
@@ -123,7 +123,7 @@
|
||||
<span><kbd>Esc</kbd> Close</span>
|
||||
</div>
|
||||
</div>
|
||||
`;const t=document.createElement("style");t.textContent=`
|
||||
`;const e=document.createElement("style");e.textContent=`
|
||||
.quick-search-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
@@ -271,7 +271,7 @@
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=`
|
||||
`,document.head.appendChild(e),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",d),h.addEventListener("keydown",r),u.addEventListener("click",t=>{t.target===u&&g()})}function E(e){try{if((e.ctrlKey||e.metaKey)&&e.key==="k"){e.preventDefault(),S();return}if(e.key==="Escape"){if(u&&u.classList.contains("show")){g();return}q()}}catch(t){console.warn("[Keyboard Shortcuts] Error handling keydown:",t.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(e){console.warn("[Keyboard Shortcuts] Error opening quick search:",e.message)}}function g(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(e){console.warn("[Keyboard Shortcuts] Error closing quick search:",e.message)}}function q(){for(const e of k){const t=document.querySelector(e);if(t&&(t.classList.contains("show")||t.style.display==="flex"))return t.classList.remove("show"),t.style.display="none",!0}return!1}function i(){const e=`
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">\u{1F504}</span>
|
||||
@@ -303,29 +303,29 @@
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${i()}
|
||||
`;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${w}">
|
||||
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
${a()}
|
||||
`;f.innerHTML=e,c()}function a(){const e=document.querySelectorAll(".card[data-app], #cards .card");let t="";return e.forEach(s=>{const p=s.querySelector(".name")?.textContent||"Unknown",l=s.dataset.status||"unknown",y=s.dataset.app||"";p&&p!=="--"&&(t+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${y}">
|
||||
<span class="quick-search-item-icon">${l==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${p}</div>
|
||||
<div class="quick-search-item-description">Click to open service</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${d.toUpperCase()}</span>
|
||||
<span class="quick-search-item-badge">${l.toUpperCase()}</span>
|
||||
</div>
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
`)}),t||'<div class="quick-search-empty">No services found</div>'}function d(e){try{const t=e.target.value.toLowerCase().trim();if(!t){i();return}const s=o(t);n(s)}catch(t){console.warn("[Keyboard Shortcuts] Error handling search input:",t.message)}}function o(e){const t={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(l=>{(l.title.toLowerCase().includes(e)||l.keywords.includes(e))&&t.actions.push(l)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(l=>{const y=l.querySelector(".name")?.textContent||"",A=l.dataset.app||"",L=l.dataset.status||"unknown";(y.toLowerCase().includes(e)||A.toLowerCase().includes(e))&&t.services.push({id:A,title:y,status:L,icon:L==="on"?"\u{1F7E2}":"\u{1F534}"})}),t}function n(e){let t="";e.actions.length>0&&(t+='<div class="quick-search-category">Actions</div>',e.actions.forEach(s=>{t+=`
|
||||
<div class="quick-search-item" data-action="${s.id}">
|
||||
<span class="quick-search-item-icon">${s.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${r.title}</div>
|
||||
<div class="quick-search-item-title">${s.title}</div>
|
||||
</div>
|
||||
</div>
|
||||
`})),t.services.length>0&&(o+='<div class="quick-search-category">Services</div>',t.services.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
`})),e.services.length>0&&(t+='<div class="quick-search-category">Services</div>',e.services.forEach(s=>{t+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${s.id}">
|
||||
<span class="quick-search-item-icon">${s.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
<div class="quick-search-item-title">${r.title}</div>
|
||||
<div class="quick-search-item-title">${s.title}</div>
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
|
||||
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
|
||||
</div>
|
||||
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
|
||||
`})),t||(t='<div class="quick-search-empty">No results found</div>'),f.innerHTML=t,c()}function c(){f.querySelectorAll(".quick-search-item").forEach((t,s)=>{t.addEventListener("click",()=>m(t)),s===0&&t.classList.add("selected")})}function r(e){try{const t=f.querySelectorAll(".quick-search-item"),s=f.querySelector(".quick-search-item.selected"),p=Array.from(t).indexOf(s);if(e.key==="ArrowDown"){e.preventDefault(),s&&s.classList.remove("selected");const l=(p+1)%t.length;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else if(e.key==="ArrowUp"){e.preventDefault(),s&&s.classList.remove("selected");const l=p<=0?t.length-1:p-1;t[l]?.classList.add("selected"),t[l]?.scrollIntoView({block:"nearest"})}else e.key==="Enter"&&(e.preventDefault(),s&&m(s))}catch(t){console.warn("[Keyboard Shortcuts] Error handling search navigation:",t.message)}}function m(e){try{const t=e.dataset.action,s=e.dataset.service;switch(g(),t){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const p=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const l=document.querySelector(`[data-app="${s}"]`);l&&l.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",t)}}catch(t){console.warn("[Keyboard Shortcuts] Error executing action:",t.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:g}})();
|
||||
Vendored
+3
-3
@@ -1,5 +1,5 @@
|
||||
this.driver=this.driver||{},this.driver.js=(function(y){"use strict";let P={};function _(e={}){P={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?P[e]:P}function c(e,t,i,s){return(e/=s/2)<1?i/2*e*e+t:-i/2*(--e*(e-2)-1)+t}function r(e){const t='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(i=>{const s=i.matches(t),o=Array.from(i.querySelectorAll(t));return[...s?[i]:[],...o]}).filter(i=>getComputedStyle(i).pointerEvents!=="none"&&k(i))}function n(e){if(!e||A(e))return;const t=a("smoothScroll");e.scrollIntoView({behavior:!t||v(e)?"auto":"smooth",inline:"center",block:"center"})}function v(e){if(!e||!e.parentElement)return;const t=e.parentElement;return t.scrollHeight>t.clientHeight}function A(e){const t=e.getBoundingClientRect();return t.top>=0&&t.left>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&t.right<=(window.innerWidth||document.documentElement.clientWidth)}function k(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let m={};function C(e,t){m[e]=t}function l(e){return e?m[e]:m}function u(){m={}}let b={};function $(e,t){b[e]=t}function L(e){var t;(t=b[e])==null||t.call(b)}function R(){b={}}function j(e,t,i,s){let o=l("__activeStagePosition");const h=o||i.getBoundingClientRect(),S=s.getBoundingClientRect(),x=c(e,h.x,S.x-h.x,t),p=c(e,h.y,S.y-h.y,t),T=c(e,h.width,S.width-h.width,t),d=c(e,h.height,S.height-h.height,t);o={x,y:p,width:T,height:d},X(o),C("__activeStagePosition",o)}function Q(e){if(!e)return;const t=e.getBoundingClientRect(),i={x:t.x,y:t.y,width:t.width,height:t.height};C("__activeStagePosition",i),X(i)}function le(){const e=l("__activeStagePosition"),t=l("__overlaySvg");if(!e)return;if(!t){console.warn("No stage svg found.");return}const i=window.innerWidth,s=window.innerHeight;t.setAttribute("viewBox",`0 0 ${i} ${s}`)}function de(e){const t=pe(e);document.body.appendChild(t),re(t,i=>{i.target.tagName==="path"&&L("overlayClick")}),C("__overlaySvg",t)}function X(e){const t=l("__overlaySvg");if(!t){de(e);return}const i=t.firstElementChild;if(i?.tagName!=="path")throw new Error("no path element found in stage svg");i.setAttribute("d",Z(e))}function pe(e){const t=window.innerWidth,i=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${t} ${i}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const o=document.createElementNS("http://www.w3.org/2000/svg","path");return o.setAttribute("d",Z(e)),o.style.fill=a("overlayColor")||"rgb(0,0,0)",o.style.opacity=`${a("overlayOpacity")}`,o.style.pointerEvents="auto",o.style.cursor="auto",s.appendChild(o),s}function Z(e){const t=window.innerWidth,i=window.innerHeight,s=a("stagePadding")||0,o=a("stageRadius")||0,h=e.width+s*2,S=e.height+s*2,x=Math.min(o,h/2,S/2),p=Math.floor(Math.max(x,0)),T=e.x-s+p,d=e.y-s,g=h-p*2,f=S-p*2;return`M${t},0L0,0L0,${i}L${t},${i}L${t},0Z
|
||||
M${T},${d} h${g} a${p},${p} 0 0 1 ${p},${p} v${f} a${p},${p} 0 0 1 -${p},${p} h-${g} a${p},${p} 0 0 1 -${p},-${p} v-${f} a${p},${p} 0 0 1 ${p},-${p} z`}function ce(){const e=l("__overlaySvg");e&&e.remove()}function ue(){const e=document.getElementById("driver-dummy-element");if(e)return e;let t=document.createElement("div");return t.id="driver-dummy-element",t.style.width="0",t.style.height="0",t.style.pointerEvents="none",t.style.opacity="0",t.style.position="fixed",t.style.top="50%",t.style.left="50%",document.body.appendChild(t),t}function ee(e){const{element:t}=e;let i=typeof t=="string"?document.querySelector(t):t;i||(i=ue()),he(i,e)}function me(){const e=l("__activeElement"),t=l("__activeStep");e&&(Q(e),le(),ae(e,t))}function he(e,t){const i=Date.now(),s=l("__activeStep"),o=l("__activeElement")||e,h=!o||o===e,S=e.id==="driver-dummy-element",x=o.id==="driver-dummy-element",p=a("animate"),T=t.onHighlightStarted||a("onHighlightStarted"),d=t?.onHighlighted||a("onHighlighted"),g=s?.onDeselected||a("onDeselected"),f=a(),D=l();!h&&g&&g(x?void 0:o,s,{config:f,state:D}),T&&T(S?void 0:e,t,{config:f,state:D});const B=!h&&p;let E=!1;we(),C("previousStep",s),C("previousElement",o),C("activeStep",t),C("activeElement",e);const w=()=>{if(l("__transitionCallback")!==w)return;const I=Date.now()-i,O=400-I<=400/2;t.popover&&O&&!E&&B&&(oe(e,t),E=!0),a("animate")&&I<400?j(I,400,o,e):(Q(e),d&&d(S?void 0:e,t,{config:a(),state:l()}),C("__transitionCallback",void 0),C("__previousStep",s),C("__previousElement",o),C("__activeStep",t),C("__activeElement",e)),window.requestAnimationFrame(w)};C("__transitionCallback",w),window.requestAnimationFrame(w),n(e),!B&&t.popover&&oe(e,t),o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function ge(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(t=>{t.classList.remove("driver-active-element","driver-no-interaction"),t.removeAttribute("aria-haspopup"),t.removeAttribute("aria-expanded"),t.removeAttribute("aria-controls")})}function U(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),C("__resizeTimeout",window.requestAnimationFrame(me))}function ve(e){var t;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),s=(t=l("popover"))==null?void 0:t.wrapper,o=r([...s?[s]:[],...i?[i]:[]]),h=o[0],S=o[o.length-1];if(e.preventDefault(),e.shiftKey){const x=o[o.indexOf(document.activeElement)-1]||S;x?.focus()}else{const x=o[o.indexOf(document.activeElement)+1]||h;x?.focus()}}function te(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?L("escapePress"):e.key==="ArrowRight"?L("arrowRightPress"):e.key==="ArrowLeft"&&L("arrowLeftPress"))}function re(e,t,i){const s=(o,h)=>{const S=o.target;e.contains(S)&&((!i||i(S))&&(o.preventDefault(),o.stopPropagation(),o.stopImmediatePropagation()),h?.(o))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",o=>{s(o,t)},!0)}function fe(){window.addEventListener("keyup",te,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",U),window.addEventListener("scroll",U)}function ye(){window.removeEventListener("keyup",te),window.removeEventListener("resize",U),window.removeEventListener("scroll",U)}function we(){const e=l("popover");e&&(e.wrapper.style.display="none")}function oe(e,t){var i,s;let o=l("popover");o&&document.body.removeChild(o.wrapper),o=Te(),document.body.appendChild(o.wrapper);const{title:h,description:S,showButtons:x,disableButtons:p,showProgress:T,nextBtnText:d=a("nextBtnText")||"Next →",prevBtnText:g=a("prevBtnText")||"← Previous",progressText:f=a("progressText")||"{current} of {total}"}=t.popover||{};o.nextButton.innerHTML=d,o.previousButton.innerHTML=g,o.progress.innerHTML=f,h?(o.title.innerHTML=h,o.title.style.display="block"):o.title.style.display="none",S?(o.description.innerHTML=S,o.description.style.display="block"):o.description.style.display="none";const D=x||a("showButtons"),B=T||a("showProgress")||!1,E=D?.includes("next")||D?.includes("previous")||B;o.closeButton.style.display=D.includes("close")?"block":"none",E?(o.footer.style.display="flex",o.progress.style.display=B?"block":"none",o.nextButton.style.display=D.includes("next")?"block":"none",o.previousButton.style.display=D.includes("previous")?"block":"none"):o.footer.style.display="none";const w=p||a("disableButtons")||[];w!=null&&w.includes("next")&&(o.nextButton.disabled=!0
Line truncated
|
||||
this.driver=this.driver||{},this.driver.js=(function(y){"use strict";let P={};function _(e={}){P={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function a(e){return e?P[e]:P}function c(e,t,i,s){return(e/=s/2)<1?i/2*e*e+t:-i/2*(--e*(e-2)-1)+t}function r(e){const t='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(i=>{const s=i.matches(t),o=Array.from(i.querySelectorAll(t));return[...s?[i]:[],...o]}).filter(i=>getComputedStyle(i).pointerEvents!=="none"&&k(i))}function n(e){if(!e||A(e))return;const t=a("smoothScroll");e.scrollIntoView({behavior:!t||v(e)?"auto":"smooth",inline:"center",block:"center"})}function v(e){if(!e||!e.parentElement)return;const t=e.parentElement;return t.scrollHeight>t.clientHeight}function A(e){const t=e.getBoundingClientRect();return t.top>=0&&t.left>=0&&t.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&t.right<=(window.innerWidth||document.documentElement.clientWidth)}function k(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let m={};function C(e,t){m[e]=t}function l(e){return e?m[e]:m}function u(){m={}}let b={};function $(e,t){b[e]=t}function L(e){var t;(t=b[e])==null||t.call(b)}function R(){b={}}function j(e,t,i,s){let o=l("__activeStagePosition");const h=o||i.getBoundingClientRect(),T=s.getBoundingClientRect(),x=c(e,h.x,T.x-h.x,t),p=c(e,h.y,T.y-h.y,t),S=c(e,h.width,T.width-h.width,t),d=c(e,h.height,T.height-h.height,t);o={x,y:p,width:S,height:d},X(o),C("__activeStagePosition",o)}function Q(e){if(!e)return;const t=e.getBoundingClientRect(),i={x:t.x,y:t.y,width:t.width,height:t.height};C("__activeStagePosition",i),X(i)}function le(){const e=l("__activeStagePosition"),t=l("__overlaySvg");if(!e)return;if(!t){console.warn("No stage svg found.");return}const i=window.innerWidth,s=window.innerHeight;t.setAttribute("viewBox",`0 0 ${i} ${s}`)}function de(e){const t=pe(e);document.body.appendChild(t),re(t,i=>{i.target.tagName==="path"&&L("overlayClick")}),C("__overlaySvg",t)}function X(e){const t=l("__overlaySvg");if(!t){de(e);return}const i=t.firstElementChild;if(i?.tagName!=="path")throw new Error("no path element found in stage svg");i.setAttribute("d",Z(e))}function pe(e){const t=window.innerWidth,i=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${t} ${i}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const o=document.createElementNS("http://www.w3.org/2000/svg","path");return o.setAttribute("d",Z(e)),o.style.fill=a("overlayColor")||"rgb(0,0,0)",o.style.opacity=`${a("overlayOpacity")}`,o.style.pointerEvents="auto",o.style.cursor="auto",s.appendChild(o),s}function Z(e){const t=window.innerWidth,i=window.innerHeight,s=a("stagePadding")||0,o=a("stageRadius")||0,h=e.width+s*2,T=e.height+s*2,x=Math.min(o,h/2,T/2),p=Math.floor(Math.max(x,0)),S=e.x-s+p,d=e.y-s,g=h-p*2,f=T-p*2;return`M${t},0L0,0L0,${i}L${t},${i}L${t},0Z
|
||||
M${S},${d} h${g} a${p},${p} 0 0 1 ${p},${p} v${f} a${p},${p} 0 0 1 -${p},${p} h-${g} a${p},${p} 0 0 1 -${p},-${p} v-${f} a${p},${p} 0 0 1 ${p},-${p} z`}function ce(){const e=l("__overlaySvg");e&&e.remove()}function ue(){const e=document.getElementById("driver-dummy-element");if(e)return e;let t=document.createElement("div");return t.id="driver-dummy-element",t.style.width="0",t.style.height="0",t.style.pointerEvents="none",t.style.opacity="0",t.style.position="fixed",t.style.top="50%",t.style.left="50%",document.body.appendChild(t),t}function ee(e){const{element:t}=e;let i=typeof t=="string"?document.querySelector(t):t;i||(i=ue()),he(i,e)}function me(){const e=l("__activeElement"),t=l("__activeStep");e&&(Q(e),le(),ae(e,t))}function he(e,t){const i=Date.now(),s=l("__activeStep"),o=l("__activeElement")||e,h=!o||o===e,T=e.id==="driver-dummy-element",x=o.id==="driver-dummy-element",p=a("animate"),S=t.onHighlightStarted||a("onHighlightStarted"),d=t?.onHighlighted||a("onHighlighted"),g=s?.onDeselected||a("onDeselected"),f=a(),D=l();!h&&g&&g(x?void 0:o,s,{config:f,state:D}),S&&S(T?void 0:e,t,{config:f,state:D});const B=!h&&p;let E=!1;we(),C("previousStep",s),C("previousElement",o),C("activeStep",t),C("activeElement",e);const w=()=>{if(l("__transitionCallback")!==w)return;const I=Date.now()-i,O=400-I<=400/2;t.popover&&O&&!E&&B&&(oe(e,t),E=!0),a("animate")&&I<400?j(I,400,o,e):(Q(e),d&&d(T?void 0:e,t,{config:a(),state:l()}),C("__transitionCallback",void 0),C("__previousStep",s),C("__previousElement",o),C("__activeStep",t),C("__activeElement",e)),window.requestAnimationFrame(w)};C("__transitionCallback",w),window.requestAnimationFrame(w),n(e),!B&&t.popover&&oe(e,t),o.classList.remove("driver-active-element","driver-no-interaction"),o.removeAttribute("aria-haspopup"),o.removeAttribute("aria-expanded"),o.removeAttribute("aria-controls"),a("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function ge(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(t=>{t.classList.remove("driver-active-element","driver-no-interaction"),t.removeAttribute("aria-haspopup"),t.removeAttribute("aria-expanded"),t.removeAttribute("aria-controls")})}function U(){const e=l("__resizeTimeout");e&&window.cancelAnimationFrame(e),C("__resizeTimeout",window.requestAnimationFrame(me))}function ve(e){var t;if(!l("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const i=l("__activeElement"),s=(t=l("popover"))==null?void 0:t.wrapper,o=r([...s?[s]:[],...i?[i]:[]]),h=o[0],T=o[o.length-1];if(e.preventDefault(),e.shiftKey){const x=o[o.indexOf(document.activeElement)-1]||T;x?.focus()}else{const x=o[o.indexOf(document.activeElement)+1]||h;x?.focus()}}function te(e){var t;((t=a("allowKeyboardControl"))==null||t)&&(e.key==="Escape"?L("escapePress"):e.key==="ArrowRight"?L("arrowRightPress"):e.key==="ArrowLeft"&&L("arrowLeftPress"))}function re(e,t,i){const s=(o,h)=>{const T=o.target;e.contains(T)&&((!i||i(T))&&(o.preventDefault(),o.stopPropagation(),o.stopImmediatePropagation()),h?.(o))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",o=>{s(o,t)},!0)}function fe(){window.addEventListener("keyup",te,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",U),window.addEventListener("scroll",U)}function ye(){window.removeEventListener("keyup",te),window.removeEventListener("resize",U),window.removeEventListener("scroll",U)}function we(){const e=l("popover");e&&(e.wrapper.style.display="none")}function oe(e,t){var i,s;let o=l("popover");o&&document.body.removeChild(o.wrapper),o=Se(),document.body.appendChild(o.wrapper);const{title:h,description:T,showButtons:x,disableButtons:p,showProgress:S,nextBtnText:d=a("nextBtnText")||"Next →",prevBtnText:g=a("prevBtnText")||"← Previous",progressText:f=a("progressText")||"{current} of {total}"}=t.popover||{};o.nextButton.innerHTML=d,o.previousButton.innerHTML=g,o.progress.innerHTML=f,h?(o.title.innerHTML=h,o.title.style.display="block"):o.title.style.display="none",T?(o.description.innerHTML=T,o.description.style.display="block"):o.description.style.display="none";const D=x||a("showButtons"),B=S||a("showProgress")||!1,E=D?.includes("next")||D?.includes("previous")||B;o.closeButton.style.display=D.includes("close")?"block":"none",E?(o.footer.style.display="flex",o.progress.style.display=B?"block":"none",o.nextButton.style.display=D.includes("next")?"block":"none",o.previousButton.style.display=D.includes("previous")?"block":"none"):o.footer.style.display="none";const w=p||a("disableButtons")||[];w!=null&&w.includes("next")&&(o.nextButton.disabled=!0
Line truncated
|
||||
.driver-popover {
|
||||
background: ${n.backgroundColor} !important;
|
||||
color: ${n.textColor} !important;
|
||||
@@ -154,7 +154,7 @@ ${b}`)}}y.TooltipValidation={validateTooltipDefinition:a,validateTooltipDefiniti
|
||||
<p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin → License</strong></p>
|
||||
</div>
|
||||
<p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin → Help Tour</strong>.</p>
|
||||
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function A(){return v}function k(u){return v.find(b=>b.id===u)||null}function m(){return v.filter(u=>{if(u.condition&&typeof u.condition=="function")try{return u.condition()}catch(b){return P.logError("[TooltipDefinitions] Condition Eval",b,{function:"evaluateCondition",tooltipId:u.id}),!1}return!0})}function C(){return m().sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}function l(){return m().filter(b=>b.isNewFeature===!0).sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}y.TooltipDefinitions={TOOLTIP_DEFINITIONS:v,getTooltipDefinitions:A,getTooltipById:k,getActiveTooltips:m,getSortedTooltips:C,getNewFeatureTooltips:l},_("[TooltipDefinitions] Definitions loaded:",v.length,"tooltips")})(window),(function(y){"use strict";const P=(...a)=>{y.DASHCADDY_DEBUG&&console.log(...a)};class _{constructor(c){this.progressTracker=c,this.modal=null,this.onTemplateSelected=null,P("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const c=document.createElement("div");c.id="dns-template-modal",c.className="dns-template-modal",c.innerHTML=`
|
||||
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function A(){return v}function k(u){return v.find(b=>b.id===u)||null}function m(){return v.filter(u=>{if(u.condition&&typeof u.condition=="function")try{return u.condition()}catch(b){return P.logError("[TooltipDefinitions] Condition Eval",b,{function:"evaluateCondition",tooltipId:u.id}),!1}return!0})}function C(){return m().sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}function l(){return m().filter(b=>b.isNewFeature===!0).sort((b,$)=>{const L=b.priority||999,R=$.priority||999;return L-R})}y.TooltipDefinitions={TOOLTIP_DEFINITIONS:v,getTooltipDefinitions:A,getTooltipById:k,getActiveTooltips:m,getSortedTooltips:C,getNewFeatureTooltips:l},_("[TooltipDefinitions] Definitions loaded:",v.length,"tooltips")})(window),(function(y){"use strict";const P=(...a)=>{y.DASHCADDY_DEBUG&&console.log(...a)};class _{constructor(c){this.progressTracker=c,this.modal=null,this.onTemplateSelected=null,P("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1},{id:"cloudflare",name:"Cloudflare DNS",description:"Managed DNS with API access \u2014 no self-hosting needed",icon:"\u{1F536}",difficulty:"Easy",features:["Fully managed, no server needed","API for automated record management","Global anycast network","Free tier available"],recommended:!1,providerId:"cloudflare"},{id:"external",name:"External / Manual DNS",description:"Use your own DNS provider (cPanel, Route53, etc.)",icon:"\u{1F517}",difficulty:"Easy",features:["Works with any DNS provider","DashCaddy shows you what records to create","Propagation checking still works","No API credentials needed"],recommended:!1,providerId:"manual"}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const c=document.createElement("div");c.id="dns-template-modal",c.className="dns-template-modal",c.innerHTML=`
|
||||
<div class="dns-template-modal-content">
|
||||
<div class="dns-template-header">
|
||||
<h2>\u{1F310} Choose a DNS Server</h2>
|
||||
|
||||
@@ -256,9 +256,6 @@
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
</select>
|
||||
<select id="service-filter-category" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Categories</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
|
||||
@@ -95,8 +95,6 @@
|
||||
const card = el('div', 'card');
|
||||
card.setAttribute('data-app', s.id);
|
||||
card.setAttribute('data-status', 'off'); // Initial status
|
||||
if (s.containerId) card.setAttribute('data-container-id', s.containerId);
|
||||
if (s.category) card.setAttribute('data-category', s.category);
|
||||
if (s.recipeId) card.setAttribute('data-recipe-id', s.recipeId);
|
||||
|
||||
const dot = el('span', 'dot bad at-bl'); dot.id = 'dot-' + s.id + '-grid'; card.appendChild(dot);
|
||||
@@ -158,16 +156,6 @@
|
||||
nameSpan.appendChild(tsBadge);
|
||||
}
|
||||
|
||||
// Add Category badge if service has one (colored pill with icon)
|
||||
if (s.category) {
|
||||
const cats = (typeof DC !== 'undefined' && DC.CATEGORIES) || window.DC_CATEGORIES || {};
|
||||
const catInfo = cats[s.category] || {};
|
||||
const catBadge = el('span', 'cat-badge', `${catInfo.icon || ''} ${s.category}`.trim());
|
||||
catBadge.title = `Category: ${s.category}`;
|
||||
catBadge.style.cssText = `margin-left: 6px; font-size: 0.65rem; padding: 1px 6px; border-radius: 999px; background: color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 25%, transparent); color: ${catInfo.color || '#7f8c8d'}; border: 1px solid color-mix(in srgb, ${catInfo.color || '#7f8c8d'} 50%, transparent); white-space: nowrap; font-weight: 500;`;
|
||||
nameSpan.appendChild(catBadge);
|
||||
}
|
||||
|
||||
row.appendChild(el('span', 'spacer'));
|
||||
|
||||
const pill = el('span', 'badge off', 'OFF'); pill.id = 'badge-' + s.id; row.appendChild(pill);
|
||||
@@ -294,9 +282,6 @@
|
||||
|
||||
// Group recipe cards visually after grid is built
|
||||
if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards());
|
||||
|
||||
// Refresh the service filter so the category dropdown reflects new services
|
||||
if (window.refreshServiceFilter) window.refreshServiceFilter();
|
||||
}
|
||||
|
||||
function setBadge(id, up, responseTime = null) {
|
||||
|
||||
@@ -59,13 +59,11 @@
|
||||
}
|
||||
_dashboardInitialized = true;
|
||||
await window.loadServices();
|
||||
await loadTemplateCategories();
|
||||
window.buildGrid();
|
||||
animateTopCards();
|
||||
window.refreshAll();
|
||||
setInterval(window.refreshAll, DC.POLL.DASHBOARD);
|
||||
if (typeof window.refreshCredsButtons === 'function') window.refreshCredsButtons();
|
||||
if (typeof window.refreshMonitoringWidgets === 'function') window.refreshMonitoringWidgets();
|
||||
// Update auth card (may have already been updated by the auto-load IIFE but ensure it's correct)
|
||||
if (typeof window._updateAuthCard === 'function') {
|
||||
try {
|
||||
@@ -202,55 +200,6 @@
|
||||
window.loadCustomServices = loadCustomServices;
|
||||
registerServiceWorker();
|
||||
|
||||
// ===== TEMPLATE CATEGORIES =====
|
||||
// Cached template categories from /api/v1/templates for use across the UI
|
||||
// (service create/edit, filter dropdown, category badges, etc.)
|
||||
async function loadTemplateCategories() {
|
||||
try {
|
||||
const r = await fetch('/api/v1/templates', { cache: 'no-store' });
|
||||
if (!r.ok) return;
|
||||
const data = await r.json();
|
||||
if (data && data.categories) {
|
||||
window.DC_CATEGORIES = data.categories;
|
||||
// Also expose via globals.js constant for convenience
|
||||
if (typeof DC !== 'undefined') DC.CATEGORIES = data.categories;
|
||||
// Populate any category <select> that's already in the DOM
|
||||
populateCategorySelects();
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[init] Failed to load template categories:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function populateCategorySelects() {
|
||||
const cats = window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES);
|
||||
if (!cats) return;
|
||||
document.querySelectorAll('select[data-role="service-category"]').forEach(select => {
|
||||
const current = select.dataset.current || '';
|
||||
// Clear options but keep the first (placeholder)
|
||||
const placeholder = select.querySelector('option[value=""]');
|
||||
select.innerHTML = '';
|
||||
if (placeholder) select.appendChild(placeholder);
|
||||
else {
|
||||
const ph = document.createElement('option');
|
||||
ph.value = '';
|
||||
ph.textContent = '— Select category —';
|
||||
select.appendChild(ph);
|
||||
}
|
||||
Object.entries(cats).forEach(([name, info]) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = `${info.icon || ''} ${name}`.trim();
|
||||
if (name === current) opt.selected = true;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Allow other modules to re-run population after they (re)inject selects
|
||||
window.populateCategorySelects = populateCategorySelects;
|
||||
window.loadTemplateCategories = loadTemplateCategories;
|
||||
|
||||
// TOTP-gated initialization
|
||||
(async () => {
|
||||
try {
|
||||
|
||||
@@ -262,7 +262,6 @@
|
||||
const proxyIp = document.getElementById('external-proxy-ip').value.trim() || SITE.dnsIp || 'localhost';
|
||||
const preserveHost = document.getElementById('external-preserve-host').checked;
|
||||
const followRedirects = document.getElementById('external-follow-redirects').checked;
|
||||
const category = document.getElementById('external-service-category')?.value || '';
|
||||
|
||||
if (!name || !externalUrl) {
|
||||
showNotification('Please fill in Name and External URL', 'warning');
|
||||
@@ -342,8 +341,6 @@
|
||||
isExternal: true,
|
||||
isCustom: true
|
||||
};
|
||||
// Only attach category if user actually picked one
|
||||
if (category) newService.category = category;
|
||||
|
||||
window.APPS.push(newService);
|
||||
results.dashboard = true;
|
||||
@@ -460,13 +457,6 @@
|
||||
const healthCheck = document.getElementById('health-check-input')?.value || '';
|
||||
const timeout = document.getElementById('timeout-input')?.value || 30;
|
||||
|
||||
// Category is optional — pulled from either local or external select by the
|
||||
// openAddServiceModal reset. If user doesn't choose one, it stays undefined
|
||||
// and we don't send it (so the backend keeps the existing behavior).
|
||||
const categoryEl = document.getElementById('service-category-input')
|
||||
|| document.getElementById('external-service-category');
|
||||
const category = categoryEl?.value || '';
|
||||
|
||||
const dnsToken = window.getToken(getPrimaryDnsId(), 'admin');
|
||||
|
||||
if (!name || !port || !ip) {
|
||||
@@ -535,8 +525,6 @@
|
||||
logo: logo || `/assets/${subdomain}.png`,
|
||||
tailscaleOnly: tailscaleOnly || false
|
||||
};
|
||||
// Only include category if user actually picked one
|
||||
if (category) serviceConfig.category = category;
|
||||
|
||||
await window.addServiceToConfig(serviceConfig);
|
||||
results.dashboard = true;
|
||||
|
||||
@@ -19,16 +19,6 @@
|
||||
document.getElementById('edit-tailscale-only').checked = service.tailscaleOnly || false;
|
||||
document.getElementById('edit-logo-url').value = service.logo || '';
|
||||
|
||||
// Populate the category select for this service, then set the current value.
|
||||
// populateCategorySelects() uses data-current so we set it first, then call.
|
||||
const categorySelect = document.getElementById('edit-service-category');
|
||||
if (categorySelect) {
|
||||
categorySelect.dataset.current = service.category || '';
|
||||
if (typeof window.populateCategorySelects === 'function') {
|
||||
window.populateCategorySelects();
|
||||
}
|
||||
}
|
||||
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
@@ -46,7 +36,6 @@
|
||||
const newIp = document.getElementById('edit-ip').value.trim() || 'localhost';
|
||||
const tailscaleOnly = document.getElementById('edit-tailscale-only').checked;
|
||||
const newLogo = document.getElementById('edit-logo-url').value.trim();
|
||||
const newCategory = document.getElementById('edit-service-category')?.value || '';
|
||||
|
||||
if (!newSubdomain) {
|
||||
showNotification('Subdomain is required', 'warning');
|
||||
@@ -62,7 +51,6 @@
|
||||
if (newIp !== currentEditService.ip) changes.push('ip');
|
||||
if (tailscaleOnly !== (currentEditService.tailscaleOnly || false)) changes.push('tailscale');
|
||||
if (newLogo && newLogo !== currentEditService.logo) changes.push('logo');
|
||||
if (newCategory !== (currentEditService.category || '')) changes.push('category');
|
||||
|
||||
if (changes.length === 0) {
|
||||
closeServiceEditModal();
|
||||
@@ -84,8 +72,7 @@
|
||||
port: newPort || currentEditService.port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || undefined,
|
||||
category: newCategory
|
||||
logo: newLogo || undefined
|
||||
})
|
||||
});
|
||||
|
||||
@@ -104,8 +91,7 @@
|
||||
port: newPort || window.APPS[appIndex].port,
|
||||
ip: newIp,
|
||||
tailscaleOnly,
|
||||
logo: newLogo || window.APPS[appIndex].logo,
|
||||
category: newCategory || undefined
|
||||
logo: newLogo || window.APPS[appIndex].logo
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@
|
||||
name: serviceConfig.name,
|
||||
logo: serviceConfig.logo || `/assets/${serviceConfig.subdomain}.png`
|
||||
};
|
||||
// Forward optional metadata fields if provided
|
||||
if (serviceConfig.category) newService.category = serviceConfig.category;
|
||||
if (serviceConfig.containerId) newService.containerId = serviceConfig.containerId;
|
||||
|
||||
try {
|
||||
const response = await secureFetch('/api/v1/services', {
|
||||
|
||||
@@ -82,16 +82,6 @@
|
||||
Enter a URL or upload an image file (PNG, JPG, SVG)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="edit-service-category" class="form-label-accent-sm">
|
||||
Category
|
||||
</label>
|
||||
<select id="edit-service-category" data-role="service-category" class="form-input-md">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
||||
@@ -249,15 +239,6 @@
|
||||
Reload Caddy after adding
|
||||
</label>
|
||||
|
||||
<!-- Category -->
|
||||
<div>
|
||||
<label for="service-category-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||
<select id="service-category-input" data-role="service-category" style="width: 100%;">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-top: 3px;">Group services on the dashboard by purpose (Media, Productivity, etc.)</div>
|
||||
</div>
|
||||
|
||||
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
||||
|
||||
<div class="grid-2col">
|
||||
@@ -345,14 +326,6 @@
|
||||
Follow Redirects
|
||||
</label>
|
||||
|
||||
<!-- Category (external) -->
|
||||
<div>
|
||||
<label for="external-service-category" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Category</label>
|
||||
<select id="external-service-category" data-role="service-category" style="width: 100%;">
|
||||
<option value="">— No category —</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -2,50 +2,11 @@
|
||||
(function() {
|
||||
const searchInput = document.getElementById('service-filter-search');
|
||||
const statusSelect = document.getElementById('service-filter-status');
|
||||
const categorySelect = document.getElementById('service-filter-category');
|
||||
const countSpan = document.getElementById('service-filter-count');
|
||||
|
||||
// Build a single category list from both the API categories and any
|
||||
// categories present on the actual rendered cards (covers custom services
|
||||
// whose category isn't in TEMPLATE_CATEGORIES).
|
||||
function getCategoryList() {
|
||||
const seen = new Set();
|
||||
const fromCards = new Set();
|
||||
document.querySelectorAll('#cards .card[data-category]').forEach(c => {
|
||||
const cat = c.dataset.category.trim();
|
||||
if (cat) fromCards.add(cat);
|
||||
});
|
||||
const apiCats = (window.DC_CATEGORIES || (typeof DC !== 'undefined' && DC.CATEGORIES)) || {};
|
||||
const all = Object.keys(apiCats).concat([...fromCards].filter(c => !apiCats[c]));
|
||||
all.forEach(c => seen.add(c));
|
||||
return { list: [...seen], apiCats };
|
||||
}
|
||||
|
||||
function refreshCategoryDropdown() {
|
||||
if (!categorySelect) return;
|
||||
const { list, apiCats } = getCategoryList();
|
||||
const current = categorySelect.value;
|
||||
categorySelect.innerHTML = '<option value="all">All Categories</option>';
|
||||
list.sort().forEach(name => {
|
||||
const info = apiCats[name];
|
||||
const opt = document.createElement('option');
|
||||
opt.value = name;
|
||||
opt.textContent = info ? `${info.icon || ''} ${name}`.trim() : name;
|
||||
categorySelect.appendChild(opt);
|
||||
});
|
||||
// Restore selection if it still exists
|
||||
if (current && [...categorySelect.options].some(o => o.value === current)) {
|
||||
categorySelect.value = current;
|
||||
} else {
|
||||
categorySelect.value = 'all';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFilter() {
|
||||
refreshCategoryDropdown();
|
||||
const query = searchInput.value.toLowerCase().trim();
|
||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||
const categoryFilter = categorySelect ? categorySelect.value : 'all';
|
||||
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
let visibleCount = 0;
|
||||
@@ -54,13 +15,11 @@
|
||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||
const app = card.dataset.app?.toLowerCase() || '';
|
||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||
const category = card.dataset.category || '';
|
||||
|
||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
const matchesCategory = categoryFilter === 'all' || category === categoryFilter;
|
||||
|
||||
if (matchesSearch && matchesStatus && matchesCategory) {
|
||||
if (matchesSearch && matchesStatus) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
@@ -85,7 +44,6 @@
|
||||
|
||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||
statusSelect?.addEventListener('change', updateFilter);
|
||||
categorySelect?.addEventListener('change', updateFilter);
|
||||
|
||||
// Initial count on page load
|
||||
if (document.readyState === 'loading') {
|
||||
@@ -94,7 +52,6 @@
|
||||
setTimeout(updateFilter, 500);
|
||||
}
|
||||
|
||||
// Expose for external triggers (called after buildGrid to repopulate categories)
|
||||
// Expose for external triggers
|
||||
window.refreshServiceFilter = updateFilter;
|
||||
window.refreshCategoryDropdown = refreshCategoryDropdown;
|
||||
})();
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-43a872cc40';
|
||||
const CACHE = 'dashcaddy-shell-594ec75648';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in new issue
Block a user