Compare commits
5
Commits
v1.9.0
...
7bbd969fa2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bbd969fa2 | ||
|
|
4f377970d7 | ||
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
+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 @@
|
||||
dev
|
||||
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."
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
@@ -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.6.0",
|
||||
"version": "1.7.8",
|
||||
"description": "DashCaddy API server - Dashboard backend for Docker, Caddy & DNS management",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -164,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
})();
|
||||
@@ -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;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
@@ -72,6 +72,7 @@ const bundles = {
|
||||
],
|
||||
'init.js': [
|
||||
JS('core', 'init.js'),
|
||||
JS('monitoring-widgets.js'),
|
||||
JS('keyboard-shortcuts.js'),
|
||||
],
|
||||
};
|
||||
|
||||
Vendored
+114
-87
File diff suppressed because one or more lines are too long
Vendored
+308
-233
File diff suppressed because one or more lines are too long
Vendored
+123
-12
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-8ef9c82616';
|
||||
const CACHE = 'dashcaddy-shell-594ec75648';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user