Compare commits
8
Commits
dc/DC-059
...
6d8bf40e0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d8bf40e0e | ||
|
|
d7efed7aa7 | ||
|
|
0bb57c7304 | ||
|
|
b5a1b0f5c5 | ||
|
|
5844bfed72 | ||
|
|
cf57093388 | ||
|
|
e7a7e1efa4 | ||
|
|
50391d692d |
@@ -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;
|
||||||
|
};
|
||||||
@@ -93,6 +93,7 @@ const eventsRoutes = require('../routes/events');
|
|||||||
const workflowsRoutes = require('../routes/workflows');
|
const workflowsRoutes = require('../routes/workflows');
|
||||||
const dependenciesRoutes = require('../routes/dependencies');
|
const dependenciesRoutes = require('../routes/dependencies');
|
||||||
const securityRoutes = require('../routes/security');
|
const securityRoutes = require('../routes/security');
|
||||||
|
const logInsightsRoutes = require('../routes/log-insights');
|
||||||
const billingRoutes = require('../routes/billing');
|
const billingRoutes = require('../routes/billing');
|
||||||
const DependencyManager = require('./managers/dependency-manager');
|
const DependencyManager = require('./managers/dependency-manager');
|
||||||
const autoRestartRoutes = require('../routes/auto-restart');
|
const autoRestartRoutes = require('../routes/auto-restart');
|
||||||
@@ -753,6 +754,20 @@ async function createApp() {
|
|||||||
apiRouter.use('/security', securityRoutes({
|
apiRouter.use('/security', securityRoutes({
|
||||||
log: ctx.log,
|
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({
|
apiRouter.use('/dependencies', dependenciesRoutes({
|
||||||
dependencyManager: ctx.dependencyManager,
|
dependencyManager: ctx.dependencyManager,
|
||||||
servicesStateManager: ctx.servicesStateManager,
|
servicesStateManager: ctx.servicesStateManager,
|
||||||
|
|||||||
@@ -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."
|
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
|
// 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 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_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 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_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_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
|
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 =>
|
containerStats.history = containerStats.history.filter(s =>
|
||||||
new Date(s.timestamp).getTime() > cutoffTime
|
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() {
|
saveStats() {
|
||||||
try {
|
try {
|
||||||
const data = Object.fromEntries(this.stats);
|
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) {
|
} catch (error) {
|
||||||
log.error('monitor', error, { operation: 'saveStats' });
|
log.error('monitor', error, { operation: 'saveStats' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 LEGACY_HEALTH_HISTORY_FILE = path.join(__dirname, 'health-history.json');
|
||||||
const CHECK_INTERVAL = parseInt(process.env.HEALTH_CHECK_INTERVAL || '30000', 10); // 30 seconds
|
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_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);
|
const HISTORY_RETENTION_DAYS = parseInt(process.env.HEALTH_HISTORY_RETENTION || '30', 10);
|
||||||
|
|
||||||
class HealthChecker extends EventEmitter {
|
class HealthChecker extends EventEmitter {
|
||||||
@@ -217,7 +218,7 @@ class HealthChecker extends EventEmitter {
|
|||||||
statusCode: res.statusCode,
|
statusCode: res.statusCode,
|
||||||
message: healthy ? 'Service is healthy' : 'Service check failed',
|
message: healthy ? 'Service is healthy' : 'Service check failed',
|
||||||
details: {
|
details: {
|
||||||
headers: res.headers,
|
headers: res.headers ? { server: res.headers.server } : undefined, // Compact: disk explosion fix
|
||||||
bodyLength: data.length
|
bodyLength: data.length
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -285,6 +286,11 @@ class HealthChecker extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.history[serviceId].push(status);
|
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
|
// Emit status event
|
||||||
this.emit('status-check', status);
|
this.emit('status-check', status);
|
||||||
@@ -565,6 +571,10 @@ class HealthChecker extends EventEmitter {
|
|||||||
this.history[serviceId] = this.history[serviceId].filter(h =>
|
this.history[serviceId] = this.history[serviceId].filter(h =>
|
||||||
new Date(h.timestamp).getTime() > cutoffTime
|
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() {
|
saveHistory() {
|
||||||
try {
|
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) {
|
} catch (error) {
|
||||||
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
this.emit('log', 'error', `Error saving history: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -504,6 +504,45 @@ module.exports = function configureMiddleware(app, {
|
|||||||
return errorResponse(res, 401, '[DC-110] Authentication required', { requiresTotp: true });
|
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);
|
app.use(totpAuthMiddleware);
|
||||||
|
|
||||||
// ── JWT/API Key authentication middleware ──
|
// ── JWT/API Key authentication middleware ──
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const bundles = {
|
|||||||
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
// window.wireModal + window.injectModal + window.escapeHtml helpers
|
||||||
// defined in globals.js (already in core.js).
|
// defined in globals.js (already in core.js).
|
||||||
JS('share-modal.js'),
|
JS('share-modal.js'),
|
||||||
|
JS('i18n.js'),
|
||||||
],
|
],
|
||||||
'onboarding.js': [
|
'onboarding.js': [
|
||||||
JS('driver.min.js'),
|
JS('driver.min.js'),
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+293
-251
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -206,6 +206,7 @@
|
|||||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</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="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>
|
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -652,6 +653,23 @@
|
|||||||
<!-- Will be filled dynamically -->
|
<!-- Will be filled dynamically -->
|
||||||
</div>
|
</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 & 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);">
|
<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>
|
<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);">
|
<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>
|
<script src="/js/xterm-fit.min.js" defer></script>
|
||||||
|
|
||||||
<!-- Tailscale device list panel (self-contained, polls /api/v1/tailscale/devices) -->
|
<!-- 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>
|
<script src="/js/tailscale-devices.js" defer></script>
|
||||||
|
|
||||||
<!-- Bundled JS (built with: npm run build) -->
|
<!-- 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/core.js" defer></script>
|
||||||
<script src="/dist/features.js" defer></script>
|
<script src="/dist/features.js" defer></script>
|
||||||
<script src="/dist/onboarding.js" defer></script>
|
<script src="/dist/onboarding.js" defer></script>
|
||||||
|
|||||||
@@ -34,6 +34,34 @@
|
|||||||
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
<div class="panel-empty"><span class="empty-icon">⚙️</span> Loading configuration...</div>
|
||||||
</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 -->
|
<!-- 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);">
|
<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>
|
<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 formCancel = document.getElementById('health-form-cancel');
|
||||||
const formSave = document.getElementById('health-form-save');
|
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;
|
let editingId = null;
|
||||||
|
|
||||||
function uptimeColor(pct) {
|
function uptimeColor(pct) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -135,6 +135,22 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
|
|||||||
</div>
|
</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>';
|
html += '</div>';
|
||||||
summaryContent.innerHTML = html;
|
summaryContent.innerHTML = html;
|
||||||
showStep('setup-step-summary');
|
showStep('setup-step-summary');
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
const CACHE = 'dashcaddy-shell-c4c69c2c4c';
|
const CACHE = 'dashcaddy-shell-c775f8444e';
|
||||||
const PRECACHE = [
|
const PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|||||||
Reference in New Issue
Block a user