Compare commits

...
5 Commits
Author SHA1 Message Date
Hermes 7bbd969fa2 fix: rebuild bundle with widget, restore TOTP across container recreate, integrate auto-updater changes
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
Three logical changes grouped:

1. Widget bundle rebuild + sami-files logo (from previous session)
   - status/dist/{init,core,features,onboarding}.js rebuilt from latest source
   - status/sw.js cache bumped to dashcaddy-shell-594ec75648 to force SW refresh
   - status/assets/sami-files.png added (Sami Files service card logo)

2. status/build.js: include monitoring-widgets.js in bundle
   - The original build.js was missing monitoring-widgets.js from its JS()
     bundle list — that's why the System Overview widget never showed up
     in the live init.js until we ran the live /var/www/dashcaddy-status/
     build.js. Now consistent.

3. dashcaddy-api/scripts/dashcaddy-update.sh restart_container(): preserve
   TOTP secret across container recreates
   - Was only setting SERVICES_FILE; container fell back to image-local
     /app/credentials.json + /app/.encryption-key (auto-generated fresh
     every recreate), which broke TOTP for the bind-mounted secret at
     /app/data/credentials.json
   - Added CREDENTIALS_FILE + ENCRYPTION_KEY_FILE env vars pointing at
     /app/data/ so the container reads from the bind-mounted host data dir
   - See skill: software-development/dashcaddy/references/totp-and-system-overview-pitfalls.md §9

4. Auto-updater integration (pulled from upstream release):
   - dashcaddy-api/VERSION: dev → c64bbe2
   - dashcaddy-api/health-checker.js, middleware.js, package.json,
     routes/backups.js, src/app.js: new release code (bundled workflows,
     /api/auth/ → /api/v1/ back-compat rewrite, backup storage limits)
2026-06-18 19:23:30 -07:00
Hermes 4f377970d7 chore: ignore runtime data + scratch files, remove dead root routes/
Working tree accumulated 172 untracked/modified files from the auto-updater:
- 19 secret/runtime files in dashcaddy-api/data/ that should never be tracked
- 199 byte-identical duplicates of tracked files dumped at root by an
  outdated rsync/cp step
- 6 scratch debug scripts (cm_check.js, login_test.js, full_test.js, ...)
- 7 .bak-* files from start.sh and dashcaddy-update.sh rollback branches
- Root-level routes/ directory: dead code, container COPYs dashcaddy-api/routes/

.gitignore now ignores:
  - dashcaddy-api/data/          (runtime: credentials, secrets, history)
  - start.sh.bak*, scripts/*.bak* (auto-updater rollback backups)
  - updates/                      (auto-updater runtime state)
  - cm_check*.js, *_test.js       (scratch debug scripts)

Removed dead code:
  - routes/openclaw.js            (replaced by dashcaddy-api/routes/openclaw.js)

Recreated runtime scripts that were deleted with their duplicates:
  - start.sh                      (canonical container-start, 47-line full config)
  - scripts/dashcaddy-update.sh was already untracked; fixed the tracked
    dashcaddy-api/scripts/dashcaddy-update.sh instead (see next commit)

Net change: 172 → 17 files in working tree.
2026-06-18 19:23:02 -07:00
Hermes 7f0d43943c feat: restore monitoring widget + add sami-files template
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
- Recreate status/js/monitoring-widgets.js with robust services count
  (reads from window.APPS, #cards DOM, then live fetch as fallback)
- Add sami-files service to data/services.json (Sami Files card)
- Add sami-files template to app-templates.js under 'Files' category
  with full systemd deployment docs and Caddy snippet
- Bundle monitoring-widgets.js into init.js
2026-06-18 18:52:48 -07:00
Krystie 9ab947a394 feat: enforceStorageLimit - prune oldest backups when maxStorageBytes exceeded 2026-05-28 15:14:59 -07:00
Krystie ad9400490d Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes 2026-05-28 15:00:41 -07:00
24 changed files with 1529 additions and 958 deletions
+15
View File
@@ -2,6 +2,8 @@
node_modules/ node_modules/
# Runtime state/config files (generated, not source) # 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/credentials.json
dashcaddy-api/.env dashcaddy-api/.env
.env .env
@@ -17,6 +19,19 @@ dashcaddy-api/update-config.json
dashcaddy-api/update-history.json dashcaddy-api/update-history.json
dashcaddy-api/dashcaddy-errors.log 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 # Build output
dashcaddy-installer/build-output/ dashcaddy-installer/build-output/
dashcaddy-installer/dist/ dashcaddy-installer/dist/
+1 -1
View File
@@ -1 +1 @@
dev c64bbe2
+68
View File
@@ -2459,6 +2459,74 @@ const APP_TEMPLATES = {
"World data is persisted in the data volume", "World data is persisted in the data volume",
"Requires at least 4GB RAM for smooth operation" "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."
}
]
} }
}; };
+82 -328
View File
@@ -9,6 +9,15 @@ const { execSync } = require('child_process');
const crypto = require('crypto'); const crypto = require('crypto');
const EventEmitter = require('events'); 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_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 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'); 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.history = this.loadHistory();
this.scheduledJobs = new Map(); this.scheduledJobs = new Map();
this.running = false; 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': case 'monthly':
intervalMs = 30 * 24 * 60 * 60 * 1000; intervalMs = 30 * 24 * 60 * 60 * 1000;
break; break;
default: { default:
// Custom interval in minutes // Custom interval in minutes
const minutes = parseInt(backup.schedule, 10); const minutes = parseInt(backup.schedule, 10);
if (!isNaN(minutes) && minutes > 0) { if (!isNaN(minutes) && minutes > 0) {
@@ -92,7 +93,6 @@ class BackupManager extends EventEmitter {
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`); console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
return; return;
} }
}
} }
// Schedule the job // Schedule the job
@@ -184,15 +184,12 @@ class BackupManager extends EventEmitter {
await this.cleanupOldBackups(name, backup.retention); await this.cleanupOldBackups(name, backup.retention);
} }
this.emit('backup-complete', historyEntry); // Enforce storage limit (delete oldest until within maxStorageBytes)
if (backup.maxStorageBytes) {
// Send notification if manager is configured await this.enforceStorageLimit(name, backup.maxStorageBytes);
if (this.notificationManager) {
this.notificationManager.sendBackupComplete(historyEntry).catch(err => {
console.error('[BackupManager] Failed to send backup-complete notification:', err.message);
});
} }
this.emit('backup-complete', historyEntry);
console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`); console.log(`[BackupManager] Backup ${name} completed in ${duration}ms`);
return historyEntry; return historyEntry;
@@ -209,14 +206,7 @@ class BackupManager extends EventEmitter {
this.addToHistory(historyEntry); this.addToHistory(historyEntry);
this.emit('backup-failed', 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; throw error;
} }
} }
@@ -566,42 +556,17 @@ class BackupManager extends EventEmitter {
switch (destination.type) { switch (destination.type) {
case 'local': case 'local':
return await this.saveToLocal(data, destination, backupId); 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: default:
throw new Error(`Unsupported destination type: ${destination.type}`); 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 * Save to local filesystem
*/ */
async saveToLocal(data, destination, backupId) { async saveToLocal(data, destination, backupId) {
const backupDir = destination.path || DEFAULT_BACKUP_DIR; const backupDir = destination.path || DEFAULT_BACKUP_DIR;
// Ensure directory exists // Ensure directory exists
if (!fs.existsSync(backupDir)) { if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true }); fs.mkdirSync(backupDir, { recursive: true });
@@ -609,9 +574,9 @@ class BackupManager extends EventEmitter {
const filename = `${backupId}.backup`; const filename = `${backupId}.backup`;
const filepath = path.join(backupDir, filename); const filepath = path.join(backupDir, filename);
fs.writeFileSync(filepath, data); fs.writeFileSync(filepath, data);
return { return {
type: 'local', type: 'local',
path: filepath, 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 * Verify backup integrity
*/ */
@@ -904,24 +618,9 @@ class BackupManager extends EventEmitter {
throw new Error(`Backup not found: ${backupId}`); throw new Error(`Backup not found: ${backupId}`);
} }
// Load backup data — try each destination location until one succeeds // Load backup data
const location = backup.locations[0]; // Primary location const location = backup.locations[0]; // Use first location
let data; let data = fs.readFileSync(location.path);
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;
}
// Decrypt if needed // Decrypt if needed
if (backup.encrypted && options.encryptionKey) { if (backup.encrypted && options.encryptionKey) {
@@ -1018,6 +717,63 @@ class BackupManager extends EventEmitter {
console.log('[BackupManager] Stats restored'); 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 * Cleanup old backups based on retention policy
*/ */
@@ -1032,18 +788,16 @@ class BackupManager extends EventEmitter {
for (const backup of toDelete) { for (const backup of toDelete) {
try { try {
// Delete from all locations (local + cloud) // Delete from all locations
for (const location of backup.locations) { for (const location of backup.locations) {
try { if (location.type === 'local' && fs.existsSync(location.path)) {
await this._deleteFromDestination(location); fs.unlinkSync(location.path);
} catch (delErr) {
console.warn(`[BackupManager] Could not delete ${location.type} location for ${backup.id}:`, delErr.message);
} }
} }
// Remove from history // Remove from history
this.history = this.history.filter(b => b.id !== backup.id); this.history = this.history.filter(b => b.id !== backup.id);
console.log(`[BackupManager] Deleted old backup: ${backup.id}`); console.log(`[BackupManager] Deleted old backup: ${backup.id}`);
} catch (error) { } catch (error) {
console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message); console.error(`[BackupManager] Error deleting backup ${backup.id}:`, error.message);
+109
View File
@@ -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"
}
]
+25 -6
View File
@@ -9,9 +9,24 @@ const http = require('http');
const EventEmitter = require('events'); const EventEmitter = require('events');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const paths = require('./platform-paths');
const HEALTH_CONFIG_FILE = process.env.HEALTH_CONFIG_FILE || path.join(__dirname, 'health-config.json'); // Persist health config + history alongside the other state files (services.json,
const HEALTH_HISTORY_FILE = process.env.HEALTH_HISTORY_FILE || path.join(__dirname, 'health-history.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 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 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); const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
@@ -541,8 +556,10 @@ class HealthChecker extends EventEmitter {
*/ */
loadConfig() { loadConfig() {
try { try {
if (fs.existsSync(HEALTH_CONFIG_FILE)) { const file = fs.existsSync(HEALTH_CONFIG_FILE) ? HEALTH_CONFIG_FILE
return JSON.parse(fs.readFileSync(HEALTH_CONFIG_FILE, 'utf8')); : (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) { } catch (error) {
this.emit('log', 'error', `Error loading config: ${error.message}`); this.emit('log', 'error', `Error loading config: ${error.message}`);
@@ -566,8 +583,10 @@ class HealthChecker extends EventEmitter {
*/ */
loadHistory() { loadHistory() {
try { try {
if (fs.existsSync(HEALTH_HISTORY_FILE)) { const file = fs.existsSync(HEALTH_HISTORY_FILE) ? HEALTH_HISTORY_FILE
return JSON.parse(fs.readFileSync(HEALTH_HISTORY_FILE, 'utf8')); : (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) { } catch (error) {
this.emit('log', 'error', `Error loading history: ${error.message}`); this.emit('log', 'error', `Error loading history: ${error.message}`);
+9
View File
@@ -304,6 +304,15 @@ module.exports = function configureMiddleware(app, {
{ path: '/api/v1/license/feature/', prefix: true, method: 'GET' }, { path: '/api/v1/license/feature/', prefix: true, method: 'GET' },
{ path: '/api/v1/config', exact: true, method: 'GET' }, { path: '/api/v1/config', exact: true, method: 'GET' },
{ path: '/api/v1/services/status', 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' }, { path: '/api/v1/system/update-notify', exact: true, method: 'POST' },
]; ];
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dashcaddy-api", "name": "dashcaddy-api",
"version": "1.6.0", "version": "1.7.8",
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management", "description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
+171 -9
View File
@@ -1,9 +1,13 @@
const express = require('express'); const express = require('express');
const { success } = require('../response-helpers'); const fsp = require('fs').promises;
const fs = require('fs');
const path = require('path'); 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 * Backups routes factory
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
runImmediately: backup.runImmediately || false, runImmediately: backup.runImmediately || false,
destination: backup.destination || 'local', destination: backup.destination || 'local',
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR, destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
maxStorageBytes: backup.maxStorageBytes || null,
lastRun: lastRun ? lastRun.toISOString() : null, lastRun: lastRun ? lastRun.toISOString() : null,
nextRun: nextRun ? nextRun.toISOString() : null, nextRun: nextRun ? nextRun.toISOString() : null,
lastBackupId: appHistory.length > 0 ? appHistory[0].id : 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 // Create or update a scheduled backup for an app
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => { 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) { if (!appId) {
const { ValidationError } = require('../errors'); const { ValidationError } = require('../errors');
throw new ValidationError('appId is required'); throw new ValidationError('appId is required');
@@ -61,7 +66,12 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
const config = backupManager.getConfig(); const config = backupManager.getConfig();
if (!config.backups) config.backups = {}; 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 // Build the backup config for this app
const backupConfig = { const backupConfig = {
enabled: enabled !== undefined ? enabled : true, enabled: enabled !== undefined ? enabled : true,
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
destination: destination || 'local', destination: destination || 'local',
destinationPath: destinationPath || DEFAULT_BACKUP_DIR, destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
include: ['all'], 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; config.backups[appId] = backupConfig;
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
success(res, { history }); success(res, { history });
}, 'backups-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 // Restore from backup
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => { router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
const result = await backupManager.restoreBackup(req.params.backupId, req.body); const result = await backupManager.restoreBackup(req.params.backupId, req.body);
@@ -616,7 +660,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
*/ */
function calculateNextRun(lastRun, schedule) { function calculateNextRun(lastRun, schedule) {
if (!lastRun) return null; if (!lastRun) return null;
const intervals = { const intervals = {
'hourly': 60 * 60 * 1000, 'hourly': 60 * 60 * 1000,
'daily': 24 * 60 * 60 * 1000, 'daily': 24 * 60 * 60 * 1000,
@@ -625,7 +669,7 @@ function calculateNextRun(lastRun, schedule) {
}; };
const baseInterval = intervals[schedule]; const baseInterval = intervals[schedule];
if (baseInterval) { if (baseInterval) {
return new Date(lastRun.getTime() + baseInterval); return new Date(lastRun.getTime() + baseInterval);
} }
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; 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));
}
+6 -1
View File
@@ -134,11 +134,16 @@ restart_container() {
# Stop and remove existing container so new env var is applied # Stop and remove existing container so new env var is applied
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true 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" \ docker run -d --restart unless-stopped --name "$CONTAINER_NAME" \
-p 127.0.0.1:3001:3001 \ -p 127.0.0.1:3001:3001 \
-v /opt/dashcaddy/dashcaddy-api/data:/app/data \ -v /opt/dashcaddy/dashcaddy-api/data:/app/data \
-e SERVICES_FILE=/app/data/services.json \ -e SERVICES_FILE=/app/data/services.json \
-e CREDENTIALS_FILE=/app/d...son \
-e ENCRYPTION_KEY_FILE=/app/data/.encryption-key \
"$image" "$image"
log "Container restarted with fresh env" log "Container restarted with fresh env"
} }
+15
View File
@@ -164,6 +164,21 @@ async function createApp() {
return null; 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 // Configure middleware
const middlewareResult = configureMiddleware(app, { const middlewareResult = configureMiddleware(app, {
siteConfig: config.siteConfig, siteConfig: config.siteConfig,
@@ -0,0 +1,335 @@
// ========== MONITORING WIDGETS ==========
// Embeds a compact system-resource + health summary panel directly on the
// main dashboard. Replaces the need for a separate monitoring-dashboard.html
// page — quick at-a-glance stats where you already are.
(function () {
// ----- Style injection (scoped to .dc-monitor so it doesn't leak) -----
const styleEl = document.createElement('style');
styleEl.textContent = `
.dc-monitor {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--card-base);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.dc-monitor-card {
padding: 10px 12px;
background: var(--card-bg, rgba(255,255,255,0.04));
border-radius: 8px;
border: 1px solid var(--border);
}
.dc-monitor-label {
font-size: 0.7rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.dc-monitor-value {
font-size: 1.4rem;
font-weight: 600;
color: var(--fg);
}
.dc-monitor-sub {
font-size: 0.7rem;
color: var(--muted);
margin-top: 4px;
}
.dc-monitor-bar {
margin-top: 6px;
width: 100%;
height: 4px;
background: color-mix(in srgb, var(--muted) 20%, transparent);
border-radius: 2px;
overflow: hidden;
}
.dc-monitor-bar-fill {
height: 100%;
width: 0%;
background: var(--ok-fg, #27ae60);
transition: width 0.3s ease, background 0.3s ease;
}
.dc-monitor-bar-fill.warn { background: #f39c12; }
.dc-monitor-bar-fill.bad { background: #e74c3c; }
.dc-monitor-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.dc-monitor-title {
font-size: 0.85rem;
font-weight: 500;
color: var(--muted);
display: flex;
align-items: center;
gap: 6px;
}
.dc-monitor-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.7rem;
font-weight: 500;
}
.dc-monitor-pill.ok { background: color-mix(in srgb, #27ae60 20%, transparent); color: #27ae60; }
.dc-monitor-pill.warn { background: color-mix(in srgb, #f39c12 20%, transparent); color: #f39c12; }
.dc-monitor-pill.bad { background: color-mix(in srgb, #e74c3c 20%, transparent); color: #e74c3c; }
.dc-monitor-refresh {
font-size: 0.7rem;
color: var(--muted);
opacity: 0.7;
}
`;
document.head.appendChild(styleEl);
// ----- Container element (inserted above service-filter-bar) -----
const filterBar = document.getElementById('service-filter-bar');
if (!filterBar) return;
const panel = document.createElement('div');
panel.className = 'dc-monitor';
panel.id = 'dc-monitor-panel';
panel.innerHTML = `
<div class="dc-monitor-header" style="grid-column: 1 / -1;">
<div class="dc-monitor-title">📊 System Overview</div>
<span class="dc-monitor-refresh" id="dc-monitor-refresh-stamp">—</span>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Services</div>
<div class="dc-monitor-value" id="dc-monitor-services">—</div>
<div class="dc-monitor-sub" id="dc-monitor-services-sub">loading…</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Containers Up</div>
<div class="dc-monitor-value" id="dc-monitor-containers">—</div>
<div class="dc-monitor-sub" id="dc-monitor-containers-sub">loading…</div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Avg CPU</div>
<div class="dc-monitor-value" id="dc-monitor-cpu">—</div>
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-cpu-bar"></div></div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Avg Memory</div>
<div class="dc-monitor-value" id="dc-monitor-mem">—</div>
<div class="dc-monitor-bar"><div class="dc-monitor-bar-fill" id="dc-monitor-mem-bar"></div></div>
</div>
<div class="dc-monitor-card">
<div class="dc-monitor-label">Health</div>
<div class="dc-monitor-value" id="dc-monitor-health">—</div>
<div class="dc-monitor-sub" id="dc-monitor-health-sub">—</div>
</div>
`;
// Insert ABOVE the filter bar
filterBar.parentNode.insertBefore(panel, filterBar);
// ----- Helpers -----
function setBar(id, pct) {
const el = document.getElementById(id);
if (!el) return;
const p = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = p + '%';
el.classList.remove('warn', 'bad');
if (p >= 85) el.classList.add('bad');
else if (p >= 65) el.classList.add('warn');
}
function fmtPct(v) {
if (v == null || isNaN(v)) return '—';
return (Math.round(v * 10) / 10) + '%';
}
function fmtBytes(b) {
if (b == null || isNaN(b)) return '—';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
while (b >= 1024 && i < units.length - 1) { b /= 1024; i++; }
return b.toFixed(1) + ' ' + units[i];
}
// ----- 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`;
}
function applyHealthSummary(data) {
const el = document.getElementById('dc-monitor-health');
const sub = document.getElementById('dc-monitor-health-sub');
if (!el) return;
if (!data || data.summary == null) {
el.textContent = '—';
if (sub) sub.textContent = 'no data';
return;
}
const s = data.summary;
const healthy = s.healthy ?? s.up ?? 0;
const unhealthy = s.unhealthy ?? s.down ?? 0;
const total = s.total ?? (healthy + unhealthy);
el.textContent = `${healthy}/${total}`;
if (sub) {
if (unhealthy === 0) {
sub.innerHTML = '<span class="dc-monitor-pill ok">● all healthy</span>';
} else if (unhealthy <= 2) {
sub.innerHTML = `<span class="dc-monitor-pill warn">● ${unhealthy} degraded</span>`;
} else {
sub.innerHTML = `<span class="dc-monitor-pill bad">● ${unhealthy} down</span>`;
}
}
}
// ----- Data fetches -----
async function fetchStats() {
try {
const r = await fetch('/api/v1/monitoring/stats', { cache: 'no-store' });
if (!r.ok) return null;
const data = await r.json();
return (data && data.stats) ? data.stats : null;
} catch (_) {
return null;
}
}
async function fetchHealth() {
try {
const r = await fetch('/api/v1/health-checks/status', { cache: 'no-store' });
if (!r.ok) return null;
return await r.json();
} catch (_) {
return null;
}
}
function applyStats(stats) {
const containers = document.getElementById('dc-monitor-containers');
const containersSub = document.getElementById('dc-monitor-containers-sub');
const cpuEl = document.getElementById('dc-monitor-cpu');
const memEl = document.getElementById('dc-monitor-mem');
if (!stats) {
if (containers) containers.textContent = '—';
if (cpuEl) cpuEl.textContent = '—';
if (memEl) memEl.textContent = '—';
return;
}
const entries = Object.values(stats);
if (entries.length === 0) {
if (containers) containers.textContent = '0';
if (containersSub) containersSub.textContent = 'no containers reporting';
if (cpuEl) cpuEl.textContent = '0%';
if (memEl) memEl.textContent = '0%';
setBar('dc-monitor-cpu-bar', 0);
setBar('dc-monitor-mem-bar', 0);
return;
}
let cpuSum = 0, memSum = 0, memBytes = 0, cpuCount = 0, memCount = 0;
entries.forEach(s => {
// CPU may be percentage (0-100) or fraction (0-1) — handle both
if (s.cpu != null) {
const cpu = Number(s.cpu);
if (!isNaN(cpu)) {
cpuSum += cpu > 1 ? cpu : cpu * 100;
cpuCount++;
}
}
if (s.memory != null) {
const mem = Number(s.memory);
if (!isNaN(mem)) {
memSum += mem;
memBytes += Number(s.memoryUsage || 0);
memCount++;
}
}
});
const avgCpu = cpuCount ? cpuSum / cpuCount : 0;
const avgMem = memCount ? memSum / memCount : 0;
if (containers) containers.textContent = String(entries.length);
if (containersSub) {
const memTxt = memBytes ? ` · ${fmtBytes(memBytes)} RAM` : '';
containersSub.textContent = `running${memTxt}`;
}
if (cpuEl) cpuEl.textContent = fmtPct(avgCpu);
if (memEl) memEl.textContent = fmtPct(avgMem);
setBar('dc-monitor-cpu-bar', avgCpu);
setBar('dc-monitor-mem-bar', avgMem);
}
// ----- Public refresh function -----
let inFlight = false;
async function refresh() {
if (inFlight) return;
inFlight = true;
try {
setServicesCard();
const [stats, health] = await Promise.all([fetchStats(), fetchHealth()]);
applyStats(stats);
applyHealthSummary(health);
const stamp = document.getElementById('dc-monitor-refresh-stamp');
if (stamp) {
const now = new Date();
stamp.textContent = `updated ${now.toLocaleTimeString()}`;
}
} finally {
inFlight = false;
}
}
// Expose for init.js to call once and re-call after each refreshAll cycle
window.refreshMonitoringWidgets = refresh;
// Auto-refresh on the STATS interval (separate from full DASHBOARD refresh)
setInterval(refresh, (typeof DC !== 'undefined' && DC.POLL && DC.POLL.STATS) || 5000);
// Refresh once on first script load (init.js also calls this; double-call is harmless)
setTimeout(refresh, 200);
})();
+56
View File
@@ -27,10 +27,14 @@ readonly API_DIR="${SITES_DIR}/dashcaddy-api"
readonly DASHBOARD_DIR="${SITES_DIR}/status" readonly DASHBOARD_DIR="${SITES_DIR}/status"
readonly CONTAINER_NAME="dashcaddy-api" readonly CONTAINER_NAME="dashcaddy-api"
readonly CADDY_ADMIN_PORT=2019 readonly CADDY_ADMIN_PORT=2019
readonly BACKUP_DIR="${BACKUP_DIR:-${INSTALL_DIR}/backups}"
readonly DEFAULT_MAX_STORAGE_BYTES=""
# ---- Tunables (overridable via flags) -------------------------------------- # ---- Tunables (overridable via flags) --------------------------------------
API_PORT=3001 API_PORT=3001
LOCAL_PORT=8080 LOCAL_PORT=8080
BACKUP_DIR=""
BACKUP_LIMIT=""
# ---- Runtime state --------------------------------------------------------- # ---- Runtime state ---------------------------------------------------------
DOMAIN_MODE="" # public | custom-tld | local DOMAIN_MODE="" # public | custom-tld | local
@@ -389,6 +393,7 @@ EOF
create_directories() { create_directories() {
mkdir -p "$INSTALL_DIR" "$DOCKER_DATA" "$SITES_DIR" "$API_DIR" "$DASHBOARD_DIR" "${DASHBOARD_DIR}/assets" 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 /opt/dashcaddy/updates /opt/dashcaddy/scripts
mkdir -p "${BACKUP_DIR}"
ok "Directories created" ok "Directories created"
} }
@@ -626,7 +631,41 @@ CEOF
# Docker Compose # 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() { 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 cat > "${API_DIR}/docker-compose.yml" <<DCEOF
services: services:
dashcaddy-api: dashcaddy-api:
@@ -648,6 +687,7 @@ services:
- ${DASHBOARD_DIR}:/app/dashboard:rw - ${DASHBOARD_DIR}:/app/dashboard:rw
- /opt/dashcaddy/updates:/app/updates:rw - /opt/dashcaddy/updates:/app/updates:rw
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- CADDYFILE_PATH=/caddyfile - CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT} - CADDY_ADMIN_URL=http://host.docker.internal:${CADDY_ADMIN_PORT}
@@ -665,6 +705,10 @@ services:
- DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates - DASHCADDY_HOST_UPDATES_DIR=/opt/dashcaddy/updates
- DASHCADDY_API_SOURCE_DIR=${API_DIR} - DASHCADDY_API_SOURCE_DIR=${API_DIR}
- DASHCADDY_FRONTEND_DIR=/app/dashboard - 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: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
restart: unless-stopped restart: unless-stopped
@@ -673,6 +717,14 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: ${BACKUP_DIR}
DCEOF DCEOF
ok "docker-compose.yml generated" ok "docker-compose.yml generated"
@@ -880,6 +932,8 @@ parse_args() {
--skip-caddy) SKIP_CADDY=true; shift ;; --skip-caddy) SKIP_CADDY=true; shift ;;
--uninstall) UNINSTALL=true; shift ;; --uninstall) UNINSTALL=true; shift ;;
--keep-config) KEEP_CONFIG=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 ;; --yes|-y) AUTO_YES=true; shift ;;
--help|-h) print_help; exit 0 ;; --help|-h) print_help; exit 0 ;;
*) warn "Unknown option: $1 (ignored)"; shift ;; *) warn "Unknown option: $1 (ignored)"; shift ;;
@@ -914,6 +968,8 @@ print_help() {
--source PATH Use local source files --source PATH Use local source files
--skip-docker Already have Docker --skip-docker Already have Docker
--skip-caddy Already have Caddy --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 --uninstall Remove DashCaddy
--keep-config Keep configs during uninstall --keep-config Keep configs during uninstall
--yes Skip confirmations --yes Skip confirmations
+21 -4
View File
@@ -226,17 +226,34 @@ class ConfigManager {
* @returns {Promise<Object>} Disk space info * @returns {Promise<Object>} Disk space info
*/ */
async getDiskSpace(testPath) { async getDiskSpace(testPath) {
// Note: This is a simplified version. In production, you'd use a library like 'check-disk-space'
try { 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 { return {
available: true, available: true,
path: testPath path: testPath,
total: totalBytes,
used: usedBytes,
free: freeBytes,
availableBytes: availableBytes,
usagePercent: parseFloat(((usedBytes / totalBytes) * 100).toFixed(2))
}; };
} catch (error) { } catch (error) {
return { return {
available: false, available: false,
path: testPath,
error: error.message error: error.message
}; };
} }
@@ -62,6 +62,11 @@ const state = {
installPath: '', installPath: '',
health: null health: null
}, },
// Backup configuration
backup: {
maxStorageGB: 10,
backupDir: ''
},
// Uninstall mode // Uninstall mode
uninstallMode: false, uninstallMode: false,
uninstall: { uninstall: {
@@ -373,6 +378,24 @@ function updateBranding(field, value) {
if (field === 'primaryColor') render(); 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() { async function selectLogo() {
try { try {
const result = await window.electronAPI.selectFile({ const result = await window.electronAPI.selectFile({
@@ -420,6 +443,10 @@ async function startInstallation() {
password: state.dns.password, password: state.dns.password,
token: state.dns.token token: state.dns.token
} : null, } : null,
backup: {
maxStorageGB: state.backup.maxStorageGB,
backupDir: state.backup.backupDir || null
},
autoStart: true autoStart: true
}); });
} catch (err) { } catch (err) {
@@ -963,6 +990,31 @@ function renderDashboardSetup() {
<p class="hint">Port for the DashCaddy API server (default: 3001)</p> <p class="hint">Port for the DashCaddy API server (default: 3001)</p>
</div> </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>
</div> </div>
`; `;
@@ -7,15 +7,28 @@ services:
volumes: volumes:
- {{API_PATH}}:/app - {{API_PATH}}:/app
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- dashcaddy-backups:/app/backups
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT={{API_PORT}} - PORT={{API_PORT}}
- SERVICES_FILE=/app/services.json - SERVICES_FILE=/app/services.json
- CADDY_ADMIN_URL=http://host.docker.internal:2019 - 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 restart: unless-stopped
networks: networks:
- dashcaddy - dashcaddy
volumes:
dashcaddy-backups:
driver: local
driver_opts:
type: none
o: bind
device: {{BACKUP_DIR}}
networks: networks:
dashcaddy: dashcaddy:
driver: bridge driver: bridge
-272
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

+1
View File
@@ -72,6 +72,7 @@ const bundles = {
], ],
'init.js': [ 'init.js': [
JS('core', 'init.js'), JS('core', 'init.js'),
JS('monitoring-widgets.js'),
JS('keyboard-shortcuts.js'), JS('keyboard-shortcuts.js'),
], ],
}; };
+114 -87
View File
File diff suppressed because one or more lines are too long
+308 -233
View File
File diff suppressed because one or more lines are too long
+123 -12
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-8ef9c82616'; const CACHE = 'dashcaddy-shell-594ec75648';
const PRECACHE = [ const PRECACHE = [
'/', '/',
'/index.html', '/index.html',