Merge: resolve conflict in routes/backups.js, keep storage-info + maxStorageBytes

This commit is contained in:
Krystie
2026-05-28 15:00:41 -07:00
parent ea9bdf9598
commit ad9400490d
6 changed files with 387 additions and 14 deletions
+74 -1
View File
@@ -140,8 +140,16 @@ class BackupManager extends EventEmitter {
// Calculate checksum
const checksum = this.calculateChecksum(finalData);
// Save to destinations
// Check and enforce maxStorageBytes limit BEFORE saving
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 = [];
for (const dest of destinations) {
@@ -1053,6 +1061,71 @@ class BackupManager extends EventEmitter {
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
*/
+171 -9
View File
@@ -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));
}
+56
View File
@@ -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
+21 -4
View File
@@ -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