Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes
This commit is contained in:
@@ -140,8 +140,16 @@ class BackupManager extends EventEmitter {
|
|||||||
// Calculate checksum
|
// Calculate checksum
|
||||||
const checksum = this.calculateChecksum(finalData);
|
const checksum = this.calculateChecksum(finalData);
|
||||||
|
|
||||||
// Save to destinations
|
// Check and enforce maxStorageBytes limit BEFORE saving
|
||||||
const destinations = backup.destinations || [{ type: 'local' }];
|
const destinations = backup.destinations || [{ type: 'local' }];
|
||||||
|
for (const dest of destinations) {
|
||||||
|
if (backup.maxStorageBytes && backup.maxStorageBytes > 0) {
|
||||||
|
const destPath = dest.path || DEFAULT_BACKUP_DIR;
|
||||||
|
await this.enforceStorageLimit(backup.maxStorageBytes, destPath, backupId, finalData.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to destinations
|
||||||
const savedLocations = [];
|
const savedLocations = [];
|
||||||
|
|
||||||
for (const dest of destinations) {
|
for (const dest of destinations) {
|
||||||
@@ -1053,6 +1061,71 @@ class BackupManager extends EventEmitter {
|
|||||||
this.saveHistory();
|
this.saveHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enforce storage limit by pruning oldest backups if needed.
|
||||||
|
* Works with any filesystem (ext4, Btrfs, XFS, ZFS, APFS, NTFS).
|
||||||
|
* @param {number} maxBytes - Maximum storage limit in bytes
|
||||||
|
* @param {string} backupDir - Backup directory path
|
||||||
|
* @param {string} pendingBackupId - ID of backup about to be written
|
||||||
|
* @param {number} pendingSize - Estimated size of pending backup in bytes
|
||||||
|
*/
|
||||||
|
async enforceStorageLimit(maxBytes, backupDir, pendingBackupId, pendingSize) {
|
||||||
|
console.log(`[BackupManager] Checking storage limit: max=${maxBytes} bytes`);
|
||||||
|
|
||||||
|
// Find all backup files for this destination
|
||||||
|
const files = [];
|
||||||
|
if (fs.existsSync(backupDir)) {
|
||||||
|
const entries = fs.readdirSync(backupDir);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.endsWith('.backup')) {
|
||||||
|
const filePath = path.join(backupDir, entry);
|
||||||
|
try {
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
files.push({ path: filePath, size: stats.size, mtime: stats.mtime });
|
||||||
|
} catch (e) {
|
||||||
|
// Skip files we can't stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by modification time (oldest first)
|
||||||
|
files.sort((a, b) => new Date(a.mtime) - new Date(b.mtime));
|
||||||
|
|
||||||
|
let totalSize = files.reduce((sum, f) => sum + f.size, 0) + pendingSize;
|
||||||
|
|
||||||
|
// If even ONE backup exceeds the limit, fail immediately
|
||||||
|
const largestFile = files.reduce((max, f) => f.size > max ? f.size : max, 0);
|
||||||
|
if (largestFile > maxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`Backup too large (${largestFile} bytes) for configured storage limit (${maxBytes} bytes). ` +
|
||||||
|
`Increase the storage limit or remove large backups manually.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune oldest backups until we fit within limit
|
||||||
|
while (files.length > 0 && totalSize > maxBytes) {
|
||||||
|
const oldest = files.shift();
|
||||||
|
console.log(`[BackupManager] Pruning oldest backup to free space: ${oldest.path} (${oldest.size} bytes)`);
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(oldest.path);
|
||||||
|
// Also remove from history if present
|
||||||
|
const historyEntry = this.history.find(h =>
|
||||||
|
h.locations && h.locations.some(l => l.path === oldest.path)
|
||||||
|
);
|
||||||
|
if (historyEntry) {
|
||||||
|
this.history = this.history.filter(h => h.id !== historyEntry.id);
|
||||||
|
}
|
||||||
|
totalSize -= oldest.size;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[BackupManager] Error pruning backup ${oldest.path}:`, error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveHistory();
|
||||||
|
console.log(`[BackupManager] Storage after pruning: ${totalSize}/${maxBytes} bytes`);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add entry to backup history
|
* Add entry to backup history
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { success } = require('../response-helpers');
|
const fsp = require('fs').promises;
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { success } = require('../response-helpers');
|
||||||
|
|
||||||
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, 'backups');
|
const DEFAULT_BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||||
|
const DEFAULT_MAX_STORAGE_BYTES = process.env.BACKUP_MAX_STORAGE_BYTES
|
||||||
|
? parseInt(process.env.BACKUP_MAX_STORAGE_BYTES, 10)
|
||||||
|
: 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backups routes factory
|
* Backups routes factory
|
||||||
@@ -41,6 +45,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
runImmediately: backup.runImmediately || false,
|
runImmediately: backup.runImmediately || false,
|
||||||
destination: backup.destination || 'local',
|
destination: backup.destination || 'local',
|
||||||
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
destinationPath: backup.destinationPath || DEFAULT_BACKUP_DIR,
|
||||||
|
maxStorageBytes: backup.maxStorageBytes || null,
|
||||||
lastRun: lastRun ? lastRun.toISOString() : null,
|
lastRun: lastRun ? lastRun.toISOString() : null,
|
||||||
nextRun: nextRun ? nextRun.toISOString() : null,
|
nextRun: nextRun ? nextRun.toISOString() : null,
|
||||||
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
lastBackupId: appHistory.length > 0 ? appHistory[0].id : null
|
||||||
@@ -52,7 +57,7 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
|
|
||||||
// Create or update a scheduled backup for an app
|
// Create or update a scheduled backup for an app
|
||||||
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
router.post('/backups/schedule', premiumGating, asyncHandler(async (req, res) => {
|
||||||
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath } = req.body;
|
const { appId, enabled, schedule, retention, runImmediately, destination, destinationPath, maxStorageBytes } = req.body;
|
||||||
|
|
||||||
if (!appId) {
|
if (!appId) {
|
||||||
const { ValidationError } = require('../errors');
|
const { ValidationError } = require('../errors');
|
||||||
@@ -62,6 +67,11 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
const config = backupManager.getConfig();
|
const config = backupManager.getConfig();
|
||||||
if (!config.backups) config.backups = {};
|
if (!config.backups) config.backups = {};
|
||||||
|
|
||||||
|
// Parse maxStorageBytes if provided as string (e.g. "10GB")
|
||||||
|
const parsedMaxStorage = maxStorageBytes
|
||||||
|
? (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : maxStorageBytes)
|
||||||
|
: null;
|
||||||
|
|
||||||
// Build the backup config for this app
|
// Build the backup config for this app
|
||||||
const backupConfig = {
|
const backupConfig = {
|
||||||
enabled: enabled !== undefined ? enabled : true,
|
enabled: enabled !== undefined ? enabled : true,
|
||||||
@@ -71,7 +81,8 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
destination: destination || 'local',
|
destination: destination || 'local',
|
||||||
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
destinationPath: destinationPath || DEFAULT_BACKUP_DIR,
|
||||||
include: ['all'],
|
include: ['all'],
|
||||||
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }]
|
destinations: [{ type: destination || 'local', path: destinationPath || DEFAULT_BACKUP_DIR }],
|
||||||
|
maxStorageBytes: parsedMaxStorage
|
||||||
};
|
};
|
||||||
|
|
||||||
config.backups[appId] = backupConfig;
|
config.backups[appId] = backupConfig;
|
||||||
@@ -490,6 +501,39 @@ module.exports = function({ backupManager, licenseManager, asyncHandler }) {
|
|||||||
success(res, { history });
|
success(res, { history });
|
||||||
}, 'backups-history'));
|
}, 'backups-history'));
|
||||||
|
|
||||||
|
// Get storage info for backups destination
|
||||||
|
router.get('/backups/storage-info', asyncHandler(async (req, res) => {
|
||||||
|
const storageInfo = await getStorageInfo();
|
||||||
|
success(res, storageInfo);
|
||||||
|
}, 'backups-storage-info'));
|
||||||
|
|
||||||
|
// Schedule a backup
|
||||||
|
router.post('/backups/schedule', asyncHandler(async (req, res) => {
|
||||||
|
const { name, schedule, maxStorageBytes, ...backupConfig } = req.body;
|
||||||
|
|
||||||
|
if (!name || !schedule) {
|
||||||
|
return res.status(400).json({ error: 'name and schedule are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = backupManager.getConfig();
|
||||||
|
|
||||||
|
// Store maxStorageBytes in the backup config (converted to bytes)
|
||||||
|
const maxBytes = typeof maxStorageBytes === 'number' && maxStorageBytes > 0
|
||||||
|
? maxStorageBytes
|
||||||
|
: (typeof maxStorageBytes === 'string' ? parseStorageSize(maxStorageBytes) : 0);
|
||||||
|
|
||||||
|
config.backups[name] = {
|
||||||
|
...backupConfig,
|
||||||
|
enabled: true,
|
||||||
|
schedule,
|
||||||
|
maxStorageBytes: maxBytes,
|
||||||
|
destinations: backupConfig.destinations || [{ type: 'local' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
backupManager.updateConfig(config);
|
||||||
|
success(res, { message: `Backup '${name}' scheduled`, maxStorageBytes: maxBytes });
|
||||||
|
}, 'backups-schedule'));
|
||||||
|
|
||||||
// Restore from backup
|
// Restore from backup
|
||||||
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
router.post('/backups/restore/:backupId', asyncHandler(async (req, res) => {
|
||||||
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
const result = await backupManager.restoreBackup(req.params.backupId, req.body);
|
||||||
@@ -653,3 +697,121 @@ function formatBytes(bytes) {
|
|||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get storage information for the backup directory
|
||||||
|
*/
|
||||||
|
async function getStorageInfo() {
|
||||||
|
const result = {
|
||||||
|
destination: DEFAULT_BACKUP_DIR,
|
||||||
|
maxStorageBytes: DEFAULT_MAX_STORAGE_BYTES,
|
||||||
|
usedBytes: 0,
|
||||||
|
availableBytes: 0,
|
||||||
|
usagePercent: 0,
|
||||||
|
backupCount: 0,
|
||||||
|
oldestBackup: null,
|
||||||
|
newestBackup: null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get disk space info
|
||||||
|
const diskSpace = await getDiskSpaceInfo(DEFAULT_BACKUP_DIR);
|
||||||
|
result.availableBytes = diskSpace.available;
|
||||||
|
|
||||||
|
// Scan for backup files
|
||||||
|
if (DEFAULT_MAX_STORAGE_BYTES > 0) {
|
||||||
|
result.maxStorageBytes = DEFAULT_MAX_STORAGE_BYTES;
|
||||||
|
} else {
|
||||||
|
result.maxStorageBytes = diskSpace.total || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalSize = 0;
|
||||||
|
let oldestTime = null;
|
||||||
|
let newestTime = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(DEFAULT_BACKUP_DIR);
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.endsWith('.backup')) {
|
||||||
|
const filePath = path.join(DEFAULT_BACKUP_DIR, entry);
|
||||||
|
try {
|
||||||
|
const stats = await fsp.stat(filePath);
|
||||||
|
totalSize += stats.size;
|
||||||
|
result.backupCount++;
|
||||||
|
|
||||||
|
const fileTime = new Date(stats.mtime);
|
||||||
|
if (!oldestTime || fileTime < oldestTime) oldestTime = fileTime;
|
||||||
|
if (!newestTime || fileTime > newestTime) newestTime = fileTime;
|
||||||
|
} catch (e) {
|
||||||
|
// Skip files we can't stat
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Backup directory might not exist yet
|
||||||
|
}
|
||||||
|
|
||||||
|
result.usedBytes = totalSize;
|
||||||
|
result.oldestBackup = oldestTime ? oldestTime.toISOString() : null;
|
||||||
|
result.newestBackup = newestTime ? newestTime.toISOString() : null;
|
||||||
|
|
||||||
|
// Calculate available (total limit - used), or from disk space if no limit set
|
||||||
|
if (result.maxStorageBytes > 0) {
|
||||||
|
result.availableBytes = Math.max(0, result.maxStorageBytes - totalSize);
|
||||||
|
result.usagePercent = parseFloat(((totalSize / result.maxStorageBytes) * 100).toFixed(2));
|
||||||
|
} else if (diskSpace.total) {
|
||||||
|
result.availableBytes = diskSpace.available;
|
||||||
|
result.usagePercent = diskSpace.total > 0
|
||||||
|
? parseFloat((((diskSpace.total - diskSpace.available) / diskSpace.total) * 100).toFixed(2))
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[BackupsRouter] Error getting storage info:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get disk space info (filesystem-agnostic)
|
||||||
|
*/
|
||||||
|
async function getDiskSpaceInfo(dirPath) {
|
||||||
|
try {
|
||||||
|
const diskInfo = await fsp.statfs(dirPath);
|
||||||
|
return {
|
||||||
|
total: diskInfo.blocks * diskInfo.bsize,
|
||||||
|
available: diskInfo.bfree * diskInfo.bsize,
|
||||||
|
used: (diskInfo.blocks - diskInfo.bfree) * diskInfo.bsize
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
// Directory might not exist or be accessible
|
||||||
|
return { total: 0, available: 0, used: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse storage size string like "10GB" to bytes
|
||||||
|
*/
|
||||||
|
function parseStorageSize(sizeStr) {
|
||||||
|
if (!sizeStr || typeof sizeStr === 'number') return sizeStr || 0;
|
||||||
|
|
||||||
|
const match = String(sizeStr).match(/^(\d+(?:\.\d+)?)\s*(B|KB|MB|GB|TB|K|M|G|T)?$/i);
|
||||||
|
if (!match) return 0;
|
||||||
|
|
||||||
|
const value = parseFloat(match[1]);
|
||||||
|
const unit = (match[2] || 'B').toUpperCase();
|
||||||
|
|
||||||
|
const multipliers = {
|
||||||
|
'B': 1,
|
||||||
|
'K': 1024,
|
||||||
|
'KB': 1024,
|
||||||
|
'M': 1024 * 1024,
|
||||||
|
'MB': 1024 * 1024,
|
||||||
|
'G': 1024 * 1024 * 1024,
|
||||||
|
'GB': 1024 * 1024 * 1024,
|
||||||
|
'T': 1024 * 1024 * 1024 * 1024,
|
||||||
|
'TB': 1024 * 1024 * 1024 * 1024
|
||||||
|
};
|
||||||
|
|
||||||
|
return Math.floor(value * (multipliers[unit] || 1));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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