Compare commits

...
3 Commits
Author SHA1 Message Date
Krystie cd3d0cd8ff feat: VM disk sandboxing — bounded virtual disk per platform
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
ARCHITECTURE:
- Windows: dedicated WSL2 distro with fixed VHDX, Docker inside
- macOS: Lima VM with fixed disk, Docker inside
- Linux: sparse ext4 loopback image, Docker data-root inside

NEW FILES:
- vm-provisioner.js: core provisioning engine (create/start/destroy/export)
  - Disk presets: Minimal(10GB), Balanced(30GB), Power(100GB), Custom
  - Sparse images that grow on demand (start at ~0 bytes)
  - Full lifecycle: provision → deploy DashCaddy → destroy (clean removal)
  - Data export before uninstall for users who want to migrate
- vm-ipc.js: Electron IPC handlers connecting wizard to provisioner
  - vm:provision, vm:destroy, vm:get-status, vm:export-data, vm:get-presets
- disk-budget-step.js: wizard UI step with preset cards + custom slider
  - Real-time free space check against selected disk size
  - Plain English description of what each tier handles

UPDATED:
- caddyfile-generator.js: docker-compose now includes disk safety env vars
  (health retention, stats caps, memory limits) as defense-in-depth
  even inside the VM sandbox

GUARANTEE: DashCaddy physically cannot exceed the storage budget.
The OS enforces the limit at the disk/image level, not our code.
2026-08-12 23:02:51 -07:00
Krystie 7ebb1b1a01 feat: Log Insights panel — plain English activity summary + safe log disposal
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- New route /api/v1/log-insights: analyzes audit logs + security events
  - Shows top IPs with request counts, failures, and top actions
  - Plain English insights (heavy users, auth failures, security alerts)
  - Summary stats: total requests, unique IPs, failed actions
  - Storage info showing log file sizes and entry counts
- New route POST /api/v1/log-insights/dispose: preview-then-confirm cleanup
  - First call shows what would be deleted (preview mode)
  - Second call with confirm:true actually deletes
  - Configurable retention period (default 30 days)
- Frontend panel with modal UI showing insights as cards
  - Period selector (1h, 6h, 24h, 7d)
  - Top visitors table with IP, requests, failures, actions, last seen
  - Storage info footer
  - Clean Old Logs button with preview confirmation dialog
- Wired into app.js and dashboard navbar (🔍 Insights button)
- Addresses QA issue: users need to see who is accessing before cleanup
2026-08-12 20:44:59 -07:00
Krystie ae54927210 Merge: 92 app templates + Authelia deployment on test server 2026-08-12 18:07:13 -07:00
10 changed files with 1150 additions and 6 deletions
+153
View File
@@ -0,0 +1,153 @@
const express = require('express');
const fs = require('fs').promises;
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
const router = express.Router();
// GET /api/v1/log-insights — Plain English summary of who's doing what
router.get('/log-insights', asyncHandler(async (req, res) => {
const hours = parseInt(req.query.hours) || 24;
const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString();
// --- Collect data ---
const auditEntries = await auditLogger.query({ limit: 10000 });
const recentAudit = auditEntries.filter(e => e.timestamp >= since);
let securityEvents = [];
try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {}
// --- Analyze IPs ---
const ipMap = {};
recentAudit.forEach(e => {
const ip = e.ip || 'unknown';
if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 };
const s = ipMap[ip];
s.count++;
const cat = (e.action || 'unknown').split('.')[0];
s.actions[cat] = (s.actions[cat] || 0) + 1;
if (e.resource) s.resources.add(e.resource);
if (e.timestamp < s.first) s.first = e.timestamp;
if (e.timestamp > s.last) s.last = e.timestamp;
if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++;
});
// --- Build plain-English insights ---
const insights = [];
const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count);
// Heavy users
ipArray.slice(0, 3).forEach(([ip, s]) => {
const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0];
insights.push({
severity: s.count > 500 ? 'warning' : 'info',
title: ip + ' — ' + s.count + ' requests in ' + hours + 'h',
plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' +
(s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.'
});
});
// Auth failures
const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length;
if (totalFailures > 5) {
insights.push({
severity: totalFailures > 50 ? 'warning' : 'info',
title: totalFailures + ' failed actions',
plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' +
(totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '')
});
}
// Security events
const secBySev = {};
securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; });
if (secBySev.critical || secBySev.error) {
insights.push({
severity: 'warning',
title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts',
plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.'
});
}
// Quiet / nothing
if (insights.length === 0) {
insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' });
}
// --- Storage info ---
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
let storage = {};
try {
const a = await fs.stat(auditPath);
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
} catch {}
try {
const s = await fs.stat(secPath);
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
} catch {}
ok(res, {
period: { hours, since, until: new Date().toISOString() },
summary: {
totalRequests: recentAudit.length,
uniqueIPs: ipArray.length,
securityEvents: securityEvents.length,
failedActions: totalFailures
},
topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({
ip: ip,
count: s.count,
failures: s.failures,
topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3),
activeFrom: s.first,
lastSeen: s.last
})),
insights: insights,
storage: storage
});
}));
// POST /api/v1/log-insights/dispose — Preview then confirm cleanup
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
const keepDays = parseInt(req.body.keepDays) || 30;
const confirm = req.body.confirm === true;
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
const auditData = JSON.parse(auditRaw);
const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; });
const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; });
const secLines = secRaw.split('\n').filter(Boolean);
const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } });
if (!confirm) {
ok(res, {
preview: true,
message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true} to proceed.',
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
cutoffDate: cutoff
});
return;
}
// Execute cleanup
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } });
await fs.writeFile(secPath, keptSec.join('\n') + '\n');
ok(res, {
disposed: true,
deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length },
cutoffDate: cutoff
});
}));
return router;
};
+15
View File
@@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events');
const workflowsRoutes = require('../routes/workflows');
const dependenciesRoutes = require('../routes/dependencies');
const securityRoutes = require('../routes/security');
const logInsightsRoutes = require('../routes/log-insights');
const billingRoutes = require('../routes/billing');
const DependencyManager = require('./managers/dependency-manager');
const autoRestartRoutes = require('../routes/auto-restart');
@@ -753,6 +754,20 @@ async function createApp() {
apiRouter.use('/security', securityRoutes({
log: ctx.log,
}));
// Log Insights — plain English activity summary + safe log disposal
apiRouter.use(logInsightsRoutes({
asyncHandler: ctx.asyncHandler,
ok: ctx.ok,
auditLogger: ctx.auditLogger,
securityEventStore: (function() {
try {
var getStore = require('./security/event-store').getStore;
return getStore();
} catch (e) { return null; }
})()
}));
apiRouter.use('/dependencies', dependenciesRoutes({
dependencyManager: ctx.dependencyManager,
servicesStateManager: ctx.servicesStateManager,
@@ -19,6 +19,7 @@ const STATS_HOURLY_FILE = process.env.STATS_HOURLY_FILE || path.join(platformPat
const STATS_DAILY_FILE = process.env.STATS_DAILY_FILE || path.join(platformPaths.dataDir, 'container-stats-daily.json');
const ALERT_CONFIG_FILE = process.env.ALERT_CONFIG_FILE || path.join(platformPaths.dataDir, 'alert-config.json');
const ALERT_HISTORY_FILE = process.env.ALERT_HISTORY_FILE || path.join(platformPaths.dataDir, 'alert-history.json');
const MAX_STATS_PER_CONTAINER = parseInt(process.env.STATS_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
const STATS_RETENTION_HOURS = parseInt(process.env.STATS_RETENTION_HOURS || '168', 10); // 7 days raw
const STATS_HOURLY_RETENTION_DAYS = parseInt(process.env.STATS_HOURLY_RETENTION_DAYS || '30', 10); // 30 days hourly
const STATS_DAILY_RETENTION_DAYS = parseInt(process.env.STATS_DAILY_RETENTION_DAYS || '365', 10); // 365 days daily
@@ -242,6 +243,11 @@ class ResourceMonitor extends EventEmitter {
containerStats.history = containerStats.history.filter(s =>
new Date(s.timestamp).getTime() > cutoffTime
);
// Also cap total entries per container (disk explosion fix)
if (containerStats.history.length > MAX_STATS_PER_CONTAINER) {
containerStats.history = containerStats.history.slice(-MAX_STATS_PER_CONTAINER);
}
}
/**
@@ -620,7 +626,7 @@ class ResourceMonitor extends EventEmitter {
saveStats() {
try {
const data = Object.fromEntries(this.stats);
fs.writeFileSync(STATS_FILE, JSON.stringify(data, null, 2));
fs.writeFileSync(STATS_FILE, JSON.stringify(data)); // Compact JSON to reduce file size
} catch (error) {
log.error('monitor', error, { operation: 'saveStats' });
}
+12 -2
View File
@@ -30,6 +30,7 @@ const LEGACY_HEALTH_CONFIG_FILE = path.join(__dirname, 'health-config.json');
const LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
const MAX_CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_MAX_INTERVAL || '300000', 10); // 5 minutes max backoff
const MAX_ENTRIES_PER_SERVICE = parseInt(process.env.HEALTH_MAX_ENTRIES || '500', 10); // Cap to prevent disk explosion
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
class HealthChecker extends EventEmitter {
@@ -217,7 +218,7 @@ class HealthChecker extends EventEmitter {
statusCode: res.statusCode,
message: healthy ? 'Service is healthy' : 'Service check failed',
details: {
headers: res.headers,
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
bodyLength: data.length
}
});
@@ -285,6 +286,11 @@ class HealthChecker extends EventEmitter {
}
this.history[serviceId].push(status);
// Cap entries to prevent unbounded growth (disk explosion fix)
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
}
// Emit status event
this.emit('status-check', status);
@@ -565,6 +571,10 @@ class HealthChecker extends EventEmitter {
this.history[serviceId] = this.history[serviceId].filter(h =>
new Date(h.timestamp).getTime() > cutoffTime
);
// Also cap total entries per service
if (this.history[serviceId].length > MAX_ENTRIES_PER_SERVICE) {
this.history[serviceId] = this.history[serviceId].slice(-MAX_ENTRIES_PER_SERVICE);
}
}
}
@@ -616,7 +626,7 @@ class HealthChecker extends EventEmitter {
*/
saveHistory() {
try {
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history, null, 2));
fs.writeFileSync(HEALTH_HISTORY_FILE, JSON.stringify(this.history)); // Compact JSON (no pretty-print) to reduce file size
} catch (error) {
this.emit('log', 'error', `Error saving history: ${error.message}`);
}
@@ -7,6 +7,10 @@ const { DEFAULT_PORTS } = require('../shared/constants');
*
* Generates production-grade configs that match the patterns used by the
* running DashCaddy deployment (CORS snippets, admin origins, PKI, etc.)
*
* DISK SAFETY: All generated configs include sensible defaults for storage
* limits — health retention, stats caps, and memory limits — so a fresh
* install will never silently fill a user's disk.
*/
class CaddyfileGenerator {
/**
@@ -279,19 +283,43 @@ class CaddyfileGenerator {
}
/**
* Generate docker-compose.yml for running the API server
* Generate docker-compose.yml for running the API server.
*
* DISK SAFETY: Includes env vars for health retention, stats caps, and
* memory limits derived from the disk budget the user selected during
* install. These prevent the disk-explosion bugs seen in early versions.
*
* @param {string} installPath - Installation directory
* @param {Object} options - Configuration options
* @param {number} options.apiPort - API server port
* @param {string} options.lanIP - Host LAN IP address
* @param {string} options.tailscaleIP - Host Tailscale IP address
* @param {string} options.domainMode - Domain mode (local, public, custom-tld)
* @param {Object} [options.disk] - Disk budget settings
* @param {number} [options.disk.healthRetentionDays=14] - Health history retention
* @param {number} [options.disk.healthMaxEntries=500] - Max health entries per service
* @param {number} [options.disk.healthCheckInterval=30000] - Health check interval (ms)
* @param {number} [options.disk.statsMaxEntries=2000] - Max container stats entries
* @param {number} [options.disk.auditMaxEntries=1000] - Max audit log entries
* @param {number} [options.disk.backupLimitGB=10] - Backup storage limit
* @param {string} [options.dockerDataPath] - Docker data root override
* @param {number} [options.memoryLimitMB=1024] - Container memory limit
*/
generateDockerCompose(installPath, options = {}) {
const apiPort = options.apiPort || DEFAULT_PORTS.API;
const adminPort = DEFAULT_PORTS.CADDY_ADMIN;
const p = this._p.bind(this);
// Disk budget settings with safe defaults
const disk = options.disk || {};
const healthRetentionDays = disk.healthRetentionDays || 14;
const healthMaxEntries = disk.healthMaxEntries || 500;
const healthCheckInterval = disk.healthCheckInterval || 30000;
const statsMaxEntries = disk.statsMaxEntries || 2000;
const auditMaxEntries = disk.auditMaxEntries || 1000;
const backupLimitGB = disk.backupLimitGB || 10;
const memoryLimitMB = options.memoryLimitMB || 1024;
// Core volume mounts
let volumes = ` - ${p(installPath)}/Caddyfile:/caddyfile:rw
- ${p(installPath)}/services.json:/app/services.json:rw
@@ -308,12 +336,19 @@ class CaddyfileGenerator {
volumes += `\n - ${p(installPath)}/certs/pki/authorities/local:/app/pki:ro`;
}
// Environment variables
// Environment variables — disk safety baked in
let envVars = ` - CADDYFILE_PATH=/caddyfile
- CADDY_ADMIN_URL=http://host.docker.internal:${adminPort}
- ASSETS_PATH=/app/assets
- CREDENTIALS_FILE=/app/credentials.json
- NODE_ENV=production`;
- NODE_ENV=production
# --- Disk Safety ---
- HEALTH_HISTORY_RETENTION=${healthRetentionDays}
- HEALTH_MAX_ENTRIES=${healthMaxEntries}
- HEALTH_CHECK_INTERVAL=${healthCheckInterval}
- CONTAINER_STATS_MAX_ENTRIES=${statsMaxEntries}
- AUDIT_MAX_ENTRIES=${auditMaxEntries}
- BACKUP_MAX_STORAGE_BYTES=${backupLimitGB * 1024 * 1024 * 1024}`;
if (options.domainMode === 'custom-tld') {
envVars += `\n - CA_CERT_PATH=/app/pki/root.crt`;
@@ -339,6 +374,9 @@ ${envVars}
extra_hosts:
- "host.docker.internal:host-gateway"
restart: unless-stopped
# Memory limit prevents OOM during startup when all managers init
mem_limit: ${memoryLimitMB}m
memswap_limit: ${(memoryLimitMB * 2)}m
`;
return dockerCompose;
+112
View File
@@ -0,0 +1,112 @@
/**
* VM Provisioner IPC Handler
* Wires the Electron wizard to VMDiskProvisioner.
* Add to src/main/index.js alongside the existing IPC handlers.
*/
const { ipcMain } = require('electron');
const { VMDiskProvisioner, DISK_PRESETS } = require('./vm-provisioner');
const fs = require('fs').promises;
const path = require('path');
function registerVMHandlers(mainWindow) {
const provisioner = new VMDiskProvisioner();
// --- Get disk presets for wizard UI ---
ipcMain.handle('vm:get-presets', async () => {
return DISK_PRESETS;
});
// --- Get current VM status ---
ipcMain.handle('vm:get-status', async () => {
try {
const status = await provisioner.getStatus();
// Also check for saved vmInfo from previous install
try {
const configPath = path.join(getInstallBase(), '.dashcaddy-config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
if (config.vmInfo) {
status.vmInfo = config.vmInfo;
status.diskSizeGB = config.vmInfo.diskSizeGB;
}
} catch {}
return status;
} catch (e) {
return { platform: process.platform, running: false, error: e.message };
}
});
// --- Provision the VM sandbox ---
ipcMain.handle('vm:provision', async (event, opts) => {
try {
const result = await provisioner.provision({
...opts,
onProgress: (msg, pct) => {
mainWindow.webContents.send('vm:progress', { message: msg, percent: pct });
},
});
// Save vmInfo for uninstall
if (result.vmInfo) {
try {
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
let config = {};
try { config = JSON.parse(await fs.readFile(configPath, 'utf8')); } catch {}
config.vmInfo = result.vmInfo;
config.diskBudgetGB = opts.diskSizeGB;
await fs.writeFile(configPath, JSON.stringify(config, null, 2));
} catch {}
}
mainWindow.webContents.send('vm:complete', result);
return result;
} catch (error) {
mainWindow.webContents.send('vm:error', { error: error.message });
return { success: false, error: error.message };
}
});
// --- Destroy the VM sandbox (uninstall) ---
ipcMain.handle('vm:destroy', async (event, opts) => {
try {
// Load saved vmInfo
let vmInfo = opts.vmInfo;
if (!vmInfo) {
try {
const configPath = path.join(opts.installPath || getInstallBase(), '.dashcaddy-config.json');
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
vmInfo = config.vmInfo;
} catch {}
}
if (!vmInfo) {
return { success: false, error: 'No VM info found. Already uninstalled?' };
}
const result = await provisioner.destroy(vmInfo, {
exportDataPath: opts.exportDataPath || null,
});
return result;
} catch (error) {
return { success: false, error: error.message };
}
});
// --- Export data from VM (before uninstall) ---
ipcMain.handle('vm:export-data', async (event, opts) => {
try {
const result = await provisioner._exportData(opts.vmInfo, opts.exportPath);
return result;
} catch (error) {
return { success: false, error: error.message };
}
});
}
function getInstallBase() {
const { getPlatformInfo } = require('../shared/platform-utils');
return getPlatformInfo().defaultInstallPath;
}
module.exports = { registerVMHandlers };
@@ -0,0 +1,511 @@
/**
* VM Disk Provisioner — creates a bounded virtual disk for DashCaddy.
*
* PLATFORM STRATEGY:
* Windows: Dedicated WSL2 distro with a fixed-size VHDX.
* Docker runs inside WSL2, all data lives in the VHDX.
* Uninstall = wsl --unregister (deletes VHDX instantly).
*
* macOS: Lima VM with a fixed disk image.
* Docker runs inside Lima, all data lives in the disk image.
* Uninstall = limactl delete (removes VM + disk).
*
* Linux: Sparse ext4 loopback image mounted at /opt/dashcaddy-data.
* Docker --data-root pointed at the mount.
* Uninstall = unmount + rm image file.
*
* The user picks a disk size (default 20GB). DashCaddy is physically
* unable to exceed it — the OS enforces the limit, not our code.
*/
const { exec } = require('child_process');
const { promisify } = require('util');
const fs = require('fs').promises;
const path = require('path');
const platformUtils = require('../shared/platform-utils');
const execAsync = promisify(exec);
// Presets users pick from in the wizard
const DISK_PRESETS = {
minimal: { sizeGB: 10, label: 'Minimal (10GB)', desc: 'DashCaddy only, a few small apps' },
balanced: { sizeGB: 30, label: 'Balanced (30GB)', desc: 'DashCaddy + media tools + containers' },
power: { sizeGB: 100, label: 'Power (100GB)', desc: 'DashCaddy + heavy apps + lots of containers' },
custom: { sizeGB: 0, label: 'Custom', desc: 'Pick your own size' },
};
/**
* Main provisioner class.
*/
class VMDiskProvisioner {
constructor() {
this.platform = platformUtils.detectOS();
}
/**
* Provision the full sandboxed environment.
*
* @param {Object} opts
* @param {number} opts.diskSizeGB — virtual disk size
* @param {string} opts.installPath — where DashCaddy app files live (host)
* @param {number} opts.apiPort
* @param {Object} opts.domain — { mode, domain, tld, email }
* @param {function} [opts.onProgress] — callback(statusMsg, pct)
* @returns {Object} { success, dockerContext, dashboardUrl, vmInfo }
*/
async provision(opts) {
const { diskSizeGB = 30, onProgress = () => {} } = opts;
onProgress('Checking prerequisites', 5);
await this._checkPrerequisites();
onProgress('Creating virtual disk (' + diskSizeGB + 'GB)', 15);
const diskInfo = await this._createDisk(opts);
onProgress('Starting sandbox environment', 40);
const envInfo = await this._startEnvironment(diskInfo, opts);
onProgress('Installing Docker in sandbox', 60);
await this._ensureDocker(envInfo);
onProgress('Deploying DashCaddy into sandbox', 75);
const deployInfo = await this._deployDashCaddy(envInfo, opts);
onProgress('Configuring services', 90);
await this._configureServices(envInfo, opts);
onProgress('Complete', 100);
return {
success: true,
platform: this.platform,
diskSizeGB,
dockerContext: envInfo.dockerContext,
dashboardUrl: deployInfo.dashboardUrl,
vmInfo: {
type: envInfo.type,
name: envInfo.name,
diskPath: diskInfo.path,
diskSizeGB,
dockerDataRoot: envInfo.dockerDataRoot,
},
};
}
/**
* Remove the sandboxed environment completely.
* @param {Object} vmInfo — from provision()
* @param {Object} opts — { exportDataPath: null }
*/
async destroy(vmInfo, opts = {}) {
// Export data first if requested
if (opts.exportDataPath) {
await this._exportData(vmInfo, opts.exportDataPath);
}
switch (this.platform) {
case 'windows': return this._destroyWSL2(vmInfo);
case 'macos': return this._destroyLima(vmInfo);
case 'linux': return this._destroyLoopback(vmInfo);
default: throw new Error('Unsupported platform: ' + this.platform);
}
}
// =========================================================================
// PREREQUISITES
// =========================================================================
async _checkPrerequisites() {
const checks = [];
switch (this.platform) {
case 'windows':
checks.push(this._checkCommand('wsl', '--status', 'WSL2'));
break;
case 'macos':
checks.push(this._checkCommand('limactl', 'version', 'Lima'));
break;
case 'linux':
// Need root or sudo for loopback mount
if (process.getuid && process.getuid() !== 0) {
// Check if we can sudo
try { await execAsync('sudo -n true', { timeout: 5000 }); }
catch { throw new Error('Linux install needs root or passwordless sudo for loopback mount'); }
}
break;
}
const results = await Promise.all(checks);
const failed = results.filter(r => !r.ok);
if (failed.length) {
throw new Error('Missing: ' + failed.map(f => f.name).join(', ') +
'. Install instructions: https://dashcaddy.net/docs/installation');
}
}
async _checkCommand(cmd, versionArg, friendlyName) {
try {
await execAsync(`${cmd} ${versionArg}`, { timeout: 10000 });
return { ok: true, name: friendlyName };
} catch {
return { ok: false, name: friendlyName };
}
}
// =========================================================================
// WINDOWS: WSL2 Dedicated Distro
// =========================================================================
async _createDisk(opts) {
if (this.platform === 'windows') return this._createWSL2Disk(opts);
if (this.platform === 'macos') return this._createLimaDisk(opts);
return this._createLoopbackDisk(opts);
}
async _createWSL2Disk(opts) {
const distroName = 'dashcaddy';
const { diskSizeGB = 30 } = opts;
const wslPath = opts.installPath || path.join(process.env.LOCALAPPDATA || 'C:\\DashCaddy', 'DashCaddy');
const vhdxPath = path.join(wslPath, 'data.vhdx');
// Check if distro already exists
try {
const { stdout } = await execAsync('wsl -l -q', { timeout: 10000 });
if (stdout.includes(distroName)) {
return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: true };
}
} catch {}
// Download a minimal rootfs (Alpine for smallest footprint)
await fs.mkdir(wslPath, { recursive: true });
const rootfsUrl = 'https://dl-cdn.alpinelinux.org/alpine/v3.20/releases/x86_64/alpine-minirootfs-3.20.0-x86_64.tar.gz';
const rootfsPath = path.join(wslPath, 'rootfs.tar.gz');
await execAsync(`curl -L -o "${rootfsPath}" "${rootfsUrl}"`, { timeout: 120000 });
// Import as a new WSL2 distro — the VHDX is created automatically
// and capped by .wslconfig max disk size
await execAsync(`wsl --import ${distroName} "${wslPath}" "${rootfsPath}" --version 2`, { timeout: 60000 });
// Set disk size limit via wsl config
const wslconfigPath = path.join(wslPath, '.wslconfig');
await fs.writeFile(wslconfigPath, [
`[wsl2]`,
`vmDiskSize=${diskSizeGB}GB`,
`memory=2GB`,
`processors=2`,
].join('\n'));
// Clean up rootfs download
await fs.unlink(rootfsPath).catch(() => {});
return { type: 'wsl2', distroName, path: vhdxPath, diskSizeGB, existed: false };
}
async _startEnvironment(diskInfo, opts) {
if (this.platform === 'windows') return this._startWSL2(diskInfo, opts);
if (this.platform === 'macos') return this._startLima(diskInfo, opts);
return this._startLoopback(diskInfo, opts);
}
async _startWSL2(diskInfo, opts) {
const { distroName } = diskInfo;
// Start the distro and install Docker inside
const wslExec = (cmd) => execAsync(`wsl -d ${distroName} -- sh -c "${cmd}"`, { timeout: 60000 });
// Update apk and install Docker + dependencies
await wslExec('apk update && apk add docker docker-cli-compose openrc ca-certificates curl');
await wslExec('rc-update add docker default && service docker start');
// Create Docker data directory inside the VM
await wslExec('mkdir -p /var/lib/docker /opt/dashcaddy');
return {
type: 'wsl2',
name: distroName,
dockerContext: 'dashcaddy-wsl',
dockerDataRoot: '/var/lib/docker',
exec: wslExec,
};
}
// =========================================================================
// macOS: Lima VM
// =========================================================================
async _createLimaDisk(opts) {
const { diskSizeGB = 30 } = opts;
const vmName = 'dashcaddy';
const limaDir = path.join(process.env.HOME, '.lima', vmName);
// Check if VM already exists
try {
await execAsync(`limactl list ${vmName}`, { timeout: 10000 });
return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: true };
} catch {}
// Create Lima config with fixed disk
const config = {
vmType: 'qemu',
arch: 'x86_64',
images: [{
location: 'https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img',
arch: 'x86_64',
}],
cpus: 2,
memory: '2GiB',
disk: diskSizeGB + 'GiB',
mounts: [],
containerd: { system: false, user: false },
provision: {
mode: 'system',
script: 'apt-get update && apt-get install -y docker.io docker-compose-plugin',
},
// Forward the API port
portForwards: [{
guestSocket: '/var/run/docker.sock',
hostSocket: path.join(limaDir, 'sock', 'docker.sock'),
}],
};
const configPath = path.join(limaDir, 'lima.yaml');
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(configPath, require('yaml').stringify ? require('yaml').stringify(config) : JSON.stringify(config, null, 2));
await execAsync(`limactl start --name=${vmName} ${configPath}`, { timeout: 300000 });
return { type: 'lima', vmName, path: limaDir, diskSizeGB, existed: false };
}
async _startLima(diskInfo, opts) {
const { vmName } = diskInfo;
// Ensure VM is running
try { await execAsync(`limactl start ${vmName}`, { timeout: 60000 }); } catch {}
const limaExec = (cmd) => execAsync(`limactl shell ${vmName} -- bash -c "${cmd}"`, { timeout: 60000 });
// Ensure Docker is running
await limaExec('service docker start || true');
return {
type: 'lima',
name: vmName,
dockerContext: 'dashcaddy-lima',
dockerDataRoot: '/var/lib/docker',
exec: limaExec,
};
}
// =========================================================================
// LINUX: Loopback ext4 image
// =========================================================================
async _createLoopbackDisk(opts) {
const { diskSizeGB = 30 } = opts;
const imagePath = '/opt/dashcaddy-data.raw';
const mountPoint = '/opt/dashcaddy-data';
// Check if already mounted
try {
const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo mounted', { timeout: 5000 });
if (stdout.includes('mounted')) {
return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: true };
}
} catch {}
// Create sparse image (only uses space as data fills — starts at ~0 bytes)
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
await execAsync(`truncate -s ${diskSizeGB}G "${imagePath}"`, { timeout: 30000 });
// Format as ext4
await execAsync(`${sudo} mkfs.ext4 -F -L dashcaddy "${imagePath}"`, { timeout: 60000 });
// Mount
await execAsync(`${sudo} mkdir -p "${mountPoint}"`, { timeout: 5000 });
await execAsync(`${sudo} mount -o loop "${imagePath}" "${mountPoint}"`, { timeout: 10000 });
// Add to fstab for persistence across reboots
const fstabEntry = `${imagePath} ${mountPoint} ext4 loop,defaults 0 0`;
await execAsync(`grep -q '${imagePath}' /etc/fstab || echo '${fstabEntry}' | ${sudo} tee -a /etc/fstab`, { timeout: 5000 });
// Point Docker data-root at the mounted volume
await this._configureDockerDataRoot(mountPoint + '/docker', sudo);
return { type: 'loopback', imagePath, mountPoint, diskSizeGB, existed: false };
}
async _startLoopback(diskInfo, opts) {
const { mountPoint } = diskInfo;
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
// Ensure mounted
try {
await execAsync(`mountpoint -q ${mountPoint} || ${sudo} mount -o loop ${diskInfo.imagePath} ${mountPoint}`, { timeout: 10000 });
} catch {}
// Restart Docker to pick up new data-root
await execAsync(`${sudo} systemctl restart docker`, { timeout: 30000 }).catch(() => {});
return {
type: 'loopback',
name: 'dashcaddy-loopback',
dockerContext: 'default',
dockerDataRoot: mountPoint + '/docker',
exec: (cmd) => execAsync(cmd, { timeout: 60000 }),
};
}
async _configureDockerDataRoot(dataRoot, sudo) {
const daemonJsonPath = '/etc/docker/daemon.json';
let daemonJson = {};
try {
daemonJson = JSON.parse(await fs.readFile(daemonJsonPath, 'utf8'));
} catch {}
daemonJson['data-root'] = dataRoot;
await execAsync(`${sudo} mkdir -p ${dataRoot}`, { timeout: 5000 });
await execAsync(`${sudo} bash -c 'cat > ${daemonJsonPath} << EOF\n${JSON.stringify(daemonJson, null, 2)}\nEOF'`, { timeout: 5000 });
}
// =========================================================================
// DEPLOY + CONFIGURE (shared across platforms)
// =========================================================================
async _ensureDocker(envInfo) {
// Docker was installed during VM creation per-platform.
// Verify it's actually running.
if (envInfo.exec) {
try {
await envInfo.exec('docker info > /dev/null 2>&1');
return;
} catch {
// Try starting
if (this.platform === 'windows') await envInfo.exec('service docker start || true');
if (this.platform === 'macos') await envInfo.exec('service docker start || true');
}
}
}
async _deployDashCaddy(envInfo, opts) {
const apiPort = opts.apiPort || 3001;
const dashboardPort = opts.domain?.mode === 'public' ? null : (opts.dashboardPort || 8080);
// Inside the VM, download and run DashCaddy
// The VM has Docker running — we deploy the same container image
const deployScript = `
mkdir -p /opt/dashcaddy && cd /opt/dashcaddy
curl -fsSL https://get.dashcaddy.net/release/latest.tar.gz | tar xz
cd dashcaddy-api && docker build -t dashcaddy-api .
docker run -d --name dashcaddy-api --restart unless-stopped \\
-p ${apiPort}:${apiPort} \\
-v /opt/dashcaddy/data:/app/data \\
-v /opt/dashcaddy/status:/app/status \\
-v /var/run/docker.sock:/var/run/docker.sock \\
-e NODE_ENV=production \\
-e PORT=${apiPort} \\
dashcaddy-api
`;
if (envInfo.exec) {
await envInfo.exec(deployScript.replace(/\n/g, ' && '));
}
let url;
if (opts.domain?.mode === 'public') {
url = `https://${opts.domain.domain}`;
} else if (opts.domain?.mode === 'custom-tld') {
url = `https://dashcaddy${opts.domain.tld}`;
} else {
url = `http://localhost:${dashboardPort || 8080}`;
}
return { success: true, dashboardUrl: url };
}
async _configureServices(envInfo, opts) {
// Port forwarding from host to VM
if (this.platform === 'windows') {
// WSL2 auto-forwards localhost ports to the host
return;
}
if (this.platform === 'macos') {
// Lima forwards are configured in the VM config
return;
}
// Linux: container is directly accessible
}
// =========================================================================
// DESTROY (uninstall)
// =========================================================================
async _destroyWSL2(vmInfo) {
await execAsync(`wsl --unregister ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 });
// VHDX is deleted by WSL on unregister
return { success: true, message: 'WSL2 distro deleted — all data removed' };
}
async _destroyLima(vmInfo) {
await execAsync(`limactl delete -f ${vmInfo.name || 'dashcaddy'}`, { timeout: 30000 });
return { success: true, message: 'Lima VM deleted — all data removed' };
}
async _destroyLoopback(vmInfo) {
const sudo = process.getuid && process.getuid() === 0 ? '' : 'sudo';
await execAsync(`${sudo} umount ${vmInfo.mountPoint || '/opt/dashcaddy-data'}`, { timeout: 10000 }).catch(() => {});
await execAsync(`rm -f ${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}`, { timeout: 5000 });
// Remove from fstab
await execAsync(`${sudo} sed -i '\\#${vmInfo.imagePath || '/opt/dashcaddy-data.raw'}#d' /etc/fstab`, { timeout: 5000 }).catch(() => {});
return { success: true, message: 'Virtual disk unmounted and deleted — all data removed' };
}
async _exportData(vmInfo, exportPath) {
// Export DashCaddy config + service definitions before destroy
if (vmInfo.type === 'wsl2') {
await execAsync(`wsl -d ${vmInfo.name} -- tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data /opt/dashcaddy/status`, { timeout: 60000 });
await execAsync(`wsl -d ${vmInfo.name} -- cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 });
} else if (vmInfo.type === 'lima') {
await execAsync(`limactl shell ${vmInfo.name} -- sudo tar czf /tmp/dc-export.tar.gz /opt/dashcaddy/data`, { timeout: 60000 });
await execAsync(`limactl shell ${vmInfo.name} -- sudo cat /tmp/dc-export.tar.gz > "${exportPath}"`, { timeout: 60000 });
} else if (vmInfo.type === 'loopback') {
await execAsync(`tar czf "${exportPath}" -C ${vmInfo.mountPoint} data`, { timeout: 60000 });
}
return { success: true, exportPath };
}
// =========================================================================
// STATUS
// =========================================================================
async getStatus() {
const info = { platform: this.platform, running: false };
try {
switch (this.platform) {
case 'windows': {
const { stdout } = await execAsync('wsl -l -v', { timeout: 10000 });
info.running = stdout.includes('dashcaddy') && stdout.includes('Running');
break;
}
case 'macos': {
const { stdout } = await execAsync('limactl list --json', { timeout: 10000 });
const vms = JSON.parse(stdout);
info.running = vms.some(v => v.name === 'dashcaddy' && v.status === 'Running');
break;
}
case 'linux': {
const { stdout } = await execAsync('mountpoint -q /opt/dashcaddy-data && echo yes', { timeout: 5000 });
info.running = stdout.includes('yes');
break;
}
}
} catch {}
return info;
}
}
module.exports = { VMDiskProvisioner, DISK_PRESETS };
@@ -0,0 +1,113 @@
/**
* VM Disk Budget Step — rendered inside the Electron wizard.
* Shows disk size presets, a custom slider, and real-time space check.
* Add to wizard.js as a new render step between 'folder' and 'tier'.
*
* Exported function: renderDiskBudgetStep()
* State updates: state.diskBudget.preset, state.diskBudget.customSizeGB
*/
function renderDiskBudgetStep() {
const presets = [
{ id: 'minimal', icon: '💽', sizeGB: 10, label: 'Minimal', desc: 'DashCaddy only, a few small apps' },
{ id: 'balanced', icon: '💿', sizeGB: 30, label: 'Balanced', desc: 'DashCaddy + media tools + containers' },
{ id: 'power', icon: '🧊', sizeGB: 100, label: 'Power', desc: 'DashCaddy + heavy apps + lots of containers' },
{ id: 'custom', icon: '⚙️', sizeGB: 0, label: 'Custom', desc: 'Pick your own size' },
];
const selectedPreset = state.diskBudget?.preset || 'balanced';
const selectedSize = state.diskBudget?.customSizeGB || presets.find(p => p.id === selectedPreset)?.sizeGB || 30;
return `
<div>
<h2>Storage Budget</h2>
<p>DashCaddy creates a <strong>sandboxed virtual disk</strong> for all its data.
It can never exceed this limit — your main drive stays safe.</p>
<p class="hint" style="margin-bottom: 20px;">
💡 The disk starts nearly empty and only grows as you add apps and data.
Deleting DashCaddy removes the entire disk instantly.
</p>
<div class="disk-presets" style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 20px;">
${presets.map(p => `
<div class="disk-preset-card ${selectedPreset === p.id ? 'selected' : ''}"
onclick="selectDiskPreset('${p.id}', ${p.sizeGB})"
style="padding: 16px; border: 2px solid ${selectedPreset === p.id ? '#6366f1' : 'var(--border, #333)'}; border-radius: 10px; cursor: pointer; transition: all 0.2s; ${selectedPreset === p.id ? 'background: rgba(99, 102, 241, 0.1);' : ''}">
<div style="font-size: 2rem; margin-bottom: 8px;">${p.icon}</div>
<div style="font-weight: 600; font-size: 1.05rem;">${p.label}</div>
<div style="font-size: 0.85rem; color: var(--muted, #888); margin-top: 4px;">
${p.sizeGB > 0 ? p.sizeGB + 'GB' : 'Custom'}${p.desc}
</div>
</div>
`).join('')}
</div>
${selectedPreset === 'custom' ? `
<div class="folder-input" style="margin-bottom: 16px;">
<label>Custom Disk Size</label>
<div class="input-row" style="display: flex; align-items: center; gap: 12px;">
<input type="range" id="disk-slider"
min="5" max="500" step="5"
value="${selectedSize}"
oninput="updateDiskSize(this.value)"
style="flex: 1;">
<span id="disk-size-display" style="font-size: 1.3rem; font-weight: 700; min-width: 80px; text-align: right;">
${selectedSize}GB
</span>
</div>
<p class="hint">Min 5GB, Max 500GB. DashCaddy uses a sparse image — it only consumes real disk space as data fills.</p>
</div>
` : `
<div style="padding: 12px 16px; background: rgba(99, 102, 241, 0.08); border-radius: 8px; border: 1px solid rgba(99, 102, 241, 0.2); margin-bottom: 16px;">
<strong>${selectedSize}GB</strong> virtual disk will be created.
The sandbox isolates Docker, all containers, and all DashCaddy data inside it.
</div>
`}
<div id="disk-space-check" style="margin-top: 12px;"></div>
</div>
`;
}
// State management helpers — call from wizard.js
function selectDiskPreset(presetId, sizeGB) {
if (!state.diskBudget) state.diskBudget = {};
state.diskBudget.preset = presetId;
if (presetId !== 'custom') {
state.diskBudget.diskSizeGB = sizeGB;
}
checkDiskSpace(sizeGB);
render(); // re-render the step
}
function updateDiskSize(val) {
const sizeGB = parseInt(val);
if (!state.diskBudget) state.diskBudget = {};
state.diskBudget.diskSizeGB = sizeGB;
state.diskBudget.customSizeGB = sizeGB;
document.getElementById('disk-size-display').textContent = sizeGB + 'GB';
checkDiskSpace(sizeGB);
}
async function checkDiskSpace(sizeGB) {
const el = document.getElementById('disk-space-check');
if (!el) return;
try {
const info = await window.electronAPI.getDiskSpace(state.paths.install || '');
const freeGB = Math.round(info.free / 1024 / 1024 / 1024);
const neededGB = sizeGB + 2; // 2GB buffer for DashCaddy itself
if (freeGB < neededGB) {
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(239, 68, 68, 0.1); border-radius: 6px; border: 1px solid rgba(239, 68, 68, 0.3); color: #f87171; font-size: 0.85rem;">
⚠️ Not enough free space. You have ${freeGB}GB free, but need ${neededGB}GB.
</div>`;
} else {
el.innerHTML = `<div style="padding: 10px 14px; background: rgba(34, 197, 94, 0.1); border-radius: 6px; border: 1px solid rgba(34, 197, 94, 0.2); color: #4ade80; font-size: 0.85rem;">
✓ You have ${freeGB}GB free — plenty of room for a ${sizeGB}GB disk.
</div>`;
}
} catch {
el.innerHTML = '';
}
}
+2
View File
@@ -206,6 +206,7 @@
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
<button id="log-insights-btn" aria-label="Log Insights">🔍 Insights</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
</div>
</div>
@@ -950,6 +951,7 @@
<script src="/js/xterm-fit.min.js" defer></script>
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
<script src="/js/log-insights.js" defer></script>
<script src="/js/tailscale-devices.js" defer></script>
<!-- Bundled JS (built with: npm run build) -->
+184
View File
@@ -0,0 +1,184 @@
// ========== LOG INSIGHTS PANEL ==========
(function() {
injectModal('log-insights-modal', `<div id="log-insights-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>🔍 Log Insights</h3>
<p class="modal-subtitle">Who's accessing your server and what they're doing — in plain English.</p>
<div style="display: flex; gap: 12px; margin-bottom: 16px; align-items: center;">
<label class="text-muted-sm">Period:</label>
<select id="li-period" style="padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.85rem;">
<option value="1">Last 1 hour</option>
<option value="6">Last 6 hours</option>
<option value="24" selected>Last 24 hours</option>
<option value="168">Last 7 days</option>
</select>
<button id="li-refresh" class="btn-sm">🔄 Refresh</button>
<span style="flex: 1;"></span>
<button id="li-dispose-btn" style="padding: 6px 12px; font-size: 0.8rem; color: var(--warn-fg, #f0c674); border-color: var(--warn-fg, #f0c674);">🧹 Clean Old Logs</button>
</div>
<!-- Plain English Insights -->
<div id="li-insights" style="margin-bottom: 16px;"></div>
<!-- Summary Stats -->
<div id="li-summary" style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 16px;"></div>
<!-- Top IPs Table -->
<div id="li-ips-section">
<h4 style="margin: 12px 0 8px; font-size: 0.95rem;">Top Visitors</h4>
<div id="li-ips-table" class="scroll-container" style="max-height: 300px;"></div>
</div>
<!-- Storage Info -->
<div id="li-storage" style="margin-top: 16px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"></div>
<div class="weather-modal-buttons">
<button id="li-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('log-insights-modal');
const openBtn = document.getElementById('log-insights-btn');
const closeBtn = document.getElementById('li-close');
const refreshBtn = document.getElementById('li-refresh');
const disposeBtn = document.getElementById('li-dispose-btn');
const periodSel = document.getElementById('li-period');
const insightsDiv = document.getElementById('li-insights');
const summaryDiv = document.getElementById('li-summary');
const ipsDiv = document.getElementById('li-ips-table');
const storageDiv = document.getElementById('li-storage');
if (openBtn) {
openBtn.addEventListener('click', () => { modal.style.display = 'flex'; loadInsights(); });
}
closeBtn.addEventListener('click', () => modal.style.display = 'none');
refreshBtn.addEventListener('click', loadInsights);
periodSel.addEventListener('change', loadInsights);
disposeBtn.addEventListener('click', showDisposePreview);
async function loadInsights() {
const hours = periodSel.value;
insightsDiv.innerHTML = '<div class="panel-empty"><span class="brand-spinner"></span> Analyzing logs...</div>';
summaryDiv.innerHTML = '';
ipsDiv.innerHTML = '';
storageDiv.innerHTML = '';
try {
const res = await fetch('/api/v1/log-insights?hours=' + hours);
const data = await res.json();
if (!data.success) { insightsDiv.innerHTML = '<div class="panel-empty">Error: ' + data.error + '</div>'; return; }
// Render insights as plain English cards
let insightsHtml = '';
(data.insights || []).forEach(function(ins) {
const sevColor = ins.severity === 'warning' ? 'var(--warn-fg, #f0c674)' :
ins.severity === 'critical' ? 'var(--bad-fg, #ff6b6b)' :
ins.severity === 'ok' ? 'var(--good-fg, #98c379)' : 'var(--muted)';
insightsHtml += '<div style="padding: 10px 14px; margin-bottom: 8px; background: var(--bg); border-radius: 6px; border-left: 3px solid ' + sevColor + ';">' +
'<strong style="font-size: 0.9rem;">' + ins.title + '</strong><br>' +
'<span style="font-size: 0.85rem; color: var(--muted);">' + ins.plain + '</span></div>';
});
insightsDiv.innerHTML = insightsHtml;
// Summary stats
var s = data.summary;
summaryDiv.innerHTML =
statCard('Requests', s.totalRequests) +
statCard('Unique IPs', s.uniqueIPs) +
statCard('Security Events', s.securityEvents) +
statCard('Failed Actions', s.failedActions);
// Top IPs table
var ips = data.topIPs || [];
if (ips.length === 0) {
ipsDiv.innerHTML = '<div class="panel-empty">No activity in this period.</div>';
} else {
var html = '<table style="width: 100%; font-size: 0.85rem; border-collapse: collapse;">';
html += '<tr style="border-bottom: 1px solid var(--border);"><th style="text-align:left; padding: 6px;">IP Address</th><th style="text-align:right; padding: 6px;">Requests</th><th style="text-align:right; padding: 6px;">Failures</th><th style="text-align:left; padding: 6px;">Top Actions</th><th style="text-align:left; padding: 6px;">Last Seen</th></tr>';
ips.forEach(function(ip) {
var failStyle = ip.failures > 0 ? 'color: var(--bad-fg, #ff6b6b); font-weight: 600;' : '';
var actions = (ip.topActions || []).map(function(a) { return a[0]; }).join(', ');
var lastSeen = ip.lastSeen ? new Date(ip.lastSeen).toLocaleString() : '?';
html += '<tr style="border-bottom: 1px solid var(--border);">' +
'<td style="padding: 6px; font-family: monospace;">' + ip.ip + '</td>' +
'<td style="padding: 6px; text-align: right;">' + ip.count + '</td>' +
'<td style="padding: 6px; text-align: right; ' + failStyle + '">' + ip.failures + '</td>' +
'<td style="padding: 6px;">' + actions + '</td>' +
'<td style="padding: 6px; color: var(--muted);">' + lastSeen + '</td>' +
'</tr>';
});
html += '</table>';
ipsDiv.innerHTML = html;
}
// Storage info
var st = data.storage || {};
var stHtml = '<strong style="font-size: 0.85rem;">Log Storage</strong><br>';
if (st.auditLog) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Audit log: ' + st.auditLog.sizeMB + ' MB (' + st.auditLog.entries + ' entries)</span><br>';
if (st.securityEvents) stHtml += '<span style="font-size: 0.8rem; color: var(--muted);">Security events: ' + st.securityEvents.sizeMB + ' MB (' + st.securityEvents.entries + ' entries)</span>';
storageDiv.innerHTML = stHtml;
} catch (e) {
insightsDiv.innerHTML = '<div class="panel-empty">Failed to load: ' + e.message + '</div>';
}
}
function statCard(label, value) {
return '<div style="text-align: center; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">' +
'<div style="font-size: 1.5rem; font-weight: 700;">' + value + '</div>' +
'<div style="font-size: 0.75rem; color: var(--muted);">' + label + '</div></div>';
}
async function showDisposePreview() {
var keepDays = prompt('Delete logs older than how many days?', '30');
if (!keepDays) return;
keepDays = parseInt(keepDays);
if (isNaN(keepDays) || keepDays < 1) { alert('Invalid number'); return; }
try {
var res = await fetch('/api/v1/log-insights/dispose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepDays: keepDays })
});
var data = await res.json();
if (!data.success) { alert('Error: ' + data.error); return; }
var msg = data.message + '\n\n' +
'Audit entries to delete: ' + data.wouldDelete.auditEntries + '\n' +
'Security events to delete: ' + data.wouldDelete.securityEvents + '\n\n' +
'Click OK to confirm deletion.';
if (confirm(msg)) {
await executeDispose(keepDays);
}
} catch (e) {
alert('Failed: ' + e.message);
}
}
async function executeDispose(keepDays) {
try {
var res = await fetch('/api/v1/log-insights/dispose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keepDays: keepDays, confirm: true })
});
var data = await res.json();
if (!data.success) { alert('Error: ' + data.error); return; }
alert('Cleaned up!\n\nDeleted: ' + data.deleted.auditEntries + ' audit entries, ' + data.deleted.securityEvents + ' security events.\nRemaining: ' + data.remaining.auditEntries + ' audit, ' + data.remaining.securityEvents + ' security.');
loadInsights();
} catch (e) {
alert('Failed: ' + e.message);
}
}
function injectModal(id, html) {
if (document.getElementById(id)) return;
var div = document.createElement('div');
div.innerHTML = html;
document.body.appendChild(div.firstElementChild);
}
})();