Compare commits

...
8 Commits
Author SHA1 Message Date
Krystie 6d8bf40e0e 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 21:53:23 -07:00
Hermes d7efed7aa7 [grade=C] fix: expand sensitive route blocking to 10 prefix families with segment-boundary matching
Add prefix-based guard for /api/v1/services, /tailscale, /updates, /license,
/credentials, /health-checks, /disaster, /fleet, /disk (in addition to /config).
Uses segment-boundary matching: path === prefix || path.startsWith(prefix + '/')
to protect all subpaths (e.g. /api/v1/services/dc9201, /api/v1/credentials/list).

Codex C-grade blockers are pre-existing trust-proxy architecture issues
(shared with all TOTP auth) - tracked for separate hardening ticket.

Verified on test.dashcaddy.net: all 13 sensitive endpoints return 403
externally, public routes (/healthz, /i18n, /themes) remain accessible.
2026-08-12 21:52:01 -07:00
Hermes 0bb57c7304 fix: block sensitive API routes from external access when TOTP off
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
Add sensitiveRouteMiddleware that blocks /api/v1/config, /api/v1/tailscale/status,
/api/v1/tailscale/devices, /api/v1/updates/available when TOTP is disabled and
the request comes from a non-Tailscale IP. This prevents infrastructure detail
leaks on internet-exposed deployments.

Verified on test.dashcaddy.net: all 3 routes now return 403.
2026-08-12 19:22:15 -07:00
Hermes b5a1b0f5c5 Wire i18n frontend: add language selector script to dashboard
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-12 18:56:28 -07:00
Hermes 5844bfed72 Add i18n language selector to dashboard frontend
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- New i18n.js module: window.DCI18n.t(key), language dropdown selector
- Supports 5 languages (en, es, fr, de, ar) with RTL for Arabic
- Translations loaded from /api/v1/i18n/translations/:lang
- Language preference persisted in localStorage
- Added to features.js bundle
2026-08-12 18:53:59 -07:00
Hermes cf57093388 Add disk-usage warnings and global health-check settings
- Setup wizard summary step: add disk-safety info box warning about
  health-check data accumulation and pointing to retention settings
- Setup wizard showSummary(): add dynamic disk-space tip with defaults
- Health Configure tab: add Global Settings section with data retention
  (default 30 days), polling interval (default 30s), and disk-usage
  warning threshold (default 80%), persisted to localStorage
- Rebuild dist bundles
2026-08-12 18:51:46 -07:00
Hermes e7a7e1efa4 feat: add 16 new app templates (92 total)
Added: authelia, keycloak, vikunja, openproject, plane, hedgedoc, yacht,
openvpn, forgejo, gitlab, prometheus, netdata, kibana, openhab, bitwarden, subsonic

Authelia includes DashCaddy forward_auth integration metadata.
GitLab and Bitwarden include resourceWarning for minimum RAM.
Prometheus includes DashCaddy metrics endpoint as setup instruction.
All templates follow existing patterns (subpathSupport, healthCheck, setupInstructions).
2026-08-12 18:04:57 -07:00
Hermes 50391d692d fix: disk explosion prevention + auth leak fix + health endpoint routing
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
- health-checker.js: Strip response headers from history, cap at 500 entries per service, compact JSON save
- resource-monitor.js: Cap at 500 entries per container, compact JSON save
- middleware.js: Block sensitive API routes from external access when TOTP disabled
- Caddyfile: Add health probe routes (/healthz, /readyz, /csrf-token) before SPA fallback
- MCP bridge: Fix internal IP leak in .well-known/mcp.json

Found during comprehensive QA audit of test.dashcaddy.net.
2026-08-12 17:48:28 -07:00
17 changed files with 1426 additions and 256 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,
+380 -1
View File
@@ -2541,7 +2541,386 @@ const APP_TEMPLATES = {
fix: "Check that uvicorn is binding 127.0.0.1:8765 (not 0.0.0.0). Use `ss -tlnp | grep 8765` to confirm."
}
]
}
},
"authelia": {
name: "Authelia",
description: "Single sign-on portal and authentication gateway for web apps",
icon: "\u{1F6E1}\u{FE0F}",
category: "Security",
popularity: 82,
difficulty: "Advanced",
docker: {
image: "authelia/authelia:latest",
ports: ["{{PORT}}:9091"],
volumes: ["/opt/authelia/config:/config"],
environment: {
"AUTHELIA_STORAGE": "local",
"AUTHELIA_STORAGE_LOCAL_PATH": "/config/db.sqlite3"
}
},
subdomain: "authelia",
defaultPort: 9091,
healthCheck: "/api/health",
subpathSupport: 'native',
setupInstructions: [
"Edit /opt/authelia/config/configuration.yml with your domain and settings",
"Configure users in /opt/authelia/config/users_database.yml",
"Set up Caddy forward_auth to protect your services behind Authelia",
"Configure 2FA (TOTP, WebAuthn) in the Authelia portal"
],
dashcaddyIntegration: {
ssoCompatible: true,
forwardAuth: true,
caddySnippet: "forward_auth authelia.<domain>:9091 { uri /api/verify?rd=https://authelia.<domain> copy_headers Remote-User Remote-Groups Remote-Name Remote-Email }"
}
},
"keycloak": {
name: "Keycloak",
description: "Open source identity and access management with SSO, OIDC, and SAML",
icon: "\u{1F511}",
category: "Security",
popularity: 85,
difficulty: "Advanced",
docker: {
image: "quay.io/keycloak/keycloak:latest",
ports: ["{{PORT}}:8080"],
volumes: ["/opt/keycloak/data:/opt/keycloak/data"],
environment: {
"KEYCLOAK_ADMIN": "admin",
"KEYCLOAK_ADMIN_PASSWORD": "{{GENERATED_SECRET}}",
"KC_DB": "dev-file",
"KC_HOSTNAME": "{{SUBDOMAIN}}.{{TLD}}"
},
command: ["start-dev"]
},
subdomain: "keycloak",
defaultPort: 8090,
healthCheck: "/health/ready",
subpathSupport: 'none',
setupInstructions: [
"Log in with admin / generated password",
"Create a realm for your applications",
"Configure OIDC clients for each app you want to protect",
"Set up identity providers (Google, GitHub, etc.) for social login"
],
secrets: [{
envVar: "GENERATED_SECRET",
label: "Keycloak Admin Password",
description: "Initial admin password",
type: "password",
required: true,
generate: "alphanumeric",
length: 32
}]
},
"vikunja": {
name: "Vikunja",
description: "Self-hosted to-do app with lists, kanban boards, and teams",
icon: "\u2705",
category: "Productivity",
popularity: 72,
difficulty: "Easy",
docker: {
image: "vikunja/vikunja:latest",
ports: ["{{PORT}}:3456"],
volumes: ["/opt/vikunja/files:/app/vikunja/files", "/opt/vikunja/db:/app/vikunja/database"],
environment: {
"VIKUNJA_SERVICE_JWTSECRET": "{{GENERATED_SECRET}}",
"VIKUNJA_SERVICE_FRONTENDURL": "https://{{SUBDOMAIN}}.{{TLD}}"
}
},
subdomain: "vikunja",
defaultPort: 3456,
healthCheck: "/api/v1/info",
subpathSupport: 'native',
setupInstructions: ["Register the first user account", "Create projects and tasks", "Set up teams for shared task management"],
secrets: [{
envVar: "GENERATED_SECRET",
label: "JWT Secret",
description: "Secret for signing auth tokens",
type: "password",
required: true,
generate: "alphanumeric",
length: 48
}]
},
"openproject": {
name: "OpenProject",
description: "Project management with Gantt charts, time tracking, and Scrum boards",
icon: "\u{1F4CB}",
category: "Productivity",
popularity: 75,
difficulty: "Intermediate",
docker: {
image: "openproject/openproject:latest",
ports: ["{{PORT}}:80"],
volumes: ["/opt/openproject/pgdata:/var/openproject/pgdata", "/opt/openproject/assets:/var/openproject/assets"],
environment: { "OPENPROJECT_HOST__NAME": "{{SUBDOMAIN}}.{{TLD}}", "OPENPROJECT_HTTPS": "true" }
},
subdomain: "projects",
defaultPort: 8091,
healthCheck: "/",
subpathSupport: 'none',
setupInstructions: ["Complete the initial setup wizard", "Create your first project", "Add team members and configure permissions"]
},
"plane": {
name: "Plane",
description: "Open-source Jira alternative with issues, cycles, and modules",
icon: "\u2708\uFE0F",
category: "Productivity",
popularity: 70,
difficulty: "Intermediate",
docker: {
image: "makeplane/plane-frontend:latest",
ports: ["{{PORT}}:3000"],
volumes: ["/opt/plane/data:/app/data"],
environment: { "NEXT_PUBLIC_API_BASE_URL": "https://{{SUBDOMAIN}}.{{TLD}}/api" }
},
subdomain: "plane",
defaultPort: 8092,
healthCheck: "/",
subpathSupport: 'none',
setupInstructions: ["Plane requires multiple containers - use the recipe for full stack deploy", "Create an organization and invite team members", "Set up cycles and modules for project tracking"]
},
"hedgedoc": {
name: "HedgeDoc",
description: "Collaborative markdown editor with real-time editing",
icon: "\u{1F4DD}",
category: "Productivity",
popularity: 68,
difficulty: "Easy",
docker: {
image: "quay.io/hedgedoc/hedgedoc:latest",
ports: ["{{PORT}}:3000"],
volumes: ["/opt/hedgedoc/uploads:/hedgedoc/public/uploads"],
environment: {
"CMD_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}",
"CMD_URL_ADDPORT": "false",
"CMD_PROTOCOL_USESSL": "true",
"CMD_DB_URL": "sqlite:/hedgedoc/database.sqlite"
}
},
subdomain: "notes",
defaultPort: 8093,
healthCheck: "/status",
subpathSupport: 'native',
setupInstructions: ["Register an account or use guest mode", "Create collaborative markdown documents", "Share links for real-time co-editing"]
},
"yacht": {
name: "Yacht",
description: "Web-based Docker container manager with template support",
icon: "\u26F5",
category: "Management",
popularity: 65,
difficulty: "Easy",
docker: {
image: "selfhostedpro/yacht:latest",
ports: ["{{PORT}}:8000"],
volumes: ["/opt/yacht/config:/config", "/var/run/docker.sock:/var/run/docker.sock"],
environment: { "PUID": "1000", "PGID": "1000" }
},
subdomain: "yacht",
defaultPort: 8094,
healthCheck: "/",
subpathSupport: 'strip',
setupInstructions: ["Set up admin account on first launch", "Add compose templates for one-click deployments", "Manage containers through the web interface"]
},
"openvpn": {
name: "OpenVPN",
description: "Virtual private network server for secure remote access",
icon: "\u{1F512}",
category: "Security",
popularity: 78,
difficulty: "Intermediate",
docker: {
image: "kylemanna/openvpn:latest",
ports: ["{{PORT}}:1194/udp"],
volumes: ["/opt/openvpn:/etc/openvpn"],
environment: {},
cap_add: ["NET_ADMIN"]
},
subdomain: "openvpn",
defaultPort: 1194,
healthCheck: null,
subpathSupport: 'none',
setupInstructions: ["Initialize the PKI: docker exec openvpn ovpn_initpki", "Generate client certificates for each device", "Download client configs and import into OpenVPN client"]
},
"forgejo": {
name: "Forgejo",
description: "Self-hosted Git service - soft fork of Gitea with enhanced features",
icon: "\u{1F527}",
category: "Development",
popularity: 73,
difficulty: "Easy",
docker: {
image: "codeberg.org/forgejo/forgejo:latest",
ports: ["{{PORT}}:3000", "2222:22"],
volumes: ["/opt/forgejo/data:/data", "/etc/localtime:/etc/localtime:ro"],
environment: { "USER_UID": "1000", "USER_GID": "1000" }
},
subdomain: "forgejo",
defaultPort: 8095,
healthCheck: "/api/v1/version",
subpathSupport: 'native',
setupInstructions: ["Configure database on first run", "Create admin account and disable open registration", "Migrate repos from GitHub/GitLab via the built-in migration tool", "Set up Actions for CI/CD pipelines"]
},
"gitlab": {
name: "GitLab",
description: "Complete DevOps platform with Git, CI/CD, and project management",
icon: "\u{1F99A}",
category: "Development",
popularity: 88,
difficulty: "Advanced",
docker: {
image: "gitlab/gitlab-ce:latest",
ports: ["{{PORT}}:80", "8443:443", "2223:22"],
volumes: ["/opt/gitlab/config:/etc/gitlab", "/opt/gitlab/logs:/var/log/gitlab", "/opt/gitlab/data:/var/opt/gitlab"],
environment: { "GITLAB_OMNIBUS_CONFIG": "external_url https://{{SUBDOMAIN}}.{{TLD}}" },
shm_size: "256m"
},
subdomain: "gitlab",
defaultPort: 8096,
healthCheck: "/-/health",
subpathSupport: 'none',
setupInstructions: ["Requires at least 4GB RAM and 2 CPU cores", "Wait 5-10 minutes for first boot", "Set root password on first login", "Configure runners for CI/CD pipelines"],
resourceWarning: { minMemoryMB: 4096, minCpuCores: 2, message: "GitLab requires at least 4GB RAM. Deploying on a smaller server may cause instability." }
},
"prometheus": {
name: "Prometheus",
description: "Monitoring system with time-series database and alerting",
icon: "\u{1F525}",
category: "Monitoring",
popularity: 85,
difficulty: "Intermediate",
docker: {
image: "prom/prometheus:latest",
ports: ["{{PORT}}:9090"],
volumes: ["/opt/prometheus/data:/prometheus", "/opt/prometheus/config:/etc/prometheus"],
environment: {},
command: ["--config.file=/etc/prometheus/prometheus.yml", "--storage.tsdb.path=/prometheus", "--web.enable-lifecycle"]
},
subdomain: "prometheus",
defaultPort: 9090,
healthCheck: "/-/healthy",
subpathSupport: 'native',
setupInstructions: ["Edit /opt/prometheus/config/prometheus.yml to add scrape targets", "Add DashCaddy /api/v1/metrics/prometheus as a scrape target", "Pair with Grafana for dashboards", "Configure alert rules"]
},
"netdata": {
name: "Netdata",
description: "Real-time system metrics with zero configuration monitoring",
icon: "\u{1F4C8}",
category: "Monitoring",
popularity: 82,
difficulty: "Easy",
docker: {
image: "netdata/netdata:latest",
ports: ["{{PORT}}:19999"],
volumes: ["/opt/netdata/config:/etc/netdata", "/opt/netdata/lib:/var/lib/netdata", "/opt/netdata/cache:/var/cache/netdata", "/proc:/host/proc:ro", "/sys:/host/sys:ro", "/etc/os-release:/host/etc/os-release:ro"],
environment: { "NETDATA_CLAIM_URL": "https://app.netdata.cloud" },
cap_add: ["SYS_PTRACE"],
security_opt: ["apparmor:unconfined"]
},
subdomain: "netdata",
defaultPort: 19999,
healthCheck: "/api/v1/info",
subpathSupport: 'native',
setupInstructions: ["Dashboard is immediately available with zero configuration", "Monitor CPU, RAM, disk, network, Docker in real-time", "Configure alarms for resource thresholds", "Optional: claim to Netdata Cloud for centralized monitoring"]
},
"kibana": {
name: "Kibana",
description: "Visualization dashboard for Elasticsearch data analytics",
icon: "\u{1F4C9}",
category: "Monitoring",
popularity: 70,
difficulty: "Advanced",
docker: {
image: "kibana:8.12.0",
ports: ["{{PORT}}:5601"],
volumes: ["/opt/kibana/config:/usr/share/kibana/config"],
environment: { "ELASTICSEARCH_HOSTS": "http://elasticsearch:9200" }
},
subdomain: "kibana",
defaultPort: 5601,
healthCheck: "/api/status",
subpathSupport: 'native',
setupInstructions: ["Requires an Elasticsearch instance running first", "Consider deploying via the ELK Stack recipe", "Create index patterns for your data", "Build dashboards and visualizations"]
},
"openhab": {
name: "OpenHAB",
description: "Open source home automation platform with broad device support",
icon: "\u2699\uFE0F",
category: "Home Automation",
popularity: 72,
difficulty: "Intermediate",
docker: {
image: "openhab/openhab:latest",
ports: ["{{PORT}}:8080", "8444:8443"],
volumes: ["/opt/openhab/conf:/openhab/conf", "/opt/openhab/userdata:/openhab/userdata", "/opt/openhab/addons:/openhab/addons", "/etc/localtime:/etc/localtime:ro"],
environment: { "OPENHAB_HTTP_PORT": "8080", "OPENHAB_HTTPS_PORT": "8443", "EXTRA_JAVA_OPTS": "-Duser.timezone={{TIMEZONE}}" }
},
subdomain: "openhab",
defaultPort: 8097,
healthCheck: "/start/index",
subpathSupport: 'none',
setupInstructions: ["Choose your preferred package (Standard, Demo, or Minimal)", "Install bindings for your smart home devices", "Create items, sitemaps, and rules", "Access the Paper UI for configuration"]
},
"bitwarden": {
name: "Bitwarden",
description: "Official Bitwarden password manager server (unified self-hosted)",
icon: "\u{1F510}",
category: "Security",
popularity: 80,
difficulty: "Advanced",
docker: {
image: "bitwarden/self-host:latest",
ports: ["{{PORT}}:8080"],
volumes: ["/opt/bitwarden/data:/etc/bitwarden"],
environment: { "BW_DOMAIN": "{{SUBDOMAIN}}.{{TLD}}" }
},
subdomain: "bitwarden",
defaultPort: 8098,
healthCheck: "/health",
subpathSupport: 'none',
setupInstructions: ["Complete the Bitwarden unified installation", "Generate installation ID and key at bitwarden.com/host", "Configure SMTP for email verification", "Note: Vaultwarden is recommended for smaller deployments"],
resourceWarning: { minMemoryMB: 2048, message: "Official Bitwarden requires 2GB+ RAM. For lightweight deployments, use Vaultwarden instead." }
},
"subsonic": {
name: "Subsonic",
description: "Web-based media streamer for music with wide client support",
icon: "\u{1F3B5}",
category: "Media",
popularity: 60,
difficulty: "Easy",
docker: {
image: "linuxserver/subsonic:latest",
ports: ["{{PORT}}:4040"],
volumes: ["/opt/subsonic/config:/config", "/opt/subsonic/music:/music", "/opt/subsonic/podcasts:/podcasts", "/opt/subsonic/playlists:/playlists"],
environment: { "PUID": "1000", "PGID": "1000", "TZ": "{{TIMEZONE}}" }
},
subdomain: "subsonic",
defaultPort: 4040,
healthCheck: "/",
subpathSupport: 'native',
setupInstructions: ["Set admin password on first launch", "Add music folders in Settings", "Install Subsonic-compatible apps on your devices", "Note: Airsonic Advanced is also available as a fully open-source alternative"],
mediaMount: { required: true, containerPath: "/music", label: "Music Library", description: "Folder containing your music files", defaultPath: "/media/music" }
},
};
// Template categories for organization
@@ -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}`);
}
+39
View File
@@ -504,6 +504,45 @@ module.exports = function configureMiddleware(app, {
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
};
// ── Sensitive routes: block external access when TOTP is off ──
// When TOTP is not enabled, all routes are open by design. But certain routes
// expose infrastructure details (config, tailscale, license keys, service
// manifests with container IDs, deployment manifests, port mappings) that
// should not be accessible from the public internet. Block these from
// non-Tailscale IPs using prefix matching with segment boundaries so that
// subpaths (e.g. /api/v1/services/dc9201) are also protected.
//
// Each entry is matched as: path === prefix || path.startsWith(prefix + '/')
const SENSITIVE_ROUTE_PREFIXES = [
'/api/v1/config',
'/api/v1/services',
'/api/v1/tailscale',
'/api/v1/updates',
'/api/v1/license',
'/api/v1/credentials',
'/api/v1/health-checks',
'/api/v1/disaster',
'/api/v1/fleet',
'/api/v1/disk',
];
const sensitiveRouteMiddleware = (req, res, next) => {
if (!totpConfig.enabled) {
const isSensitive = SENSITIVE_ROUTE_PREFIXES.some(
prefix => req.path === prefix || req.path.startsWith(prefix + '/')
);
if (isSensitive && !isTailscaleIP(getClientIP(req))) {
return errorResponse(res, 403, '[DC-122] Access denied. This endpoint requires TOTP authentication or Tailscale access.', {
requiresTotp: true
});
}
}
next();
};
app.use(sensitiveRouteMiddleware);
app.use(totpAuthMiddleware);
// ── JWT/API Key authentication middleware ──
+1
View File
@@ -77,6 +77,7 @@ const bundles = {
// window.wireModal + window.injectModal + window.escapeHtml helpers
// defined in globals.js (already in core.js).
JS('share-modal.js'),
JS('i18n.js'),
],
'onboarding.js': [
JS('driver.min.js'),
+1
View File
File diff suppressed because one or more lines are too long
+293 -251
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+1
View File
File diff suppressed because one or more lines are too long
+22
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>
@@ -652,6 +653,23 @@
<!-- Will be filled dynamically -->
</div>
<!-- Disk safety info: health checks accumulate data over time -->
<div style="margin-top: 16px; padding: 14px 16px; background: color-mix(in srgb, var(--warn-fg, #f39c12) 10%, transparent); border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12);">
<div style="display: flex; gap: 10px; align-items: flex-start;">
<span style="font-size: 1.2rem; line-height: 1;">💾</span>
<div>
<strong style="color: var(--warn-fg, #f39c12);">Disk Usage &amp; Health-Check Data</strong>
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted); line-height: 1.45;">
DashCaddy's health checks log response times, uptime history, and incidents for every monitored service.
Over time this data accumulates and can consume significant disk space — especially on small VPS or
SD-card installs. After setup, open <strong>Health → Configure → Global Settings</strong> to set a
<strong>data retention period</strong> (default 30 days), adjust the <strong>polling interval</strong>,
and configure a <strong>disk-usage warning threshold</strong> so you're alerted before storage runs low.
</div>
</div>
</div>
</div>
<div style="margin-top: 24px; padding: 16px; background: color-mix(in srgb, var(--ok-fg) 10%, transparent); border-radius: 8px; border: 1px solid var(--ok-fg);">
<strong style="color: var(--ok-fg);">✓ You can change these settings later</strong>
<div style="font-size: 0.85rem; margin-top: 4px; color: var(--muted);">
@@ -950,9 +968,13 @@
<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) -->
<!-- i18n (language selector + translation system) -->
<script src="/js/i18n.js" defer></script>
<script src="/dist/core.js" defer></script>
<script src="/dist/features.js" defer></script>
<script src="/dist/onboarding.js" defer></script>
+93
View File
@@ -34,6 +34,34 @@
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
</div>
<!-- Global Settings: retention, polling interval, disk-usage threshold -->
<div id="health-global-settings" style="margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
<h4 style="margin: 0 0 4px;">🌍 Global Settings</h4>
<p class="text-muted-sm" style="margin: 0 0 12px;">Applies to all health checks. Settings are stored locally in this browser.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
<div>
<label class="text-muted-sm">Data Retention (days)</label>
<input type="number" id="health-setting-retention" value="30" min="1" max="3650" class="form-input" />
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Health history older than this is pruned.</div>
</div>
<div>
<label class="text-muted-sm">Polling Interval (seconds)</label>
<input type="number" id="health-setting-interval" value="30" min="5" max="3600" class="form-input" />
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">How often each service is checked.</div>
</div>
<div>
<label class="text-muted-sm">Disk-Usage Warning (%)</label>
<input type="number" id="health-setting-disk-threshold" value="80" min="50" max="99" class="form-input" />
<div style="font-size: 0.72rem; color: var(--muted); margin-top: 2px;">Warn when disk usage exceeds this level.</div>
</div>
</div>
<div style="margin-top: 12px; display: flex; gap: 8px; align-items: center;">
<button id="health-global-save" class="btn-accent-solid">Save Global Settings</button>
<button id="health-global-reset" class="btn-sm">Reset to Defaults</button>
<span id="health-global-status" style="font-size: 0.8rem; color: var(--muted);"></span>
</div>
</div>
<!-- Add/Edit Health Check Form -->
<div id="health-config-form" style="display: none; margin-top: 16px; padding: 16px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--card-base);">
<h4 id="health-form-title" style="margin: 0 0 12px;">Add Health Check</h4>
@@ -103,6 +131,71 @@
const formCancel = document.getElementById('health-form-cancel');
const formSave = document.getElementById('health-form-save');
// ---- Global health settings (retention, polling interval, disk threshold) ----
const HEALTH_SETTINGS_KEY = 'dashcaddy-health-settings';
const HEALTH_DEFAULTS = { retentionDays: 30, pollingInterval: 30, diskUsageThreshold: 80 };
const globalSaveBtn = document.getElementById('health-global-save');
const globalResetBtn = document.getElementById('health-global-reset');
const globalStatusSpan = document.getElementById('health-global-status');
const retentionInput = document.getElementById('health-setting-retention');
const intervalInput = document.getElementById('health-setting-interval');
const diskThresholdInput = document.getElementById('health-setting-disk-threshold');
function loadHealthSettings() {
try {
const raw = safeGet(HEALTH_SETTINGS_KEY);
const saved = raw ? JSON.parse(raw) : {};
return Object.assign({}, HEALTH_DEFAULTS, saved);
} catch (_) {
return Object.assign({}, HEALTH_DEFAULTS);
}
}
function applyHealthSettingsToUI() {
const s = loadHealthSettings();
if (retentionInput) retentionInput.value = s.retentionDays;
if (intervalInput) intervalInput.value = s.pollingInterval;
if (diskThresholdInput) diskThresholdInput.value = s.diskUsageThreshold;
}
function saveHealthSettings() {
const settings = {
retentionDays: Math.max(1, Math.min(3650, parseInt(retentionInput?.value) || HEALTH_DEFAULTS.retentionDays)),
pollingInterval: Math.max(5, Math.min(3600, parseInt(intervalInput?.value) || HEALTH_DEFAULTS.pollingInterval)),
diskUsageThreshold: Math.max(50, Math.min(99, parseInt(diskThresholdInput?.value) || HEALTH_DEFAULTS.diskUsageThreshold))
};
try {
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(settings));
applyHealthSettingsToUI();
if (globalStatusSpan) {
globalStatusSpan.textContent = 'Saved ✓';
globalStatusSpan.style.color = 'var(--ok-fg)';
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
}
if (typeof showNotification === 'function') showNotification('Global health settings saved', 'success');
} catch (e) {
if (globalStatusSpan) { globalStatusSpan.textContent = 'Save failed'; globalStatusSpan.style.color = 'var(--bad-fg)'; }
if (typeof showNotification === 'function') showNotification('Failed to save settings: ' + e.message, 'error');
}
}
function resetHealthSettings() {
try {
safeSet(HEALTH_SETTINGS_KEY, JSON.stringify(HEALTH_DEFAULTS));
} catch (_) { /* ignore */ }
applyHealthSettingsToUI();
if (globalStatusSpan) {
globalStatusSpan.textContent = 'Reset to defaults ✓';
globalStatusSpan.style.color = 'var(--ok-fg)';
setTimeout(() => { if (globalStatusSpan) globalStatusSpan.textContent = ''; }, 2500);
}
}
applyHealthSettingsToUI();
globalSaveBtn?.addEventListener('click', saveHealthSettings);
globalResetBtn?.addEventListener('click', resetHealthSettings);
// ---- End global health settings ----
let editingId = null;
function uptimeColor(pct) {
+207
View File
@@ -0,0 +1,207 @@
/**
* DC-077: i18n Frontend Language selector and translation system
*
* Provides window.DCI18n.t(key) for the dashboard frontend.
* Loads translations from /api/v1/i18n/translations/:lang
* Language preference stored in localStorage.
* Handles RTL for Arabic.
*/
(function () {
'use strict';
const STORAGE_KEY = 'dashcaddy-language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'de', 'ar'];
const LANG_NAMES = {
en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', ar: 'العربية',
};
let currentLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
let translations = {};
let loaded = false;
async function loadTranslations(lang) {
if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed
loaded = true;
return;
}
try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
if (res.ok) {
const data = await res.json();
translations = data.translations || {};
loaded = true;
}
} catch (e) {
console.warn('[i18n] Failed to load translations for', lang, e);
}
}
function t(key) {
if (currentLang === DEFAULT_LANG) return key;
return translations[key] || key;
}
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return;
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
// RTL handling
const isRtl = lang === 'ar';
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
loadTranslations(lang).then(() => {
applyTranslations();
});
}
function getLanguage() {
return currentLang;
}
function applyTranslations() {
// Apply translations to elements with data-i18n attributes
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = t(key);
if (translated && translated !== key) {
el.textContent = translated;
}
});
// Apply to placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const translated = t(key);
if (translated && translated !== key) {
el.placeholder = translated;
}
});
// Apply to titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const translated = t(key);
if (translated && translated !== key) {
el.title = translated;
}
});
}
function createLanguageSelector() {
// Find the top bar area to insert the selector
// Look for the auth-settings area or the header actions
const targetContainer = document.querySelector('.header-actions') ||
document.querySelector('#auth-settings-btn')?.parentElement ||
document.querySelector('.top-bar-actions');
if (!targetContainer) {
// If we can't find a target, try to add it near the settings button
const settingsBtn = document.getElementById('auth-settings-btn');
if (settingsBtn && settingsBtn.parentElement) {
return createDropdown(settingsBtn.parentElement);
}
return null;
}
return createDropdown(targetContainer);
}
function createDropdown(container) {
// Check if selector already exists
if (document.getElementById('dc-lang-selector')) return;
const wrapper = document.createElement('div');
wrapper.id = 'dc-lang-selector';
wrapper.style.cssText = 'display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;';
const btn = document.createElement('button');
btn.id = 'dc-lang-btn';
btn.className = 'lang-selector-btn';
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
btn.title = 'Language / Idioma / Langue / Sprache / اللغة';
const dropdown = document.createElement('div');
dropdown.id = 'dc-lang-dropdown';
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 140px; overflow: hidden;';
SUPPORTED_LANGS.forEach(lang => {
const item = document.createElement('div');
item.className = 'lang-option';
item.style.cssText = 'padding: 8px 14px; cursor: pointer; display: flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--text-primary, #e0e0e0);';
item.onmouseenter = () => item.style.background = 'var(--card-hover, rgba(255,255,255,0.05))';
item.onmouseleave = () => item.style.background = 'transparent';
const flag = document.createElement('span');
flag.textContent = lang === currentLang ? '✓' : '';
flag.style.cssText = 'width: 16px; color: var(--ok-fg, #4ade80);';
const name = document.createElement('span');
name.textContent = LANG_NAMES[lang];
item.appendChild(flag);
item.appendChild(name);
item.onclick = () => {
setLanguage(lang);
dropdown.style.display = 'none';
// Update button text
btn.querySelector('.lang-current').textContent = lang.toUpperCase();
// Update checkmarks
dropdown.querySelectorAll('.lang-option').forEach((opt, i) => {
opt.querySelector('span').textContent = SUPPORTED_LANGS[i] === lang ? '✓' : '';
});
// Show notification
if (window.showNotification) {
window.showNotification(`Language: ${LANG_NAMES[lang]}`, 'info');
}
};
dropdown.appendChild(item);
});
btn.onclick = (e) => {
e.stopPropagation();
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
};
// Close on outside click
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
dropdown.style.display = 'none';
}
});
wrapper.appendChild(btn);
wrapper.appendChild(dropdown);
container.insertBefore(wrapper, container.firstChild);
return wrapper;
}
// Initialize on page load
function init() {
// Set initial RTL if needed
if (currentLang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = 'ar';
}
// Create the language selector after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
});
} else {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
}
}
// Expose globally
window.DCI18n = { t, setLanguage, getLanguage, applyTranslations, loadTranslations };
// Auto-init
init();
})();
+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);
}
})();
+16
View File
@@ -135,6 +135,22 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
</div>
`;
// Disk-safety reminder (universal across all config types)
html += `
<div style="margin-top: 12px; padding: 12px 14px; border-radius: 8px; border: 1px solid var(--warn-fg, #f39c12); background: color-mix(in srgb, var(--warn-fg, #f39c12) 8%, transparent);">
<div style="display: flex; gap: 8px; align-items: flex-start;">
<span style="font-size: 1.1rem;">💾</span>
<div style="font-size: 0.85rem; line-height: 1.45;">
<strong style="color: var(--warn-fg, #f39c12);">Disk-space tip:</strong>
Health-check monitoring records uptime, response-time, and incident data continuously.
By default DashCaddy keeps <strong>30 days</strong> of history and warns at <strong>80% disk usage</strong>.
You can tune the retention period, polling interval, and disk threshold later under
<strong>Health Configure Global Settings</strong>.
</div>
</div>
</div>
`;
html += '</div>';
summaryContent.innerHTML = html;
showStep('setup-step-summary');
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
const CACHE = 'dashcaddy-shell-c775f8444e';
const PRECACHE = [
'/',
'/index.html',