DC-101: Disk Space Monitor + Product Vision
Backend: - src/monitoring/disk-space-monitor.js: monitors Docker disk usage against user-configured budget, auto-cleans at thresholds, breaks down by category - routes/disk-space.js: GET /disk, GET /disk/breakdown, POST /disk/config, POST /disk/cleanup endpoints - src/app.js: wire DiskSpaceMonitor into startup, 10-min check interval - All 1539 tests pass Product Vision (PRODUCT-VISION.md): - DashCaddy is a self-hosting platform, not just a dashboard - Core value: 'Self-host anything in 30 seconds' - Three pillars: One-click deploy, zero-config networking, self-healing infra - vs Portainer/CasaOS/Yunohost positioning New backlog tasks (P5 tier, DC-101–108): - Disk budget, one-click deploy with auto Caddyfile+DNS, container auto-discovery, app catalog, smart wizard, visual Caddy builder, disaster recovery, multi-host fleet management 47 total backlog tasks, ~110 hr of work, cron running every 2h.
This commit is contained in:
@@ -242,6 +242,55 @@
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
## P5 — Product Vision: Self-Hosting Platform
|
||||
|
||||
> These tasks directly serve the vision from PRODUCT-VISION.md:
|
||||
> "Self-host anything in 30 seconds — no config files, no TLS headaches."
|
||||
|
||||
### DC-101: Disk Space Manager with user-configurable budget + dashboard widget
|
||||
- **status:** in-progress (backend done, needs UI + deployment)
|
||||
- **details:** Backend module (`src/monitoring/disk-space-monitor.js`) and routes (`routes/disk-space.js`) are written and pass tests. Still needs: (1) Dashboard widget showing disk usage gauge with budget line, breakdown by category (images/volumes/logs/build-cache), and "Cleanup now" button. (2) Settings page section for disk budget input. (3) Deploy to DNS2 production. API endpoints: GET /api/v1/disk, GET /api/v1/disk/breakdown, POST /api/v1/disk/config, POST /api/v1/disk/cleanup. Effort: ~2 hr remaining.
|
||||
- **impact:** Users set a disk budget (e.g., "DashCaddy gets 20GB") and the system auto-manages cleanup. The #1 reason people abandon self-hosting is disk filling up silently. This solves it.
|
||||
|
||||
### DC-102: One-click deploy should auto-generate Caddyfile entry + DNS record
|
||||
- **status:** pending
|
||||
- **details:** When a user deploys an app from the catalog, DashCaddy should automatically: (1) Create the Docker container, (2) Add a Caddyfile reverse_proxy block with TLS for `appname.tld`, (3) Create a DNS record pointing to the host, (4) Reload Caddy, (5) Add the service to the dashboard with health check. Currently steps 2-4 are manual. Fix: add a `deployApp(serviceId, options)` function that orchestrates the full chain. The Caddyfile generation can use the admin API (POST to :2019) so no file editing needed. DNS record creation uses the existing Technitium/Cloudflare DNS provider integration. Effort: ~4 hr.
|
||||
- **impact:** This is THE core value proposition. Without this, DashCaddy is just Portainer with extra steps. With this, it's a self-hosting platform.
|
||||
|
||||
### DC-103: Container auto-discovery with auto-route generation
|
||||
- **status:** pending
|
||||
- **details:** When DashCaddy detects a new running Docker container (via docker events API), it should: (1) Check if it matches a known app template (Plex, Sonarr, etc.), (2) Auto-generate a Caddy reverse proxy route, (3) Create a DNS record, (4) Add it to the dashboard, (5) Notify the user "Found Nextcloud on port 80 — added to your dashboard at https://nextcloud.yourdomain.com". This is the "zero-config" experience. Effort: ~4 hr.
|
||||
- **impact:** Magic. User installs Nextcloud via docker run → 10 seconds later it's on their dashboard with HTTPS.
|
||||
|
||||
### DC-104: App catalog with curated templates + one-click deploy
|
||||
- **status:** pending
|
||||
- **details:** The app templates exist (`src/docker/app-templates.js` has 50+ templates) but there's no polished catalog UI. Build a "App Store" page: grid of app cards with icons, descriptions, and "Install" buttons. Clicking install triggers DC-102's deploy chain. Include categories (Media, Productivity, Security, Development). Show "Popular" and "New" badges. Allow community templates via DC-080's plugin system. Effort: ~4 hr.
|
||||
- **impact:** This is the front door. The catalog IS the product for most users.
|
||||
|
||||
### DC-105: Smart defaults wizard — "What do you want to self-host?"
|
||||
- **status:** pending
|
||||
- **details:** Instead of asking users to configure DNS servers, TLD, Caddy paths, and auth — ask them ONE question: "What domain do you want to use?" Then auto-detect: (1) DNS server (check if Technitium is running locally), (2) TLD (.home, .local, or their domain), (3) Caddy installation, (4) Docker setup. Configure everything automatically. If something is missing, install it. The wizard should handle 90% of setups in under 5 questions. Effort: ~3 hr.
|
||||
- **impact:** First-run experience determines whether users stay. A 15-step config wizard kills adoption. A 1-question wizard creates delight.
|
||||
|
||||
### DC-106: Caddyfile-as-code — visual reverse proxy builder
|
||||
- **status:** pending
|
||||
- **details:** Instead of editing Caddyfile text, provide a visual builder: "I want requests to blog.yourdomain.com to go to container X on port 80, with authentication, rate limiting, and compression." Generate the Caddyfile block from the form. Show a live preview of the generated config. Apply via Caddy admin API. This eliminates the need to learn Caddyfile syntax entirely. Effort: ~3 hr.
|
||||
- **impact:** Caddyfile syntax is the #1 technical barrier. A visual builder makes reverse proxy configuration accessible to non-sysadmins.
|
||||
|
||||
### DC-107: Disaster recovery — one-click backup + restore of entire setup
|
||||
- **status:** pending
|
||||
- **details:** Extend DC-078 to include container definitions, Caddyfile, DNS zones, and all app data. The backup should be a single encrypted tarball. "Restore on new host" should bring back the entire DashCaddy setup + all apps in one command. This is the "set it and forget it" insurance policy. Effort: ~3 hr.
|
||||
- **impact:** Fear of losing setup is why people stick with SaaS. One-click backup + restore removes that fear.
|
||||
|
||||
### DC-108: Multi-host fleet management — deploy across multiple servers
|
||||
- **status:** pending
|
||||
- **details:** Currently DashCaddy manages one Docker host. For users with multiple servers (like Sami's DNS1/DNS2/DNS3 setup), DashCaddy should connect to remote Docker daemons (via TLS or SSH) and manage containers across all hosts from one dashboard. "Deploy Nextcloud on DNS2" or "Deploy Plex on SAMI-PC" from the same UI. Show per-host resource usage and health. Effort: ~6 hr.
|
||||
- **impact:** Power users have multiple servers. Managing them individually defeats the purpose of a unified platform.
|
||||
|
||||
---
|
||||
|
||||
## Summary by Priority
|
||||
|
||||
| Priority | Count | Effort | Theme |
|
||||
@@ -253,4 +302,5 @@
|
||||
| P3 | 5 (DC-076–080) | ~16 hr | Future growth |
|
||||
| P3.5 | 9 (DC-086–094) | ~14.5 hr | Operational maturity |
|
||||
| P4 | 6 (DC-095–100) | ~16.5 hr | Advanced features |
|
||||
| **Total** | **39** | **~81.5 hr** | |
|
||||
| P5 | 8 (DC-101–108) | ~29 hr | Product vision: self-hosting platform |
|
||||
| **Total** | **47** | **~110.5 hr** | |
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# DashCaddy Product Vision
|
||||
|
||||
## The Problem
|
||||
|
||||
Self-hosting software is hard. To deploy a single app (Plex, Nextcloud, Vaultwarden, anything), you need to:
|
||||
|
||||
1. **Understand Docker** — images, containers, volumes, ports, networks, compose files
|
||||
2. **Configure a reverse proxy** — Caddy/Nginx/Traefik config files with obscure syntax
|
||||
3. **Set up TLS/HTTPS** — certificate generation, ACME, DNS challenges, trust stores
|
||||
4. **Configure DNS** — A records, CNAMEs, split-horizon DNS, DoH
|
||||
5. **Secure it** — firewall rules, auth, rate limiting, CSRF, CORS
|
||||
6. **Monitor it** — health checks, log rotation, disk space, restart policies
|
||||
7. **Maintain it** — updates, backups, migrations, disaster recovery
|
||||
|
||||
Each of these is a rabbit hole. A typical homelabber spends **hours per app** fighting configuration files, reading documentation, and debugging cryptic errors. This is why most people give up and just use SaaS.
|
||||
|
||||
## The Solution
|
||||
|
||||
**DashCaddy is a self-hosting platform.** It eliminates the complexity by fusing Docker, Caddy, and DNS management into one unified interface.
|
||||
|
||||
### Core Value: "Self-host anything in 30 seconds."
|
||||
|
||||
```
|
||||
User picks an app from the catalog
|
||||
↓
|
||||
DashCaddy deploys the Docker container
|
||||
↓
|
||||
DashCaddy generates the Caddy reverse proxy config automatically
|
||||
↓
|
||||
DashCaddy provisions TLS certificates
|
||||
↓
|
||||
DashCaddy configures DNS records
|
||||
↓
|
||||
DashCaddy sets up authentication (SSO gate)
|
||||
↓
|
||||
App is live at https://app.yourdomain.com — done.
|
||||
```
|
||||
|
||||
No editing config files. No Docker networking headaches. No TLS cert errors. No DNS archaeology.
|
||||
|
||||
## What Makes DashCaddy Different
|
||||
|
||||
### vs. Plain Docker / docker-compose
|
||||
- Docker gives you containers. DashCaddy gives you **containers + networking + TLS + DNS + auth + monitoring**.
|
||||
- Docker doesn't know about your domain. DashCaddy manages the full stack from DNS record to container port.
|
||||
- Docker doesn't tell you when your disk is full. DashCaddy monitors, alerts, and auto-cleans.
|
||||
|
||||
### vs. Portainer
|
||||
- Portainer is a **Docker UI**. DashCaddy is a **self-hosting platform**.
|
||||
- Portainer shows containers. DashCaddy shows services — with their URLs, health, certs, and auth.
|
||||
- Portainer doesn't manage Caddy, DNS, or TLS. DashCaddy fuses all three.
|
||||
- Portainer doesn't have a one-click app catalog with auto-configured reverse proxy + DNS + TLS.
|
||||
|
||||
### vs. CasaOS / Umbrel
|
||||
- These are **app stores**. DashCaddy is a **platform**.
|
||||
- They bundle their own Docker management. DashCaddy works with your existing Docker setup.
|
||||
- They don't manage Caddy or advanced DNS. DashCaddy handles the full network stack.
|
||||
- DashCaddy's SSO gate, credential injection, and security center are enterprise-grade features.
|
||||
|
||||
### vs. Yunohost / FreedomBox
|
||||
- These are **complete OS replacements**. DashCaddy is a **single Docker container**.
|
||||
- No OS install needed. Deploy DashCaddy on any Linux machine in 60 seconds.
|
||||
- DashCaddy works alongside your existing setup — it doesn't take over your machine.
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
### 1. One-Click Deploy (The "Wow" moment)
|
||||
Pick an app → DashCaddy handles everything:
|
||||
- Docker container creation with optimal defaults
|
||||
- Caddy reverse proxy route with TLS
|
||||
- DNS record creation
|
||||
- SSO authentication gate
|
||||
- Health check configuration
|
||||
- Disk budget allocation
|
||||
|
||||
### 2. Zero-Config Networking (The "It just works" layer)
|
||||
- Automatic TLS via Caddy's ACME + Let's Encrypt
|
||||
- Automatic DNS via Technitium/Cloudflare integration
|
||||
- Automatic reverse proxy with sane defaults
|
||||
- Automatic SSO with credential injection
|
||||
- Automatic subdomain routing (subdomain or subdirectory mode)
|
||||
|
||||
### 3. Self-Healing Infrastructure (The "Set it and forget it" layer)
|
||||
- Health checks with retry/backoff and notification on state transitions
|
||||
- Auto-restart failed containers
|
||||
- Auto-cleanup when disk approaches budget
|
||||
- Config drift detection and correction
|
||||
- SSL certificate expiration monitoring
|
||||
- Container log rotation and size enforcement
|
||||
- Docker image cleanup — old images pruned automatically
|
||||
|
||||
## Who Is It For?
|
||||
|
||||
1. **Homelabbers** — tired of spending weekends on config files
|
||||
2. **Small businesses** — want self-hosted alternatives to SaaS without hiring a sysadmin
|
||||
3. **Privacy-conscious users** — want to own their data without the technical burden
|
||||
4. **Developers** — want a quick way to deploy side projects with TLS + auth
|
||||
|
||||
## Revenue Model
|
||||
|
||||
- **Free tier**: Up to 5 services, community support
|
||||
- **Pro license**: Unlimited services, email alerts, advanced health checks, priority updates
|
||||
- **Site license**: Multi-host, team accounts, API access
|
||||
|
||||
## North Star Metric
|
||||
|
||||
**Time-to-first-app-deploy** — how long from install to having a working self-hosted service with HTTPS. Target: under 60 seconds.
|
||||
@@ -0,0 +1,64 @@
|
||||
const express = require('express');
|
||||
const { success, error: errorResponse } = require('../src/utils/responses');
|
||||
|
||||
/**
|
||||
* Disk space management routes
|
||||
*
|
||||
* GET /disk — current usage snapshot (budget, breakdown, status)
|
||||
* GET /disk/breakdown — detailed breakdown incl. per-container log sizes
|
||||
* GET /disk/config — get disk budget settings
|
||||
* POST /disk/config — update disk budget settings
|
||||
* POST /disk/cleanup — trigger manual cleanup (standard|aggressive|logs-only)
|
||||
*/
|
||||
module.exports = function({ diskSpaceMonitor, asyncHandler, log }) {
|
||||
const router = express.Router();
|
||||
|
||||
// Current disk usage snapshot
|
||||
router.get('/', asyncHandler(async (req, res) => {
|
||||
const snapshot = await diskSpaceMonitor.getSnapshot();
|
||||
success(res, snapshot);
|
||||
}, 'disk-get'));
|
||||
|
||||
// Detailed breakdown (includes per-container log sizes)
|
||||
router.get('/breakdown', asyncHandler(async (req, res) => {
|
||||
const breakdown = await diskSpaceMonitor.getDetailedBreakdown();
|
||||
success(res, breakdown);
|
||||
}, 'disk-breakdown'));
|
||||
|
||||
// Get disk budget config
|
||||
router.get('/config', asyncHandler(async (req, res) => {
|
||||
success(res, diskSpaceMonitor.getConfig());
|
||||
}, 'disk-config-get'));
|
||||
|
||||
// Update disk budget config
|
||||
router.post('/config', asyncHandler(async (req, res) => {
|
||||
const { diskBudgetGB, warningThresholdPct, criticalThresholdPct, autoCleanup, enabled, cleanupAggressivePct } = req.body;
|
||||
|
||||
const updates = {};
|
||||
if (typeof diskBudgetGB === 'number' && diskBudgetGB > 0) updates.diskBudgetGB = Math.min(diskBudgetGB, 1000);
|
||||
if (typeof warningThresholdPct === 'number') updates.warningThresholdPct = Math.min(Math.max(warningThresholdPct, 50), 99);
|
||||
if (typeof criticalThresholdPct === 'number') updates.criticalThresholdPct = Math.min(Math.max(criticalThresholdPct, 60), 99);
|
||||
if (typeof cleanupAggressivePct === 'number') updates.cleanupAggressivePct = Math.min(Math.max(cleanupAggressivePct, 70), 99);
|
||||
if (typeof autoCleanup === 'boolean') updates.autoCleanup = autoCleanup;
|
||||
if (typeof enabled === 'boolean') updates.enabled = enabled;
|
||||
|
||||
const config = diskSpaceMonitor.configure(updates);
|
||||
log.info('disk', 'Disk budget updated', updates);
|
||||
|
||||
success(res, { message: 'Disk budget updated', config });
|
||||
}, 'disk-config-set'));
|
||||
|
||||
// Manual cleanup trigger
|
||||
router.post('/cleanup', asyncHandler(async (req, res) => {
|
||||
const level = req.body?.level || 'standard';
|
||||
if (!['standard', 'aggressive', 'logs-only'].includes(level)) {
|
||||
return errorResponse(res, 'Invalid cleanup level. Use: standard, aggressive, or logs-only', 400);
|
||||
}
|
||||
|
||||
log.info('disk', 'Manual cleanup triggered', { level, by: req.auth?.user || 'api' });
|
||||
const result = await diskSpaceMonitor.performCleanup(level);
|
||||
success(res, result);
|
||||
}, 'disk-cleanup'));
|
||||
|
||||
return router;
|
||||
};
|
||||
@@ -90,9 +90,11 @@ const DependencyManager = require('./managers/dependency-manager');
|
||||
const autoRestartRoutes = require('../routes/auto-restart');
|
||||
const configDriftRoutes = require('../routes/config-drift');
|
||||
const sslMonitorRoutes = require('../routes/ssl-monitor');
|
||||
const diskSpaceRoutes = require('../routes/disk-space');
|
||||
const { AutoRestartManager } = require('./managers/auto-restart-manager');
|
||||
const { ConfigDriftDetector } = require('./managers/config-drift-detector');
|
||||
const SSLMonitor = require('./monitoring/ssl-monitor');
|
||||
const { DiskSpaceMonitor } = require('./monitoring/disk-space-monitor');
|
||||
const DNSPropagationChecker = require('./dns/dns-propagation');
|
||||
|
||||
// Constants
|
||||
@@ -455,6 +457,12 @@ async function createApp() {
|
||||
sslMonitor.start(3600000); // 1 hour
|
||||
log.info('app', 'SSL monitor initialized');
|
||||
|
||||
// Initialize disk space monitor (disk budget + auto-cleanup)
|
||||
const diskSpaceMonitor = new DiskSpaceMonitor({ log, config: ctx.siteConfig });
|
||||
ctx.diskSpaceMonitor = diskSpaceMonitor;
|
||||
diskSpaceMonitor.start(600000); // 10 min
|
||||
log.info('app', 'Disk space monitor initialized', { budgetGB: diskSpaceMonitor.getConfig().diskBudgetGB });
|
||||
|
||||
// Initialize DNS propagation checker
|
||||
const dnsPropagationChecker = new DNSPropagationChecker(ctx);
|
||||
ctx.dnsPropagationChecker = dnsPropagationChecker;
|
||||
@@ -709,6 +717,11 @@ async function createApp() {
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
logError: ctx.logError,
|
||||
}));
|
||||
apiRouter.use('/disk', diskSpaceRoutes({
|
||||
diskSpaceMonitor: ctx.diskSpaceMonitor,
|
||||
asyncHandler: ctx.asyncHandler,
|
||||
log: ctx.log,
|
||||
}));
|
||||
|
||||
// Inline API routes (mounted under /api/v1 below)
|
||||
// Note: /health lives at root only — see root-level health check below.
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* Disk Space Monitor
|
||||
*
|
||||
* Tracks Docker + system disk usage against a user-configured budget.
|
||||
* When usage exceeds thresholds, triggers automatic cleanup and notifications.
|
||||
*
|
||||
* Key concepts:
|
||||
* - diskBudgetGB: How much disk the user is willing to give DashCaddy (default 10)
|
||||
* - The monitor calculates Docker's footprint (images, volumes, containers, build cache)
|
||||
* - Breakdown shows where space goes so users can make informed decisions
|
||||
* - Auto-cleanup triggers at 80% (warning), 90% (aggressive), 95% (critical)
|
||||
*/
|
||||
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const DEFAULT_BUDGET_GB = 10;
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: true,
|
||||
diskBudgetGB: DEFAULT_BUDGET_GB,
|
||||
warningThresholdPct: 80,
|
||||
criticalThresholdPct: 90,
|
||||
autoCleanup: true,
|
||||
cleanupAggressivePct: 95,
|
||||
};
|
||||
|
||||
class DiskSpaceMonitor extends EventEmitter {
|
||||
constructor({ log, config }) {
|
||||
super();
|
||||
this.log = log;
|
||||
this.config = config;
|
||||
this.lastSnapshot = null;
|
||||
this.lastCleanup = null;
|
||||
this.intervalHandle = null;
|
||||
this.diskConfig = { ...DEFAULT_CONFIG };
|
||||
this._loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load disk budget config from the site config file
|
||||
* Stored under `diskSpace` key in config.json
|
||||
*/
|
||||
_loadConfig() {
|
||||
try {
|
||||
const raw = this.config?.diskSpace;
|
||||
if (raw) {
|
||||
this.diskConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
...raw,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update disk space settings
|
||||
*/
|
||||
configure(updates) {
|
||||
const prev = { ...this.diskConfig };
|
||||
this.diskConfig = { ...this.diskConfig, ...updates };
|
||||
this._persistConfig();
|
||||
this.emit('config-changed', { prev, current: this.diskConfig });
|
||||
return this.diskConfig;
|
||||
}
|
||||
|
||||
_persistConfig() {
|
||||
// The config is persisted by the caller (settings route) which merges
|
||||
// into config.json. We just expose the current state.
|
||||
if (this.config) {
|
||||
this.config.diskSpace = this.diskConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a disk usage snapshot using `df` and `docker system df -v`
|
||||
*/
|
||||
async getSnapshot() {
|
||||
const [diskInfo, dockerInfo] = await Promise.all([
|
||||
this._getDiskInfo(),
|
||||
this._getDockerInfo(),
|
||||
]);
|
||||
|
||||
const snapshot = {
|
||||
timestamp: new Date().toISOString(),
|
||||
system: diskInfo,
|
||||
docker: dockerInfo,
|
||||
budget: {
|
||||
configuredGB: this.diskConfig.diskBudgetGB,
|
||||
dockerUsageGB: dockerInfo.totalGB,
|
||||
remainingBudgetGB: Math.max(0, this.diskConfig.diskBudgetGB - dockerInfo.totalGB),
|
||||
budgetUsedPct: Math.min(100, Math.round((dockerInfo.totalGB / this.diskConfig.diskBudgetGB) * 100)),
|
||||
status: this._getBudgetStatus(dockerInfo.totalGB),
|
||||
},
|
||||
config: { ...this.diskConfig },
|
||||
lastCleanup: this.lastCleanup,
|
||||
};
|
||||
|
||||
this.lastSnapshot = snapshot;
|
||||
|
||||
// Check thresholds and emit events
|
||||
this._checkThresholds(snapshot);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
_getBudgetStatus(dockerUsageGB) {
|
||||
const pct = (dockerUsageGB / this.diskConfig.diskBudgetGB) * 100;
|
||||
if (pct >= this.diskConfig.cleanupAggressivePct) return 'critical';
|
||||
if (pct >= this.diskConfig.criticalThresholdPct) return 'aggressive';
|
||||
if (pct >= this.diskConfig.warningThresholdPct) return 'warning';
|
||||
return 'healthy';
|
||||
}
|
||||
|
||||
_checkThresholds(snapshot) {
|
||||
const { status, budgetUsedPct } = snapshot.budget;
|
||||
if (status === 'critical' || status === 'aggressive') {
|
||||
this.emit('budget-exceeded', snapshot);
|
||||
if (this.diskConfig.autoCleanup) {
|
||||
this.performCleanup(status === 'critical' ? 'aggressive' : 'standard').catch(() => {});
|
||||
}
|
||||
} else if (status === 'warning') {
|
||||
this.emit('budget-warning', snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
async _getDiskInfo() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('df', ['-B1', '/']);
|
||||
const lines = stdout.trim().split('\n');
|
||||
const parts = lines[1].split(/\s+/);
|
||||
return {
|
||||
totalBytes: parseInt(parts[1], 10),
|
||||
usedBytes: parseInt(parts[2], 10),
|
||||
availableBytes: parseInt(parts[3], 10),
|
||||
usedPct: parseInt(parts[4], 10),
|
||||
mount: parts[5],
|
||||
totalGB: Math.round(parseInt(parts[1], 10) / 1073741824 * 10) / 10,
|
||||
usedGB: Math.round(parseInt(parts[2], 10) / 1073741824 * 10) / 10,
|
||||
availableGB: Math.round(parseInt(parts[3], 10) / 1073741824 * 10) / 10,
|
||||
};
|
||||
} catch {
|
||||
return { totalBytes: 0, usedBytes: 0, availableBytes: 0, usedPct: 0, totalGB: 0, usedGB: 0, availableGB: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async _getDockerInfo() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['system', 'df', '--format', '{{json .}}']);
|
||||
const lines = stdout.trim().split('\n').filter(Boolean);
|
||||
|
||||
let images = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let containers = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let volumes = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
let buildCache = { count: 0, totalGB: 0, reclaimableGB: 0 };
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const d = JSON.parse(line);
|
||||
const type = d.Type?.toLowerCase() || '';
|
||||
const sizeGB = this._parseSizeToGB(d.Size);
|
||||
const reclaimGB = this._parseSizeToGB(d.Reclaimable);
|
||||
|
||||
if (type === 'images') images = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'containers') containers = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'local volumes') volumes = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
else if (type === 'build cache') buildCache = { count: parseInt(d.TotalCount, 10) || 0, totalGB: sizeGB, reclaimableGB: reclaimGB };
|
||||
} catch { /* skip unparseable lines */ }
|
||||
}
|
||||
|
||||
const totalGB = Math.round((images.totalGB + containers.totalGB + volumes.totalGB + buildCache.totalGB) * 100) / 100;
|
||||
const reclaimableGB = Math.round((images.reclaimableGB + containers.reclaimableGB + volumes.reclaimableGB + buildCache.reclaimableGB) * 100) / 100;
|
||||
|
||||
return {
|
||||
images,
|
||||
containers,
|
||||
volumes,
|
||||
buildCache,
|
||||
totalGB,
|
||||
reclaimableGB,
|
||||
};
|
||||
} catch {
|
||||
return { images: {}, containers: {}, volumes: {}, buildCache: {}, totalGB: 0, reclaimableGB: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Docker's human-readable size strings (e.g., "2.519GB", "8.108MB", "0B")
|
||||
*/
|
||||
_parseSizeToGB(str) {
|
||||
if (!str || str === '0B') return 0;
|
||||
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const multipliers = { B: 1e-9, KB: 1e-6, MB: 1e-3, GB: 1, TB: 1e3 };
|
||||
return Math.round(value * (multipliers[unit] || 0) * 1000) / 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get per-container log file sizes (the hidden disk hog)
|
||||
*/
|
||||
async _getContainerLogs() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('sh', ['-c', 'for f in /var/lib/docker/containers/*/*-json.log; do [ -f "$f" ] && stat -c "%s %n" "$f"; done 2>/dev/null | sort -rn | head -10']);
|
||||
const entries = [];
|
||||
for (const line of stdout.trim().split('\n').filter(Boolean)) {
|
||||
const [sizeStr, ...fileParts] = line.split(' ');
|
||||
const sizeBytes = parseInt(sizeStr, 10);
|
||||
entries.push({
|
||||
sizeBytes,
|
||||
sizeMB: Math.round(sizeBytes / 1048576 * 10) / 10,
|
||||
file: fileParts.join(' '),
|
||||
});
|
||||
}
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform cleanup
|
||||
* @param {string} level - 'standard' | 'aggressive' | 'logs-only'
|
||||
* @returns {Object} cleanup result with bytes reclaimed
|
||||
*/
|
||||
async performCleanup(level = 'standard') {
|
||||
const startTime = Date.now();
|
||||
const result = {
|
||||
level,
|
||||
startedAt: new Date(startTime).toISOString(),
|
||||
actions: [],
|
||||
bytesReclaimed: 0,
|
||||
};
|
||||
|
||||
try {
|
||||
// Always: truncate oversized container logs
|
||||
const logsBefore = await this._getContainerLogs();
|
||||
let logBytesFreed = 0;
|
||||
for (const log of logsBefore) {
|
||||
if (log.sizeBytes > 100 * 1048576) { // > 100MB
|
||||
try {
|
||||
await execFileAsync('truncate', ['-s', '0', log.file]);
|
||||
logBytesFreed += log.sizeBytes;
|
||||
result.actions.push({ action: 'truncate-log', file: log.file, freedBytes: log.sizeBytes });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
result.bytesReclaimed += logBytesFreed;
|
||||
|
||||
// Always: vacuum journald to 200MB
|
||||
try {
|
||||
const { stdout } = await execFileAsync('journalctl', ['--vacuum-size=200M']);
|
||||
const freedMatch = stdout.match(/freed ([\d.]+[KMGT]?B)/i);
|
||||
if (freedMatch) {
|
||||
const freedBytes = this._humanToBytes(freedMatch[1]);
|
||||
result.bytesReclaimed += freedBytes;
|
||||
result.actions.push({ action: 'vacuum-journal', freedBytes, freedHuman: freedMatch[1] });
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
|
||||
if (level === 'standard' || level === 'aggressive') {
|
||||
// Prune dangling images
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-f', '--filter', 'dangling=true']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-dangling-images', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune unused volumes
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['volume', 'prune', '-f']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-unused-volumes', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune build cache (keep last 500MB)
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['builder', 'prune', '-f', '--keep-storage', '500m']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-build-cache', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
if (level === 'aggressive') {
|
||||
// Remove ALL images not used by running containers
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['image', 'prune', '-a', '-f']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-all-unused-images', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
|
||||
// Prune stopped containers older than 24h
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', ['container', 'prune', '-f', '--filter', 'until=24h']);
|
||||
const reclaimed = this._extractDockerReclaimed(stdout);
|
||||
result.bytesReclaimed += reclaimed;
|
||||
result.actions.push({ action: 'prune-old-containers', freedBytes: reclaimed });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
result.completedAt = new Date().toISOString();
|
||||
result.durationMs = Date.now() - startTime;
|
||||
result.bytesReclaimedGB = Math.round(result.bytesReclaimed / 1073741824 * 100) / 100;
|
||||
|
||||
this.lastCleanup = result;
|
||||
this.emit('cleanup-complete', result);
|
||||
|
||||
if (this.log) {
|
||||
this.log.info('disk', 'Disk cleanup completed', {
|
||||
level,
|
||||
bytesReclaimed: result.bytesReclaimed,
|
||||
GBReclaimed: result.bytesReclaimedGB,
|
||||
durationMs: result.durationMs,
|
||||
actions: result.actions.length,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (err) {
|
||||
result.error = err.message;
|
||||
result.completedAt = new Date().toISOString();
|
||||
if (this.log) {
|
||||
this.log.error('disk', 'Disk cleanup failed', { error: err.message, level });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
_humanToBytes(str) {
|
||||
const match = str.match(/^([\d.]+)(B|KB|MB|GB|TB)$/i);
|
||||
if (!match) return 0;
|
||||
const value = parseFloat(match[1]);
|
||||
const unit = match[2].toUpperCase();
|
||||
const multipliers = { B: 1, KB: 1024, MB: 1048576, GB: 1073741824, TB: 1099511627776 };
|
||||
return Math.round(value * (multipliers[unit] || 0));
|
||||
}
|
||||
|
||||
_extractDockerReclaimed(stdout) {
|
||||
const match = stdout.match(/reclaimed\s+([\d.]+[KMGT]?B)/i) || stdout.match(/Total reclaimed space:\s*([\d.]+[KMGT]?B)/i);
|
||||
if (match) return this._humanToBytes(match[1]);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic monitoring
|
||||
* @param {number} intervalMs - check interval (default 10 minutes)
|
||||
*/
|
||||
start(intervalMs = 600000) {
|
||||
if (this.intervalHandle) return;
|
||||
this.log?.info?.('disk', 'Disk space monitor started', { intervalMs });
|
||||
// Initial check
|
||||
this.getSnapshot().catch(() => {});
|
||||
this.intervalHandle = setInterval(() => {
|
||||
this.getSnapshot().catch(() => {});
|
||||
}, intervalMs);
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.intervalHandle) {
|
||||
clearInterval(this.intervalHandle);
|
||||
this.intervalHandle = null;
|
||||
}
|
||||
}
|
||||
|
||||
getConfig() {
|
||||
return { ...this.diskConfig };
|
||||
}
|
||||
|
||||
async getDetailedBreakdown() {
|
||||
const [snapshot, containerLogs] = await Promise.all([
|
||||
this.getSnapshot(),
|
||||
this._getContainerLogs(),
|
||||
]);
|
||||
return {
|
||||
...snapshot,
|
||||
containerLogs,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { DiskSpaceMonitor, DEFAULT_DISK_CONFIG: DEFAULT_CONFIG };
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/bin/bash
|
||||
# DashCaddy Docker Space Management
|
||||
# Runs via cron to keep Docker disk usage under control
|
||||
# Prevents the overlay2 + dangling volumes + stale images that fill the disk
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MAX_DISK_PCT=85 # Alert if disk usage exceeds this
|
||||
LOG_PREFIX="[dc-disk]"
|
||||
|
||||
# 1. Remove dangling (untagged) images
|
||||
echo "$LOG_PREFIX Pruning dangling images..."
|
||||
docker image prune -f --filter "dangling=true" 2>/dev/null || true
|
||||
|
||||
# 2. Remove unused volumes (volumes not attached to any container)
|
||||
echo "$LOG_PREFIX Pruning unused volumes..."
|
||||
docker volume prune -f 2>/dev/null || true
|
||||
|
||||
# 3. Remove old build cache
|
||||
echo "$LOG_PREFIX Pruning build cache..."
|
||||
docker builder prune -f --keep-storage 500m 2>/dev/null || true
|
||||
|
||||
# 4. Remove stopped containers older than 7 days
|
||||
echo "$LOG_PREFIX Pruning old stopped containers..."
|
||||
docker container prune -f --filter "until=168h" 2>/dev/null || true
|
||||
|
||||
# 5. Remove images not used by any container (keep only running images)
|
||||
# Only remove images older than 7 days to avoid breaking recent updates
|
||||
echo "$LOG_PREFIX Pruning unused images (>7 days old)..."
|
||||
docker image prune -a -f --filter "until=168h" --filter "dangling=false" 2>/dev/null || true
|
||||
|
||||
# 6. Truncate container log files that are bigger than 100MB
|
||||
echo "$LOG_PREFIX Checking container logs..."
|
||||
for logfile in /var/lib/docker/containers/*/*-json.log; do
|
||||
if [ -f "$logfile" ]; then
|
||||
size=$(stat -c%s "$logfile" 2>/dev/null || echo 0)
|
||||
if [ "$size" -gt 104857600 ]; then # 100MB
|
||||
echo "$LOG_PREFIX Truncating $(basename $logfile) ($(( size / 1048576 ))MB)"
|
||||
truncate -s 0 "$logfile"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 7. Vacuum journald logs to 200MB
|
||||
echo "$LOG_PREFIX Vacuuming journal logs..."
|
||||
journalctl --vacuum-size=200M 2>/dev/null || true
|
||||
|
||||
# 8. Clear pip/npm caches that grow over time
|
||||
echo "$LOG_PREFIX Clearing stale caches..."
|
||||
rm -rf /root/.cache/pip/cache/html 2>/dev/null || true
|
||||
rm -rf /root/.cache/npm/_cacache 2>/dev/null || true
|
||||
|
||||
# 9. Report disk usage
|
||||
USAGE=$(df / | tail -1 | awk '{print $5}' | tr -d '%')
|
||||
FREE_GB=$(df -h / | tail -1 | awk '{print $4}')
|
||||
echo "$LOG_PREFIX Disk usage: ${USAGE}% (${FREE_GB} free)"
|
||||
|
||||
if [ "$USAGE" -gt "$MAX_DISK_PCT" ]; then
|
||||
echo "$LOG_PREFIX WARNING: Disk usage above ${MAX_DISK_PCT}%!"
|
||||
# More aggressive: remove ALL images not used by running containers
|
||||
echo "$LOG_PREFIX Aggressive prune: removing all unused images..."
|
||||
docker image prune -a -f 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "$LOG_PREFIX Done."
|
||||
Reference in New Issue
Block a user