Compare commits
3
Commits
v1.13.4
...
7f0d43943c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0d43943c | ||
|
|
9ab947a394 | ||
|
|
ad9400490d |
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Regular → Executable
@@ -39,13 +39,6 @@ let dockerMaintenance, logDigest;
|
|||||||
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
|
try { dockerMaintenance = require('../docker-maintenance'); } catch (_) { /* optional module */ }
|
||||||
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
|
try { logDigest = require('../log-digest'); } catch (_) { /* optional module */ }
|
||||||
|
|
||||||
// Workflow engine (bundled workflows)
|
|
||||||
let bundledWorkflowsModule;
|
|
||||||
let workflowEngine = null;
|
|
||||||
try {
|
|
||||||
bundledWorkflowsModule = require('../bundled-workflows');
|
|
||||||
} catch (_) { /* optional module */ }
|
|
||||||
|
|
||||||
// Templates
|
// Templates
|
||||||
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
const { APP_TEMPLATES, TEMPLATE_CATEGORIES, DIFFICULTY_LEVELS } = require('../app-templates');
|
||||||
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
|
const { RECIPE_TEMPLATES, RECIPE_CATEGORIES } = require('../recipe-templates');
|
||||||
@@ -76,7 +69,6 @@ const recipesRoutes = require('../routes/recipes');
|
|||||||
const themesRoutes = require('../routes/themes');
|
const themesRoutes = require('../routes/themes');
|
||||||
const dockerResourcesRoutes = require('../routes/docker-resources');
|
const dockerResourcesRoutes = require('../routes/docker-resources');
|
||||||
const eventsRoutes = require('../routes/events');
|
const eventsRoutes = require('../routes/events');
|
||||||
const workflowsRoutes = require('../routes/workflows');
|
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const { APP } = require('../constants');
|
const { APP } = require('../constants');
|
||||||
@@ -316,55 +308,9 @@ async function createApp() {
|
|||||||
app,
|
app,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize workflow engine if bundled-workflows is available
|
|
||||||
if (bundledWorkflowsModule && ctx.docker) {
|
|
||||||
try {
|
|
||||||
const { WorkflowEngine } = bundledWorkflowsModule;
|
|
||||||
const workflowCtx = {
|
|
||||||
docker: ctx.docker,
|
|
||||||
notification: ctx.notification,
|
|
||||||
backupManager: ctx.backupManager,
|
|
||||||
resourceMonitor: ctx.resourceMonitor,
|
|
||||||
servicesStateManager: ctx.servicesStateManager
|
|
||||||
};
|
|
||||||
workflowEngine = new WorkflowEngine(workflowCtx);
|
|
||||||
ctx.workflowEngine = workflowEngine;
|
|
||||||
log.info('app', 'Workflow engine initialized');
|
|
||||||
} catch (err) {
|
|
||||||
log.error('app', 'Failed to initialize workflow engine', { error: err.message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build versioned API router
|
// Build versioned API router
|
||||||
const apiRouter = express.Router();
|
const apiRouter = express.Router();
|
||||||
|
|
||||||
// Wire up notification listeners for resourceMonitor and backupManager
|
|
||||||
if (ctx.notification && ctx.resourceMonitor) {
|
|
||||||
ctx.resourceMonitor.on('alert', (alertData) => {
|
|
||||||
ctx.notification.sendAlert(alertData).catch(err => {
|
|
||||||
log.error('notification', 'Failed to send alert', { error: err.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ctx.resourceMonitor.on('auto-restart', (data) => {
|
|
||||||
ctx.notification.sendServiceEvent('auto-restart', data).catch(err => {
|
|
||||||
log.error('notification', 'Failed to send auto-restart notification', { error: err.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.notification && ctx.backupManager) {
|
|
||||||
ctx.backupManager.on('backup-complete', (data) => {
|
|
||||||
ctx.notification.send('backup-complete', data).catch(err => {
|
|
||||||
log.error('notification', 'Failed to send backup-complete', { error: err.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
ctx.backupManager.on('backup-failed', (data) => {
|
|
||||||
ctx.notification.send('backup-failed', data).catch(err => {
|
|
||||||
log.error('notification', 'Failed to send backup-failed', { error: err.message });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mount route modules
|
// Mount route modules
|
||||||
apiRouter.use(authRoutes(ctx));
|
apiRouter.use(authRoutes(ctx));
|
||||||
apiRouter.use(configRoutes(ctx));
|
apiRouter.use(configRoutes(ctx));
|
||||||
@@ -384,8 +330,7 @@ async function createApp() {
|
|||||||
apiRouter.use('/containers', containerRoutes({
|
apiRouter.use('/containers', containerRoutes({
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
log: ctx.log,
|
log: ctx.log,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler
|
||||||
workflowEngine: ctx.workflowEngine
|
|
||||||
}));
|
}));
|
||||||
apiRouter.use(serviceRoutes({
|
apiRouter.use(serviceRoutes({
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
@@ -416,8 +361,7 @@ async function createApp() {
|
|||||||
resourceMonitor: ctx.resourceMonitor,
|
resourceMonitor: ctx.resourceMonitor,
|
||||||
docker: ctx.docker,
|
docker: ctx.docker,
|
||||||
asyncHandler: ctx.asyncHandler,
|
asyncHandler: ctx.asyncHandler,
|
||||||
log: ctx.log,
|
log: ctx.log
|
||||||
notificationManager: ctx.notification
|
|
||||||
}));
|
}));
|
||||||
apiRouter.use(updatesRoutes({
|
apiRouter.use(updatesRoutes({
|
||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
@@ -460,8 +404,8 @@ async function createApp() {
|
|||||||
}));
|
}));
|
||||||
apiRouter.use(backupsRoutes({
|
apiRouter.use(backupsRoutes({
|
||||||
backupManager: ctx.backupManager,
|
backupManager: ctx.backupManager,
|
||||||
licenseManager: ctx.licenseManager,
|
asyncHandler: ctx.asyncHandler,
|
||||||
asyncHandler: ctx.asyncHandler
|
licenseManager: ctx.licenseManager
|
||||||
}));
|
}));
|
||||||
apiRouter.use('/ca', caRoutes(ctx));
|
apiRouter.use('/ca', caRoutes(ctx));
|
||||||
apiRouter.use(browseRoutes({
|
apiRouter.use(browseRoutes({
|
||||||
@@ -491,11 +435,6 @@ async function createApp() {
|
|||||||
updateManager: ctx.updateManager,
|
updateManager: ctx.updateManager,
|
||||||
logError: ctx.logError
|
logError: ctx.logError
|
||||||
}));
|
}));
|
||||||
apiRouter.use(workflowsRoutes({
|
|
||||||
workflowEngine: ctx.workflowEngine,
|
|
||||||
licenseManager: ctx.licenseManager,
|
|
||||||
asyncHandler: ctx.asyncHandler
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Inline API routes
|
// Inline API routes
|
||||||
apiRouter.get('/health', (req, res) => {
|
apiRouter.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user