Compare commits

...
9 Commits
Author SHA1 Message Date
Hermes 24f21abe80 [grade=pending] fix: sync installer version to 1.15.0 (M-4)
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
2026-08-12 22:48:36 -07:00
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
18 changed files with 1427 additions and 257 deletions

No files matched your search

+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 -1
View File
@@ -17,7 +17,7 @@
set -euo pipefail
# ---- Constants -------------------------------------------------------------
readonly DASHCADDY_VERSION="1.14.6"
readonly DASHCADDY_VERSION="1.15.0"
readonly DASHCADDY_DOWNLOAD="https://get.dashcaddy.net/release/latest.tar.gz"
readonly DASHCADDY_REPO="" # Set to a git URL to clone instead of downloading
readonly INSTALL_DIR="/etc/dashcaddy"
+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
@@ -846,3 +846,4 @@ The service will be briefly unavailable.`))try{o&&(o.textContent="\u{1F504}",o.d
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
`,y.disabled=!1):w.installed?(i.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(i.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{i.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,f()}function r(){const e=document.getElementById("service-type-local"),n=document.getElementById("service-type-external"),l=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),i=document.getElementById("tab-local"),y=document.getElementById("tab-external");function u(){e.checked?(l.style.display="grid",t.style.display="none",i&&(i.style.background="var(--accent)",i.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(l.style.display="none",t.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),i&&(i.style.background="transparent",i.style.color="var(--muted)"))}e?.addEventListener("change",u),n?.addEventListener("change",u)}function v(){const e=document.getElementById("service-name-input"),n=document.getElementById("service-subdomain-input"),l=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=c(e.value);!t&&n&&(n.value=L),l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()}),n?.addEventListener("input",()=>{t=n.value!==c(e?.value||"");const L=n.value.trim()||c(e?.value||"");l&&(l.textContent=L?`\u2192 ${buildDomain(L)}`:""),f()});const i=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),u=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;i?.addEventListener("input",()=>{const L=c(i.value);!T&&y&&(y.value=L);const P=y?.value||L;u&&(u.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),y?.addEventListener("input",()=>{T=y.value!==c(i?.value||"");const L=y.value.trim()||c(i?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function d(){const e=document.getElementById("external-service-name").value.trim(),n=document.getElementById("external-service-url").value.trim(),l=(document.getElementById("external-service-subdomain").value.trim()||c(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),i=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,u=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!n){showNotification("Please fill in Name and External URL","warning");return}if(!l){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!n.startsWith("http://")&&!n.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(l);try{const B={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(u)try{const E={subdomain:l,externalUrl:n,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(E){B.caddy=E.message}const $={id:l,name:e,url:`https://${C}`,externalUrl:n,logo:t||i||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const k=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(E=>!k.includes(E.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(E){console.warn("Failed to save to services.json:",E)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];y&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),u&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLyoqXG4gKiBFcnJvciBIYW5kbGVyXG4gKiBIYW5kbGVzIGVycm9ycyBncmFjZWZ1bGx5IHdpdGhvdXQgYnJlYWtpbmcgdGhlIG9uYm9hcmRpbmcgdG91clxuICovXG5cbihmdW5jdGlvbih3aW5kb3cpIHtcbiAgJ3VzZSBzdHJpY3QnO1xuXG4gIGNsYXNzIEVycm9ySGFuZGxlciB7XG4gICAgY29uc3RydWN0b3IoKSB7XG4gICAgICB0aGlzLmVycm9ycyA9IFtdO1xuICAgICAgdGhpcy5tYXhFcnJvcnMgPSA1MDsgLy8gS2VlcCBsYXN0IDUwIGVycm9yc1xuICAgIH1cblxuICAgIC8qKlxuICAgICAqIExvZyBhbiBlcnJvciB3aXRob3V0IGJyZWFraW5nIHRoZSB0b3VyXG4gICAgICogQHBhcmFtIHtzdHJpbmd9IGNvbnRleHQgLSBDb250ZXh0IHdoZXJlIGVycm9yIG9jY3VycmVkXG4gICAgICogQHBhcmFtIHtFcnJvcnxzdHJpbmd9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdCBvciBtZXNzYWdlXG4gICAgICogQHBhcmFtIHtPYmplY3R9IG1ldGFkYXRhIC0gQWRkaXRpb25hbCBtZXRhZGF0YVxuICAgICAqL1xuICAgIGxvZ0Vycm9yKGNvbnRleHQsIGVycm9yLCBtZXRhZGF0YSA9IHt9KSB7XG4gICAgICBjb25zdCBlcnJvckVudHJ5ID0ge1xuICAgICAgICB0aW1lc3RhbXA6IG5ldyBEYXRlKCkudG9JU09TdHJpbmcoKSxcbiAgICAgICAgY29udGV4dCxcbiAgICAgICAgbWVzc2FnZTogZXJyb3IgaW5zdGFuY2VvZiBFcnJvciA/IGVycm9yLm1lc3NhZ2UgOiBlcnJvcixcbiAgICAgICAgc3RhY2s6IGVycm9yIGluc3RhbmNlb2YgRXJyb3IgPyBlcnJvci5zdGFjayA6IG51bGwsXG4gICAgICAgIG1ldGFkYXRhXG4gICAgICB9O1xuXG4gICAgICAvLyBBZGQgdG8gZXJyb3JzIGFycmF5XG4gICAgICB0aGlzLmVycm9ycy5wdXNoKGVycm9yRW50cnkpO1xuXG4gICAgICAvLyBLZWVwIG9ubHkgbGFzdCBtYXhFcnJvcnNcbiAgICAgIGlmICh0aGlzLmVycm9ycy5sZW5ndGggPiB0aGlzLm1heEVycm9ycykge1xuICAgICAgICB0aGlzLmVycm9ycy5zaGlmdCgpO1xuICAgICAgfVxuXG4gICAgICAvLyBMb2cgdG8gY29uc29sZVxuICAgICAgY29uc29sZS5lcnJvcihgW09uYm9hcmRpbmcgRXJyb3JdICR7Y29udGV4dH06YCwgZXJyb3IsIG1ldGFkYXRhKTtcblxuICAgICAgLy8gT3B0aW9uYWxseSBzZW5kIHRvIGVycm9yIHRyYWNraW5nIHNlcnZpY2VcbiAgICAgIC8vIHRoaXMuc2VuZFRvRXJyb3JUcmFja2luZyhlcnJvckVudHJ5KTtcbiAgICB9XG5cbiAgICAvKipcbiAgICAgKiBBdHRlbXB0IHRvIHJlY292ZXIgZnJvbSBhbiBlcnJvciBhbmQgY29udGludWUgdG91clxuICAgICAqIEBwYXJhbSB7RXJyb3J9IGVycm9yIC0gVGhlIGVycm9yIG9iamVjdFxuICAgICAqIEBwYXJhbSB7bnVtYmVyfSBjdXJyZW50U3RlcCAtIEN1cnJlbnQgc3RlcCBpbmRleFxuICAgICAqIEByZXR1cm5zIHtPYmplY3R9IFJlY292ZXJ5IGFjdGlvblxuICAgICAqL1xuICAgIHJlY292ZXJGcm9tRXJyb3IoZXJyb3IsIGN1cnJlbnRTdGVwKSB7XG4gICAgICBjb25zdCBlcnJvclR5cGUgPSB0aGlzLmNsYXNzaWZ5RXJyb3IoZXJyb3IpO1xuXG4gICAgICBzd2l0Y2ggKGVycm9yVHlwZSkge1xuICAgICAgICBjYXNlICdFTEVNRU5UX05PVF9GT1VORCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignRWxlbWVudCBOb3QgRm91bmQnLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4geyBcbiAgICAgICAgICAgIGFjdGlvbjogJ1NLSVBfU1RFUCcsIFxuICAgICAgICAgICAgbmV4dFN0ZXA6IGN1cnJlbnRTdGVwICsgMSxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdUYXJnZXQgZWxlbWVudCBub3QgZm91bmQsIHNraXBwaW5nIHRvIG5leHQgc3RlcCdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ1NUT1JBR0VfVU5BVkFJTEFCTEUnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ1N0b3JhZ2UgVW5hdmFpbGFibGUnLCBlcnJvcik7XG4gICAgICAgICAgcmV0dXJuIHsgXG4gICAgICAgICAgICBhY3Rpb246ICdVU0VfTUVNT1JZX1NUT1JBR0UnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ0xvY2FsIHN0b3JhZ2UgdW5hdmFpbGFibGUsIHVzaW5nIGluLW1lbW9yeSBzdG9yYWdlJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnRFJJVkVSX05PVF9MT0FERUQnOlxuICAgICAgICAgIHRoaXMubG9nRXJyb3IoJ0RyaXZlci5qcyBOb3QgTG9hZGVkJywgZXJyb3IpO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdEcml2ZXIuanMgbGlicmFyeSBub3QgbG9hZGVkLCBjYW5ub3Qgc3RhcnQgdG91cidcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGNhc2UgJ0lOVkFMSURfVE9PTFRJUCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignSW52YWxpZCBUb29sdGlwIENvbmZpZ3VyYXRpb24nLCBlcnJvciwgeyBjdXJyZW50U3RlcCB9KTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnU0tJUF9TVEVQJyxcbiAgICAgICAgICAgIG5leHRTdGVwOiBjdXJyZW50U3RlcCArIDEsXG4gICAgICAgICAgICBtZXNzYWdlOiAnSW52YWxpZCB0b29sdGlwIGNvbmZpZ3VyYXRpb24sIHNraXBwaW5nJ1xuICAgICAgICAgIH07XG5cbiAgICAgICAgY2FzZSAnVEhFTUVfREVURUNUSU9OX0ZBSUxFRCc6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVGhlbWUgRGV0ZWN0aW9uIEZhaWxlZCcsIGVycm9yKTtcbiAgICAgICAgICByZXR1cm4ge1xuICAgICAgICAgICAgYWN0aW9uOiAnVVNFX0RFRkFVTFRfVEhFTUUnLFxuICAgICAgICAgICAgbWVzc2FnZTogJ1VzaW5nIGRlZmF1bHQgZGFyayB0aGVtZSdcbiAgICAgICAgICB9O1xuXG4gICAgICAgIGRlZmF1bHQ6XG4gICAgICAgICAgdGhpcy5sb2dFcnJvcignVW5rbm93biBFcnJvcicsIGVycm9yLCB7IGN1cnJlbnRTdGVwIH0pO1xuICAgICAgICAgIHJldHVybiB7XG4gICAgICAgICAgICBhY3Rpb246ICdBQk9SVF9UT1VSJyxcbiAgICAgICAgICAgIG1lc3NhZ2U6ICdVbmV4cGVjdGVkIGVycm9yIG9jY3VycmVkLCBhYm9ydGluZyB0b3VyJ1xuICAgICAgICAgIH07XG4gICAgICB9XG4gICAgfVxuXG4gICAgLyoqXG4gICAgICogQ2xhc3NpZnkgZXJyb3IgdHlwZVxuICAgICAqIEBwcml2YXRlXG4gICAgICogQHBhcmFtIHtFcnJvcn0gZXJyb3IgLSBUaGUgZXJyb3Igb2JqZWN0XG4gICAgICogQHJldHVybnMge3N0cmluZ30gRXJyb3IgdHlwZVxuICAgICAqL1xuICAgIGNsYXNzaWZ5RXJyb3IoZXJyb3IpIHtcbiAgICAgIGNvbnN0IG1lc3NhZ2UgPSBlcnJvci5tZXNzYWdlIHx8IGVycm9yLnRvU3RyaW5nKCk7XG5cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdlbGVtZW50JykgJiYgbWVzc2FnZS5pbmNsdWRlcygnbm90IGZvdW5kJykpIHtcbiAgICAgICAgcmV0dXJuICdFTEVNRU5UX05PVF9GT1VORCc7XG4gICAgICB9XG4gICAgICBpZiAobWVzc2FnZS5pbmNsdWRlcygnc3RvcmFnZScpIHx8IG1lc3NhZ2UuaW5jbHVkZXMoJ3F1b3RhJykpIHtcbiAgICAgICAgcmV0dXJuICdTVE9SQUdFX1VOQVZBSUxBQkxFJztcbiAgICAgIH1cbiAgICAgIGlmIChtZXNzYWdlLmluY2x1ZGVzKCdkcml2ZXInKSB8fCBtZXNzYWdlLmluY2x1ZGVzKCd1bmRlZmluZWQnKSkge1xuICAgI Line truncated
+293 -251
View File
@@ -90,44 +90,57 @@
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),O=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),T=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),I=DC.NAME;let C=null,j=null,M=null,D="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",C=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function A(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=a=>e(a.target.result),n.readAsDataURL(t)}P?.addEventListener("change",t=>{A(t.target.files[0],e=>{C=e,S.src=e,E.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{A(t.target.files[0],e=>{j=e,O.src=e,E.textContent="New light logo ready to save"})}),L?.addEventListener("change",t=>{A(t.target.files[0],e=>{M=e,S.src=e,O.src=e,E.textContent="New logo ready to save (both themes)"})});function y(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(T.src=e.customLogoLight,O.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,T.src=e.customLogo,S.src=e.customLogo,O.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(D=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{D=t.dataset.pos,y(D)})}),document.getElementById("brand")?.addEventListener("click",()=>{C=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),L&&(L.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,O.src=T.src;const t=w.src.includes("custom-logo")||T.src.includes("custom-logo");E.textContent=t?"Using custom logo":"Using default logos",y(D),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=B.value.trim()||I,e={position:D,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(C&&(e.dataDark=C),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const a=await n.json(),l="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+l,S.src=a.pathDark+l),a.pathLight&&(T.src=a.pathLight+l,O.src=a.pathLight+l),y(D),m(t),h.classList.remove("show")}else{const a=await n.json();showNotification("Failed to save: "+a.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
</div>`);const y=document.getElementById("logo-modal"),C=document.getElementById("logo-preview-dark"),O=document.getElementById("logo-preview-light"),E=document.getElementById("logo-status"),M=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),P=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),p=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),x=document.querySelector("#brand .brand-logo-dark"),$=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),H=document.getElementById("dashboard-title"),L=DC.NAME;let u=null,j=null,v=null,N="left",g=L;M?.addEventListener("change",()=>{M.checked?(R.style.display="none",P.style.display="",u=null,j=null):(R.style.display="flex",P.style.display="none",v=null)});function D(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const t=new FileReader;t.onload=o=>e(o.target.result),t.readAsDataURL(n)}A?.addEventListener("change",n=>{D(n.target.files[0],e=>{u=e,C.src=e,E.textContent="New dark logo ready to save"})}),p?.addEventListener("change",n=>{D(n.target.files[0],e=>{j=e,O.src=e,E.textContent="New light logo ready to save"})}),T?.addEventListener("change",n=>{D(n.target.files[0],e=>{v=e,C.src=e,O.src=e,E.textContent="New logo ready to save (both themes)"})});function B(n){k.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function f(n){g=n||L,document.title=g;const e=document.querySelector(".dashboard-title");e&&(e.textContent=g)}async function I(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(x.src=e.customLogoDark,C.src=e.customLogoDark),e.customLogoLight&&($.src=e.customLogoLight,O.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(x.src=e.customLogo,$.src=e.customLogo,C.src=e.customLogo,O.src=e.customLogo),e.isDefault||(E.textContent="Using custom logo"),e.position&&(N=e.position,B(e.position)),e.dashboardTitle&&f(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{N=n.dataset.pos,B(N)})}),document.getElementById("brand")?.addEventListener("click",()=>{u=null,j=null,v=null,A&&(A.value=""),p&&(p.value=""),T&&(T.value=""),M&&(M.checked=!1),R.style.display="flex",P.style.display="none",C.src=x.src,O.src=$.src;const n=x.src.includes("custom-logo")||$.src.includes("custom-logo");E.textContent=n?"Using custom logo":"Using default logos",B(N),H.value=g,y.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=H.value.trim()||L,e={position:N,dashboardTitle:n};M?.checked&&v?(e.dataDark=v,e.dataLight=v):(u&&(e.dataDark=u),j&&(e.dataLight=j));const t=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(t.ok){const o=await t.json(),r="?t="+Date.now();o.pathDark&&(x.src=o.pathDark+r,C.src=o.pathDark+r),o.pathLight&&($.src=o.pathLight+r,O.src=o.pathLight+r),B(N),f(n),y.classList.remove("show")}else{const o=await t.json();showNotification("Failed to save: "+o.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",T.src="/assets/dashcaddy-logo-light.png",S.src="/assets/dashcaddy-logo-dark.png",O.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",C=null,j=null,M=null,B.value=I,m(I),D="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),l=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),d=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(d.rel="icon",d.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(d));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(d.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const n=new FileReader;n.onload=a=>{r=a.target.result,p.src=r,u.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();d.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),u.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{h.classList.contains("show")&&v.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(v,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=v.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const a=await n.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(h,S){const O=Intl.supportedValuesOf("timeZone"),E=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of O){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===E&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function O(){try{const A=await fetch("/api/v1/config");if(A.ok&&(S=await A.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(A){console.warn("Could not fetch server config, checking localStorage fallback:",A.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}O();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const A=document.getElementById(x);A&&(A.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let A='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",u=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;A+=`
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(x.src="/assets/dashcaddy-logo-dark.png",$.src="/assets/dashcaddy-logo-light.png",C.src="/assets/dashcaddy-logo-dark.png",O.src="/assets/dashcaddy-logo-light.png",E.textContent="Using default logos",u=null,j=null,v=null,H.value=L,f(L),N="left",B("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const t=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");t&&(t.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),l=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(y,document.getElementById("logo-cancel"));const b=document.getElementById("favicon-preview"),z=document.getElementById("favicon-status"),i=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function w(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),b.src=e.customFavicon+"?t="+Date.now(),z.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}i?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),i.value="";return}const t=new FileReader;t.onload=o=>{l=o.target.result,b.src=l,z.textContent="New favicon ready to save"},t.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),b.src=e.path+"?t="+Date.now(),z.textContent="Using custom favicon",l=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),w(),I();const m=document.getElementById("settings-timezone");m&&(new MutationObserver(()=>{y.classList.contains("show")&&m.options.length===0&&(async()=>{let e;try{const t=await fetch("/api/v1/config");t.ok&&(e=(await t.json()).timezone)}catch{}window.populateTimezoneSelect(m,e)})()}).observe(y,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=m.value;if(e)try{const t=await fetch("/api/v1/config");if(!t.ok)return;const o=await t.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(t){console.warn("Failed to save timezone:",t.message)}}))})(),window.populateTimezoneSelect=function(y,C){const O=Intl.supportedValuesOf("timeZone"),E=C||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y.innerHTML="";for(const M of O){const R=document.createElement("option");R.value=M,R.textContent=M.replace(/_/g," "),M===E&&(R.selected=!0),y.appendChild(R)}},(function(){let y="homelab",C=null;async function O(){try{const D=await fetch("/api/v1/config");if(D.ok&&(C=await D.json(),C&&C.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}O();const E=document.getElementById("setup-timezone");E&&window.populateTimezoneSelect(E);function M(g){document.querySelectorAll(".setup-step").forEach(B=>{B.style.display="none"});const D=document.getElementById(g);D&&(D.style.display="block")}function R(){const g=document.getElementById("setup-summary-content");if(!g)return;let D='<div style="display: grid; gap: 20px;">';if(y==="homelab"){const f=document.getElementById("setup-tld")?.value?.trim()||".home",I=document.getElementById("setup-ca-name")?.value?.trim()||"",b=document.getElementById("setup-dns-ip")?.value?.trim()||"",z=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${m}</div>
<div><strong>Certificate Authority:</strong> ${b}</div>
<div><strong>DNS Server:</strong> ${p}:${u}</div>
<div><strong>Example URLs:</strong> https://uptime${m}, https://nextcloud${m}</div>
<div><strong>TLD:</strong> ${f}</div>
<div><strong>Certificate Authority:</strong> ${I}</div>
<div><strong>DNS Server:</strong> ${b}:${z}</div>
<div><strong>Example URLs:</strong> https://uptime${f}, https://nextcloud${f}</div>
</div>
</div>
`}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";A+=`
`}else if(y==="simple"){const f=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${m}</div>
<div><strong>Default IP:</strong> ${f}</div>
<div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${m}:8080, http://${m}:3000</div>
<div><strong>Example URLs:</strong> http://${f}:8080, http://${f}:3000</div>
</div>
</div>
`}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;A+=`
`}else if(y==="public"){const f=document.getElementById("setup-public-domain")?.value?.trim()||"",I=document.getElementById("setup-public-email")?.value?.trim()||"",b=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",z=b==="subdirectory"?`https://${f}/sonarr, https://${f}/grafana`:`https://sonarr.${f}, https://grafana.${f}`;D+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${m}</div>
<div><strong>Domain:</strong> ${f}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${b}</div>
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${u}</div>
<div><strong>Email:</strong> ${I}</div>
<div><strong>Routing:</strong> ${b==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${z}</div>
</div>
</div>
`}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";A+=`
`}const B=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${y.replace(/_/g," ")}</div>
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${B.replace(/_/g," ")}</div>
</div>
`,A+="</div>",x.innerHTML=A,N("setup-step-summary")}async function z(x){try{const A=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return A.ok?(await A.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${A.status}`),{function:"saveConfigToServer"}),!1)}catch(A){return errorHandler.logError("[SetupWizard] Save Config",A,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const A=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=A?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const A=document.querySelector('input[name="config-type"]:checked');A&&(h=A.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const A=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=A),m&&(m.textContent=A)});const T=document.getElementById("setup-homelab-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!A||!A.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var A=document.getElementById("dns-requirement-note");A&&(A.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const C=document.getElementById("setup-public-back");C&&(C.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const A=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!A){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela Line truncated
`,D+=`
<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;">\u{1F4BE}</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 \u2192 Configure \u2192 Global Settings</strong>.
</div>
</div>
</div>
`,D+="</div>",g.innerHTML=D,M("setup-step-summary")}async function P(g){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(g)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const g={setupComplete:!0,configurationType:y,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(y==="homelab"){g.tld=document.getElementById("setup-tld")?.value?.trim()||".home",g.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const I=document.getElementById("setup-dns-provider")?.value||"technitium";g.dns={provider:I,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},g.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else y==="simple"?(g.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",g.defaults={dnsType:"none",sslType:"none",targetIP:g.defaultIP}):y==="public"&&(g.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",g.email=document.getElementById("setup-public-email")?.value?.trim()||"",g.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",g.defaults={dnsType:g.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await P(g);safeSet("dashcaddy-config",JSON.stringify(g)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const B=y==="homelab"?"Professional Home Lab":y==="simple"?"Simple Setup":"Public Server",f=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${B}. Settings saved to: ${f}`,"success",5e3),setTimeout(()=>location.reload(),500)}const p=document.getElementById("setup-step-1-next");p&&(p.onclick=function(g){g.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(y=D.value),M(y==="homelab"?"setup-step-homelab":y==="simple"?"setup-step-simple":y==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(g){g.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await P({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const x=document.getElementById("setup-tld");x&&(x.oninput=function(g){const D=g.target.value||".home",B=document.getElementById("tld-preview"),f=document.getElementById("tld-preview-2");B&&(B.textContent=D),f&&(f.textContent=D)});const $=document.getElementById("setup-homelab-back");$&&($.onclick=function(g){g.preventDefault(),M("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(g){g.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",B=document.getElementById("setup-ca-name")?.value?.trim()||"",f=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!B){showNotification("Please enter a Certificate Authority name","warning");return}if(!f){showNotification("Please enter your DNS server IP address","warning");return}R()});const H=document.getElementById("setup-simple-back");H&&(H.onclick=function(g){g.preventDefault(),M("setup-step-1")});const L=document.getElementById("setup-simple-next");L&&(L.onclick=function(g){g.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(g){g.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const u=document.getElementById("setup-public-back");u&&(u.onclick=function(g){g.preventDefault(),M("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(g){g.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",B=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!B||!B.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const v=document.getElementById("setup-summary-back");v&&(v.onclick=function(g){g.preventDefault(),y==="homelab"?M("setup-step-homela Line truncated
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +346,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const S="custom-apps";let O=null,E=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const d=await(await fetch("/api/v1/apps/templates")).json();if(d.success)return O=d.templates,E=d.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(d){return h.logError("[AppSelector] Check Port",d,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(d){h.logError("[AppSelector] Get Suggested Port",d,{function:"getSuggestedPort"})}return s}async function L(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!O&&!await z()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(O)){const v=f.category||"Other";s[v]||(s[v]=[]),s[v].push({id:r,...f})}const d=E?Object.keys(E):Object.keys(s).sort();for(const r of d){const f=s[r];if(!f||f.length===0)continue;f.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const v=document.createElement("div");v.className="app-category-header";const t=E?.[r]||{};v.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(v.style.borderBottomColor=t.color),R.appendChild(v),f.forEach(e=>{const n=document.createElement("div");n.className="app-option";const a=e.isDashboardWidget,l=a&&safeGet("widget-"+e.id+"-enabled")!=="false",o=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${l?"#2ecc7130":"#e74c3c30"}; color: ${l?"#2ecc71":"#e74c3c"}; font-weight: 600;">${l?"ON":"OFF"}</div>`:"",i=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
</div>`);const C="custom-apps";let O=null,E=null;const M=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function P(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return O=c.templates,E=c.categories,!0}catch(i){y.logError("[AppSelector] Fetch Templates",i,{function:"fetchApiTemplates"})}return!1}async function A(i){try{return await(await fetch(`/api/v1/apps/ports/${i}/check`)).json()}catch(c){return y.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function p(i){try{const l=await(await fetch(`/api/v1/apps/ports/${i}/suggest`)).json();if(l.success)return l.suggestedPort}catch(c){y.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return i}async function T(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!O&&!await P()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const i={};for(const[l,w]of Object.entries(O)){const m=w.category||"Other";i[m]||(i[m]=[]),i[m].push({id:l,...w})}const c=E?Object.keys(E):Object.keys(i).sort();for(const l of c){const w=i[l];if(!w||w.length===0)continue;w.sort((e,t)=>(t.popularity||0)-(e.popularity||0));const m=document.createElement("div");m.className="app-category-header";const n=E?.[l]||{};m.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(l)}`,n.color&&(m.style.borderBottomColor=n.color),R.appendChild(m),w.forEach(e=>{const t=document.createElement("div");t.className="app-option";const o=e.isDashboardWidget,r=o&&safeGet("widget-"+e.id+"-enabled")!=="false",a=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";t.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${o}${i}
`,a?n.onclick=()=>w(e,n):n.onclick=()=>T(e),R.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,d){const r="widget-"+s.id+"-enabled",v=!(safeGet(r)!=="false");safeSet(r,String(v));const t=s.widgetSelector;if(t){const n=document.querySelector(t);n&&(n.style.display=v?"":"none")}const e=d.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=v?"ON":"OFF",e.style.background=v?"#2ecc7130":"#e74c3c30",e.style.color=v?"#2ecc71":"#e74c3c"),showNotification(`${s.name} widget ${v?"enabled":"disabled"}`,"success",2e3)}async function T(s){const d=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),v=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container:
${a}${s}
`,o?t.onclick=()=>x(e,t):t.onclick=()=>$(e),R.appendChild(t)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function x(i,c){const l="widget-"+i.id+"-enabled",m=!(safeGet(l)!=="false");safeSet(l,String(m));const n=i.widgetSelector;if(n){const t=document.querySelector(n);t&&(t.style.display=m?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=m?"ON":"OFF",e.style.background=m?"#2ecc7130":"#e74c3c30",e.style.color=m?"#2ecc71":"#e74c3c"),showNotification(`${i.name} widget ${m?"enabled":"disabled"}`,"success",2e3)}async function $(i){const c=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),w=document.getElementById("deploy-subdomain"),m=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),t=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:i.id})})).json();if(G.success&&G.exists){const V=G.container;confirm(`Found existing ${i.name} container:
Container: ${V.name}
Status: ${V.status}
@@ -347,38 +360,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const l=s.subdomain||s.id.replace(/-/g,"");f.value=l;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">&#9432; '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),$=document.querySelector(`input[name="dns-type"][value="${i}"]`),H=document.querySelector(`input[name="ssl-type"][value="${c}"]`);$?$.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,H?H.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),F=q?.querySelector("div");if(q&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(F.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
Click Cancel to deploy a new container.`)&&(i._useExisting=!0,i._existingContainer=V)}}catch{}l.textContent=`Deploy ${i.name}`;const r=i.subdomain||i.id.replace(/-/g,"");w.value=r;const a=document.getElementById("subpath-compat-warning");if(a)if(SITE.routingMode==="subdirectory"){const q=i.subpathSupport||"strip";q==="none"?(a.style.display="block",a.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+i.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):q==="strip"?(a.style.display="block",a.innerHTML='<span style="color: var(--muted);">&#9432; '+i.name+" has unverified subdirectory support. It may require additional configuration.</span>"):a.style.display="none"}else a.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),d=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),S=document.querySelector(`input[name="dns-type"][value="${s}"]`),h=document.querySelector(`input[name="ssl-type"][value="${d}"]`);S?S.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,h?h.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",t.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const q=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;q&&!q.dataset.moved&&(F.appendChild(q),q.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const W=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(i.mediaMount){W.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const q=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){q.style.display="block",G.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(Z)})}else q.style.display="none"}catch{q.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else W.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(i.id==="plex"||i.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",i.docker?.volumes?.length){const q=i.mediaMount?.containerPath,G=i.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(q&&V.endsWith(":"+q)));G.length>0?(ne.style.display="block",G.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=i.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const q=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await A(q);if(G.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(q))} is available</span>`;else{const V=await p(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(q)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?o.innerHTML=`
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}v.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),d.classList.add("show"),d.dataset.appTemplate=JSON.stringify(s)}async function k(s){const d=s.appTemplate,r=safeGetJSON(S,[]),f=d._useExisting&&d._existingContainer,v=r.find(t=>t.id===s.subdomain);if(!(v&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(v){const t=r.indexOf(v);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=d._existingContainer.primaryPort;else{const t=s.port||d.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const n=await g(d.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',t.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const q=w.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${q}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(q)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||q;K=SITE.domain?`${te}://${q}.${SITE.domain}`:`${te}://${q}`}else{const te=e.value||i.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${n.value}:${te}`}m.textContent=K}w.oninput=ae,n.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(q=>{q.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(q=>{q.onchange=ae}),ae(),M.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(i)}async function k(i){const c=i.appTemplate,l=safeGetJSON(C,[]),w=c._useExisting&&c._existingContainer,m=l.find(n=>n.id===i.subdomain);if(!(m&&!w&&!confirm(`An app with subdomain "${i.subdomain}" already exists. Redeploy?`))){if(m){const n=l.indexOf(m);l.splice(n,1),safeSet(C,JSON.stringify(l))}if(w)i.port=c._existingContainer.primaryPort;else{const n=i.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await A(n);if(!e.available){const t=await p(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${n} instead?`))s.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${d.name} with existing container...`:`Deploying ${d.name}...`,"info",0);try{const t={appId:d.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||d.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=d._existingContainer.id,t.config.existingPort=d._existingContainer.primaryPort,!s.port&&d._existingContainer.primaryPort&&(t.config.port=d._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(n.success){const a={id:s.subdomain,name:d.name,logo:`/assets/${d.id}.png`,containerId:n.containerId,url:n.url,ip:s.ip,appTemplate:d.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(o=>o.id===d.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=n.usedExisting?`${d.name} configured with existing container!
URL: ${n.url}`:`${d.name} deployed successfully!
URL: ${n.url}`;n.warning&&(l+=`
Would you like to use port ${t} instead?`))i.port=t;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(w?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:i.subdomain,ip:i.ip,createDns:i.dnsType==="private",port:i.port||c.defaultPort||null,sslType:i.sslType,dnsType:i.dnsType,tailscaleOnly:i.tailscaleOnly||!1,mediaPath:i.mediaPath||null,plexClaimToken:i.plexClaimToken||null,customVolumes:i.customVolumes||null}};w&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!i.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const t=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(t.success){const o={id:i.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:t.containerId,url:t.url,ip:i.ip,appTemplate:c.id,tailscaleOnly:i.tailscaleOnly||!1};l.push(o),safeSet(C,JSON.stringify(l)),window.APPS&&!window.APPS.some(a=>a.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let r=t.usedExisting?`${c.name} configured with existing container!
URL: ${t.url}`:`${c.name} deployed successfully!
URL: ${t.url}`;t.warning&&(r+=`
\u26A0 Warning: ${n.warning}`),showNotification(l,"success",8e3),delete d._useExisting,delete d._existingContainer,n.url&&n.url.startsWith("https://")&&B(n.url,d.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${d.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${d.name}: ${t.message}`,"error",8e3)}}}async function B(s,d){showNotification(`\u23F3 Generating SSL certificate for ${d}...`,"warning",6e4);let r=0;const f=12,v=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${d} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r<f?setTimeout(v,5e3):showNotification(`\u26A0\uFE0F ${d} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(v,3e3)}function I(){safeGetJSON(S,[]).forEach(d=>{window.APPS.some(r=>r.id===d.id)||window.APPS.push(d)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const C=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(C.dataset.appTemplate),d=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(v=>{r.push({hostPath:v.value.trim(),containerPath:v.dataset.containerPath})});const f={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:d||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!d){showNotification("Please enter a media library path for this application","warning");return}C.classList.remove("show"),k(f)}),wireModal(C);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),D=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),A=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(d=>d.trim()).filter(d=>d),y="",u(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",D.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(r.error)}</div>`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(r.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
\u26A0 Warning: ${t.warning}`),showNotification(r,"success",8e3),delete c._useExisting,delete c._existingContainer,t.url&&t.url.startsWith("https://")&&H(t.url,c.name),t.setupInstructions&&t.setupInstructions.length>0&&setTimeout(()=>{const a=t.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${a}`,"info",1e4)},1e3)}else throw new Error(t.error||"Deployment failed")}catch(n){y.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function H(i,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let l=0;const w=12,m=async()=>{l++;try{const n=await fetch(i,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<w?setTimeout(m,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(m,3e3)}function L(){safeGetJSON(C,[]).forEach(c=>{window.APPS.some(l=>l.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),M.classList.add("show")}),wireModal(M,document.getElementById("app-selector-cancel"));const u=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{u.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const i=JSON.parse(u.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(m=>{l.push({hostPath:m.value.trim(),containerPath:m.dataset.containerPath})});const w={appTemplate:i,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!w.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(i.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}u.classList.remove("show"),k(w)}),wireModal(u);const j=document.getElementById("folder-browser-modal"),v=document.getElementById("folder-browser-path"),N=document.getElementById("folder-browser-list"),g=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let B="",f=[],I=null;window.openFolderBrowser=function(i){I=i,f=i.value.split(",").map(c=>c.trim()).filter(c=>c),B="",z(),b(""),j.classList.add("show")};async function b(i){v.textContent=i||"Select a drive...",N.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(i)}`)).json();if(!l.success){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}B=l.path||"",v.textContent=B||"Select a drive...";let w="";l.parent&&l.parent!==l.path&&(w+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span>
</div>`),r.items.length===0&&!r.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':r.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':r.items.forEach(v=>{const t=v.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(v.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(v.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
<span style="font-size: 1.2rem;">${t}</span>
<span style="flex: 1;">${escapeHtml(v.name)}</span>
</div>`),l.items.length===0&&!l.parent?w+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?w+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(m=>{const n=m.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=f.includes(m.path),t=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";w+=`<div class="folder-item" data-path="${escapeHtml(m.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${t}">
<span style="font-size: 1.2rem;">${n}</span>
<span style="flex: 1;">${escapeHtml(m.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),D.innerHTML=f,D.querySelectorAll(".folder-item").forEach(v=>{v.addEventListener("click",()=>{p(v.dataset.path)}),v.addEventListener("mouseenter",()=>{v.style.background="var(--card-bg)"}),v.addEventListener("mouseleave",()=>{const t=m.includes(v.dataset.path);v.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(d){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(d.message)}</div>`}}function u(){if(m.length===0){x.style.display="none";return}x.style.display="block",A.innerHTML=m.map(s=>`
</div>`}),N.innerHTML=w,N.querySelectorAll(".folder-item").forEach(m=>{m.addEventListener("click",()=>{b(m.dataset.path)}),m.addEventListener("mouseenter",()=>{m.style.background="var(--card-bg)"}),m.addEventListener("mouseleave",()=>{const n=f.includes(m.dataset.path);m.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){N.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function z(){if(f.length===0){g.style.display="none";return}g.style.display="block",D.innerHTML=f.map(i=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(s)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
${escapeHtml(i)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(i)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span>
`).join("")}window.removeSelectedFolder=function(s){m=m.filter(d=>d!==s),u(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),u(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
`).join("")}window.removeSelectedFolder=function(i){f=f.filter(c=>c!==i),z(),b(B)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{B&&!f.includes(B)&&(f.push(B),z(),b(B))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{I&&(I.value=f.join(", ")),j.classList.remove("show")}),L()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +458,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);let h=null,S=null,O=null,E=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function L(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(y){await w();let m;if(N&&h?m=h:m=T(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((u,s)=>(s.popularity||0)-(u.popularity||0));for(const u of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const d=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${u.componentCount||u.components?.length||"?"} apps</div>`,r=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
${r}
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(u.name)}</div>
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
${d}
`,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(u)},y.appendChild(s)}};function T(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(y){O=y,E=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,B(),I(),R.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===E),y.classList.toggle("completed",m<E)});for(let y=1;y<=4;y++){const m=document.getElementById(`recipe-step-${y}`);m&&(m.style.display=y===E?"":"none")}P.style.display=E>1&&E<4?"":"none",E===4?(g.style.display="none",z.textContent="Close"):E===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=O.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=b.required,s=b.internal;p.innerHTML=`
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${b.id}"
</div>`);let y=null,C=null,O=null,E=1,M=!1;const R=document.getElementById("recipe-deploy-modal"),P=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),p=document.getElementById("recipe-next");wireModal(R,P);async function T(){try{const B=await fetch("/api/v1/recipes/templates"),f=await B.json();if(f.success)return y=f.templates,C=f.categories,!0;if(B.status===403)return M=!1,!1}catch(B){console.warn("Failed to fetch recipe templates:",B.message)}return!1}async function x(){try{M=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{M=!1}return M}window.renderRecipeCards=async function(B){await x();let f;if(M&&y?f=y:f=$(),!f||f.length===0)return;const I=document.createElement("div");I.className="app-category-header",I.innerHTML="\u{1F9EA} Recipes",I.style.borderBottomColor="#8e44ad",B.appendChild(I);const b=Array.isArray(f)?f:Object.values(f);b.sort((z,i)=>(i.popularity||0)-(z.popularity||0));for(const z of b){const i=document.createElement("div");i.className="app-option",i.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${z.componentCount||z.components?.length||"?"} apps</div>`,l=M?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';i.innerHTML=`
${l}
<div class="app-option-icon">${escapeHtml(z.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(z.name)}</div>
<div class="app-option-desc">${escapeHtml(z.description||"")}</div>
${c}
`,i.onclick=()=>{if(!M){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(z)},B.appendChild(i)}};function $(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(B){O=B,E=1;const f=document.getElementById("app-selector-modal");f&&f.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${B.name}`,H(),L(),R.classList.add("show")}function H(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(B=>{const f=parseInt(B.dataset.step);B.classList.toggle("active",f===E),B.classList.toggle("completed",f<E)});for(let B=1;B<=4;B++){const f=document.getElementById(`recipe-step-${B}`);f&&(f.style.display=B===E?"":"none")}A.style.display=E>1&&E<4?"":"none",E===4?(p.style.display="none",P.textContent="Close"):E===3?(p.textContent="\u{1F680} Deploy",p.style.display="",P.textContent="Cancel"):(p.textContent="Next",p.style.display="",P.textContent="Cancel")}function L(){const B=document.getElementById("recipe-component-list");B.innerHTML="";const f=O.components||[];for(const I of f){const b=document.createElement("div");b.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const z=I.required,i=I.internal;b.innerHTML=`
<input type="checkbox" ${z?"checked disabled":"checked"} data-component-id="${I.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(b.role||b.id)}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.role||I.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);">
${b.templateRef?escapeHtml(b.templateRef):"Built-in"}
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
${I.templateRef?escapeHtml(I.templateRef):"Built-in"}
${z?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${i?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div>
${b.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(b.note)}</div>`:""}
${I.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(I.note)}</div>`:""}
</div>
`,y.appendChild(p)}}function C(){const y=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),b=O.sharedVolumes;if(b&&Object.keys(b).length>0){y.style.display="",m.innerHTML="";for(const[p,u]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||p)}</label>
<input type="text" data-volume-key="${p}" value="${escapeHtml(u.defaultPath||"")}"
placeholder="${escapeHtml(u.defaultPath||"/path")}"
`,B.appendChild(b)}}function u(){const B=document.getElementById("recipe-volumes-section"),f=document.getElementById("recipe-volume-list"),I=O.sharedVolumes;if(I&&Object.keys(I).length>0){B.style.display="",f.innerHTML="";for(const[b,z]of Object.entries(I)){const i=document.createElement("div");i.style.cssText="display: grid; gap: 4px;",i.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(z.label||b)}</label>
<input type="text" data-volume-key="${b}" value="${escapeHtml(z.defaultPath||"")}"
placeholder="${escapeHtml(z.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
`,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const u=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",d=document.getElementById("recipe-tailscale").checked;y.innerHTML=`
<div class="form-hint-sm">${escapeHtml(z.description||"")}</div>
`,f.appendChild(i)}}else B.style.display="none"}function j(){const B=document.getElementById("recipe-review-content"),f=v(),I=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),b={};I.forEach(l=>{b[l.dataset.volumeKey]=l.value});const z=document.getElementById("recipe-timezone").value||"UTC",i=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;B.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(O.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(O.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${m.length}):</strong>
<strong>Components (${f.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;">
${m.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
${f.map(l=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(l.role||l.id)}</strong> ${l.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")}
</div>
</div>
${Object.keys(p).length>0?`<div style="margin-bottom: 12px;">
${Object.keys(b).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong>
${Object.entries(p).map(([r,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(f)}</div>`).join("")}
${Object.entries(b).map(([l,w])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(w)}</div>`).join("")}
</div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(u)} &bull; IP: ${escapeHtml(s)} ${d?"&bull; Tailscale only":""}
Timezone: ${escapeHtml(z)} &bull; IP: ${escapeHtml(i)} ${c?"&bull; Tailscale only":""}
</div>
${O.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(O.network.name)}</code></div>`:""}
`}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=O.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function D(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const d of b){const r=document.createElement("div");r.id=`recipe-progress-${d.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
`}function v(){const B=document.querySelectorAll("#recipe-component-list input[data-component-id]"),f=new Set;B.forEach(b=>{b.checked&&f.add(b.dataset.componentId)});const I=O.components||[];return I.filter(b=>b.required).forEach(b=>f.add(b.id)),I.filter(b=>f.has(b.id))}async function N(){const B=document.getElementById("recipe-progress-list"),f=document.getElementById("recipe-deploy-result");f.style.display="none",B.innerHTML="";const I=v();for(const c of I){const l=document.createElement("div");l.id=`recipe-progress-${c.id}`,l.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",l.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(d.role||d.id)}</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};p.forEach(d=>{u[d.dataset.volumeKey]=d.value});const s={selectedComponents:b.map(d=>d.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:u},componentOverrides:{}};for(const d of b)x(d.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:O.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
`,B.appendChild(l)}const b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),z={};b.forEach(c=>{z[c.dataset.volumeKey]=c.value});const i={selectedComponents:I.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:z},componentOverrides:{}};for(const c of I)g(c.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:O.id,config:i})})).json();if(l.success){for(const w of l.deployed||[])g(w.id,"success",w.url?`Running \u2192 ${w.url}`:"Running");for(const w of l.errors||[])g(w.componentId,"error",w.error);f.style.display="",f.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(r.message||"Deployed!")}</div>
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(l.message||"Deployed!")}</div>
${l.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(w=>`<li>${escapeHtml(w)}</li>`).join("")}</ul>
</div>`:""}
</div>
`,showNotification(`${O.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(d){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(d.message)}
</div>`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const u=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(u.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(u.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(u.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(E===3){E=4,B(),D();return}E<3&&(E++,B(),E===2&&C(),E===3&&j())}),P.addEventListener("click",()=>{E>1&&E<4&&(E--,B())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((u,s)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let d=u.querySelector(".recipe-group-label");d||(d=document.createElement("div"),d.className="recipe-group-label",d.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",d.textContent=b.replace(/-/g," "),u.style.position="relative",u.appendChild(d))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",u=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const d=await(await secureFetch(u,{method:p})).json();d.success?(showNotification(`Recipe ${m}: ${d.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${d.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const A=document.createElement("style");A.textContent=`
`,showNotification(`${O.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(c){f.style.display="",f.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function g(B,f,I){const b=document.getElementById(`recipe-progress-${B}`);if(!b)return;const z=b.querySelector(".recipe-progress-icon"),i=b.querySelector(".recipe-progress-status");f==="deploying"?(z.textContent="\u23F3",i.style.color="var(--accent)"):f==="success"?(z.textContent="\u2705",i.style.color="var(--ok-fg)"):f==="error"&&(z.textContent="\u274C",i.style.color="var(--bad-fg)"),i.textContent=I}p.addEventListener("click",()=>{if(E===3){E=4,H(),N();return}E<3&&(E++,H(),E===2&&u(),E===3&&j())}),A.addEventListener("click",()=>{E>1&&E<4&&(E--,H())}),window.groupRecipeCards=function(){const B=document.querySelectorAll(".service-card[data-recipe-id]");if(B.length===0)return;const f={};B.forEach(I=>{const b=I.dataset.recipeId;f[b]||(f[b]=[]),f[b].push(I)});for(const[I,b]of Object.entries(f))b.length<2||b.forEach((z,i)=>{if(z.style.borderLeft="3px solid rgba(142,68,173,0.5)",i===0){let c=z.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=I.replace(/-/g," "),z.style.position="relative",z.appendChild(c))}})},window.manageRecipe=async function(B,f){const I=`/api/v1/recipes/${B}/${f}`,b=f==="remove"?"DELETE":"POST",z=f==="remove"?`/api/v1/recipes/${B}`:I;if(!(f==="remove"&&!confirm(`Remove the entire ${B} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(z,{method:b})).json();c.success?(showNotification(`Recipe ${f}: ${c.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${f} failed: ${c.error}`,"error",5e3)}catch(i){showNotification(`Network error: ${i.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=`
.recipe-step {
flex: 1;
text-align: center;
@@ -550,16 +563,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
min-height: 180px;
}
`,document.head.appendChild(A),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const O=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await O.json();if(O.ok&&E.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(O){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${O.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),O=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?S.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':S.innerHTML=L.logs.map(w=>`
`,document.head.appendChild(D),x()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const y=document.getElementById("reload-caddy-top"),C=y.textContent;try{y.textContent="\u23F3 Reloading...",y.disabled=!0;const O=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),E=await O.json();if(O.ok&&E.success)y.textContent="\u2705 Reloaded!",setTimeout(()=>{y.textContent=C,y.disabled=!1},2e3);else throw new Error(E.error||"Reload failed")}catch(O){y.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${O.message}`,"error"),setTimeout(()=>{y.textContent=C,y.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const y=document.getElementById("error-log-modal"),C=document.getElementById("error-log-content"),O=document.getElementById("view-error-logs"),E=document.getElementById("error-log-refresh"),M=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function P(){C.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const T=await(await fetch("/api/v1/error-logs")).json();T.success&&T.logs?T.logs.length===0?C.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':C.innerHTML=T.logs.map(x=>`
<div class="log-entry error">
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
<span class="log-timestamp">${new Date(x.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span>
<div class="log-message">
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
<strong>${escapeHtml(x.context)}</strong>: ${escapeHtml(x.error)}
${x.details?`<br><small style="opacity: 0.7;">${escapeHtml(x.details)}</small>`:""}
</div>
</div>
`).join(""):S.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){S.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}O?.addEventListener("click",()=>{h.classList.add("show"),z()}),E?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
`).join(""):C.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(p){C.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(p.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),P()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(p){showNotification(`\u274C Error: ${p.message}`,"error",3e3)}}O?.addEventListener("click",()=>{y.classList.add("show"),P()}),E?.addEventListener("click",P),M?.addEventListener("click",A),wireModal(y,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
@@ -609,14 +622,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),O=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),T=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),C=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),D=document.getElementById("cl-created");let x=null,A=[],y=[],m=null,b=!1,p=null;function u(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function d(i,c){const $=i.stream==="stderr"?"log-stderr":"log-stdout",H=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${$}" data-index="${c}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${c+1}</span>
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${H}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(i.text)}</span>
</div>`);const y=document.getElementById("container-logs-modal"),C=document.getElementById("cl-container-select"),O=document.getElementById("cl-log-content"),E=document.getElementById("cl-log-search"),M=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),P=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),p=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),x=document.getElementById("cl-close-btn"),$=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),H=document.getElementById("cl-stream-text"),L=document.getElementById("cl-line-count"),u=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),v=document.getElementById("cl-status"),N=document.getElementById("cl-created");let g=null,D=[],B=[],f=null,I=!1,b=null;function z(s){if(!s)return"-";const d=new Date(s);return isNaN(d.getTime())?s:d.toLocaleString()}function i(s){if(!s)return"";const d=document.createElement("div");return d.textContent=s,d.innerHTML}function c(s,d){const S=s.stream==="stderr"?"log-stderr":"log-stdout",h=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${S}" data-index="${d}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${d+1}</span>
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${h}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${i(s.text)}</span>
</div>
`}function r(i,c=""){if(!i||i.length===0){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',I.textContent="0 lines",C.textContent="0 filtered";return}if(A=i,y=c?i.filter($=>$.text&&$.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,C.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){O.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(c)}"</div>`;return}O.innerHTML=y.map(($,H)=>d($,H)).join(""),O.scrollTop=O.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],$=S.value;S.innerHTML='<option value="">Select a container...</option>',c.forEach(H=>{const U=document.createElement("option");U.value=H.id,U.textContent=`${H.name} (${H.image.split(":")[0]}) - ${H.status}`,U.dataset.name=H.name,U.dataset.image=H.image,U.dataset.status=H.status,U.dataset.created=H.created,S.appendChild(U)}),$&&S.querySelector(`option[value="${$}"]`)&&(S.value=$,v($))}catch(i){console.error("Failed to load containers:",i)}}function v(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",D.textContent=u(c.dataset.created))}async function t(){const i=S.value;if(!i){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),x=i,v(i);const c=N.value,$=E.value.trim();O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const H=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,U=await getJSON(H);U.logs&&U.logs.length>0?r(U.logs,$):(O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',I.textContent="0 lines",C.textContent="0 filtered")}catch(H){O.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(H.message)}</div>`}}function e(){const i=S.value;if(!i)return;n(),b=!0,z.textContent="\u23F9 Stop",T.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},m.onmessage=$=>{try{const H=JSON.parse($.data);if(H.error){k.textContent="\u{1F534}",B.textContent=`Error: ${H.error}`;return}A.push(H),y.push(H),I.textContent=`${A.length} lines`,C.textContent=`${y.length} shown`;const U=E.value.trim();if(!U||H.text&&H.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=d(H,y.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",O.appendChild(F),O.scrollTop=O.scrollHeight}}catch(H){console.error("Error parsing log:",H)}},m.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function n(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",T.style.display="none"}function a(){if(!A||A.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),$=`${i}-logs-${c}.txt`,H=A.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
`),U=new Blob([H],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=$,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${A.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?n():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{E.value="",r(A,"")}),E?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(A,E.value.trim())},300)}),E?.addEventListener("keydown",i=>{i.key==="Escape"&&(E.value="",r(A,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function o(){n(),h.classList.remove("show")}L?.addEventListener("click",o),w?.addEventListener("click",o),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&o()}),h.addEventListener("click",i=>{i.target===h&&o()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const $=Array.from(S.options).find(H=>H.value===i||H.dataset.name===c);$?(S.value=$.value,v($.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",D.textContent="-",t()):O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
`}function l(s,d=""){if(!s||s.length===0){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',L.textContent="0 lines",u.textContent="0 filtered";return}if(D=s,B=d?s.filter(S=>S.text&&S.text.toLowerCase().includes(d.toLowerCase())):s,L.textContent=`${s.length} lines`,u.textContent=d?`${B.length} of ${s.length} shown`:`${s.length} shown`,B.length===0){O.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${i(d)}"</div>`;return}O.innerHTML=B.map((S,h)=>c(S,h)).join(""),O.scrollTop=O.scrollHeight}async function w(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],S=C.value;C.innerHTML='<option value="">Select a container...</option>',d.forEach(h=>{const U=document.createElement("option");U.value=h.id,U.textContent=`${h.name} (${h.image.split(":")[0]}) - ${h.status}`,U.dataset.name=h.name,U.dataset.image=h.image,U.dataset.status=h.status,U.dataset.created=h.created,C.appendChild(U)}),S&&C.querySelector(`option[value="${S}"]`)&&(C.value=S,m(S))}catch(s){console.error("Failed to load containers:",s)}}function m(s){const d=C.querySelector(`option[value="${s}"]`);d&&(j.textContent=d.dataset.image||"-",v.textContent=d.dataset.status||"-",v.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",N.textContent=z(d.dataset.created))}async function n(){const s=C.value;if(!s){O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}t(),g=s,m(s);const d=M.value,S=E.value.trim();O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const h=`/api/v1/logs/container/${s}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(h);U.logs&&U.logs.length>0?l(U.logs,S):(O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',L.textContent="0 lines",u.textContent="0 filtered")}catch(h){O.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${i(h.message)}</div>`}}function e(){const s=C.value;if(!s)return;t(),I=!0,P.textContent="\u23F9 Stop",$.style.display="flex",k.textContent="\u{1F7E2}",H.textContent="Connecting...";const d=`/api/v1/logs/stream/${s}`;f=new EventSource(d),f.onopen=()=>{k.textContent="\u{1F7E2}",H.textContent="Connected - streaming logs"},f.onmessage=S=>{try{const h=JSON.parse(S.data);if(h.error){k.textContent="\u{1F534}",H.textContent=`Error: ${h.error}`;return}D.push(h),B.push(h),L.textContent=`${D.length} lines`,u.textContent=`${B.length} shown`;const U=E.value.trim();if(!U||h.text&&h.text.toLowerCase().includes(U.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(h,B.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",O.appendChild(F),O.scrollTop=O.scrollHeight}}catch(h){console.error("Error parsing log:",h)}},f.onerror=()=>{k.textContent="\u{1F534}",H.textContent="Disconnected",I=!1,P.textContent="\u25B6 Stream"},y._eventSource=f}function t(){f&&(f.close(),f=null),y._eventSource&&(y._eventSource.close(),y._eventSource=null),I=!1,P.textContent="\u25B6 Stream",$.style.display="none"}function o(){if(!D||D.length===0){showNotification("No logs to download","error");return}const s=C.querySelector(`option[value="${g}"]`)?.dataset.name||g,d=new Date().toISOString().replace(/[:.]/g,"-"),S=`${s}-logs-${d}.txt`,h=D.map(W=>{const J=W.timestamp||"",X=W.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${W.text}`}).join(`
`),U=new Blob([h],{type:"text/plain"}),_=URL.createObjectURL(U),F=document.createElement("a");F.href=_,F.download=S,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${D.length} log lines`,"success")}C?.addEventListener("change",()=>{n()}),M?.addEventListener("change",()=>{n()}),R?.addEventListener("click",()=>{n()}),P?.addEventListener("click",()=>{I?t():e()}),A?.addEventListener("click",()=>{o()}),p?.addEventListener("click",()=>{E.value="",l(D,"")}),E?.addEventListener("input",()=>{clearTimeout(b),b=setTimeout(()=>{l(D,E.value.trim())},300)}),E?.addEventListener("keydown",s=>{s.key==="Escape"&&(E.value="",l(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{y.classList.add("show"),w()});function a(){t(),y.classList.remove("show")}T?.addEventListener("click",a),x?.addEventListener("click",a),document.addEventListener("keydown",s=>{s.key==="Escape"&&y.classList.contains("show")&&a()}),y.addEventListener("click",s=>{s.target===y&&a()}),window.openContainerLogsModal=function(s,d){y.classList.add("show"),w().then(()=>{const S=Array.from(C.options).find(h=>h.value===s||h.dataset.name===d);S?(C.value=S.value,m(S.value),n()):s?(g=s,j.textContent=d||s,v.textContent="-",N.textContent="-",n()):O.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle">
@@ -670,7 +683,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),O=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const C of I.containers){const j=document.createElement("option");j.value=C.id,j.textContent=`${C.name||C.id} (${C.image||"unknown"})`,j.dataset.name=C.name,j.dataset.image=C.image,j.dataset.status=C.status,j.dataset.created=C.created,E.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",P=null;return}P=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(C){z.textContent=`\u2717 Error: ${C.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function T(){h.classList.add("show"),g()}function k(){h.classList.remove("show"),N.style.display="none",P=null,E.selectedIndex=0}S?.addEventListener("click",T),O?.addEventListener("click",k),wireModal(h,O),E?.addEventListener("change",B=>{const I=E.options[E.selectedIndex];L(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
</div>`);const y=document.getElementById("snapshot-modal"),C=document.getElementById("snapshot-btn"),O=document.getElementById("snapshot-close"),E=document.getElementById("snapshot-container-select"),M=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),P=document.getElementById("snapshot-create-status");let A=null;async function p(){try{const L=await(await fetch("/api/v1/containers")).json();if(!L.success||!L.containers)return;E.innerHTML='<option value="">-- Select a container --</option>';for(const u of L.containers){const j=document.createElement("option");j.value=u.id,j.textContent=`${u.name||u.id} (${u.image||"unknown"})`,j.dataset.name=u.name,j.dataset.image=u.image,j.dataset.status=u.status,j.dataset.created=u.created,E.appendChild(j)}}catch(H){console.error("Failed to load containers:",H)}}function T(H){if(!H||!H.value){M.style.display="none",A=null;return}A=H.value,document.getElementById("snapshot-image").textContent=H.dataset.image||"-",document.getElementById("snapshot-status").textContent=H.dataset.status||"-",document.getElementById("snapshot-created").textContent=H.dataset.created?new Date(H.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=H.value.substring(0,12),M.style.display=""}async function x(){if(!A){P.textContent="Please select a container first",P.style.color="var(--bad-fg)";return}const H=document.getElementById("snapshot-name").value.trim();if(!H){P.textContent="Please enter a snapshot name",P.style.color="var(--bad-fg)";return}const L=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",P.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:H,leaveRunning:L})})).json();j.success?(P.textContent=`\u2713 Snapshot "${H}" created successfully`,P.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(P.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,P.style.color="var(--bad-fg)")}catch(u){P.textContent=`\u2717 Error: ${u.message}`,P.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function $(){y.classList.add("show"),p()}function k(){y.classList.remove("show"),M.style.display="none",A=null,E.selectedIndex=0}C?.addEventListener("click",$),O?.addEventListener("click",k),wireModal(y,O),E?.addEventListener("change",H=>{const L=E.options[E.selectedIndex];T(L)}),R?.addEventListener("click",x),y?.querySelectorAll(".panel-tab").forEach(H=>{H.addEventListener("click",()=>{y.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),y.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active")),H.classList.add("active"),y.querySelector(`#${H.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -749,73 +762,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),O=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),T=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let I=null;const C={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function D(p){const u={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},s=u[p]||u.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(I.error)}</div>`,g.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[d,r]of Object.entries(I.services)){const f=C[d]||"\u{1F4E6}",v=j[d]||d,t=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",e=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",n=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${f}</span>
</div>`);const y=document.getElementById("arr-setup-modal"),C=document.getElementById("arr-setup-btn"),O=document.getElementById("arr-setup-cancel"),E=document.getElementById("smart-connect-btn"),M=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),P=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),p=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),x=document.getElementById("smart-progress-steps"),$=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),H=document.getElementById("smart-retry-btn");let L=null;const u={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function v(b){M.style.display=b==="detect"?"block":"none",R.style.display=b==="credentials"?"block":"none",P.style.display=b==="progress"?"block":"none",A.style.display=b==="results"?"block":"none"}function N(b){const z={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},i=z[b]||z.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${i.bg} 20%, transparent); color: ${i.bg};">${i.icon} ${i.text}</span>`}async function g(){v("detect"),p.style.display="none";try{if(L=await(await fetch("/api/v1/arr/smart-detect")).json(),!L.success){p.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(L.error)}</div>`,p.style.display="block";return}let z='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,l]of Object.entries(L.services)){const w=u[c]||"\u{1F4E6}",m=j[c]||c,n=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",t=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";z+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${w}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${v}</div>
<div style="font-weight: 500; font-size: 0.9rem;">${m}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${t} ${e} ${n}
${n} ${e} ${t}
</div>
</div>
${D(r.status)}
</div>`}u+="</div>";const s=I.summary;u+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected &middot;
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` &middot; <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
</div>`,g.innerHTML=u,g.style.display="block",A(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(p.message)}</div>`,g.style.display="block"}}function A(p){let u="";const s=p.services,d=["radarr","sonarr","prowlarr"];for(const v of d){const t=s[v];if(!t||t.status==="not_found"&&!t.url)continue;const e=C[v],n=j[v],a=t.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
${N(l.status)}
</div>`}z+="</div>";const i=L.summary;z+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(i.fullyConnected))}/${escapeHtml(String(i.totalDetected+(5-i.totalDetected)))} services detected &middot;
${escapeHtml(String(i.fullyConnected))} connected${i.needsApiKey>0?` &middot; <strong>${escapeHtml(String(i.needsApiKey))} needs API key</strong>`:""}
</div>`,p.innerHTML=z,p.style.display="block",D(L),setTimeout(()=>{v("credentials")},800)}catch(b){p.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(b.message)}</div>`,p.style.display="block"}}function D(b){let z="";const i=b.services,c=["radarr","sonarr","prowlarr"];for(const m of c){const n=i[m];if(!n||n.status==="not_found"&&!n.url)continue;const e=u[m],t=j[m],o=n.status==="connected";z+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${n}</span>
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;">
${a?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
<span style="font-weight: 500;">${t}</span>
<span id="smart-${m}-status" style="margin-left: auto; font-size: 0.75rem;">
${o?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${v}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${v}/"
<input type="text" id="smart-${m}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${m}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${v}-key" placeholder="${a?"(saved)":"Paste API key"}"
<input type="password" id="smart-${m}-key" placeholder="${o?"(saved)":"Paste API key"}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
</div>
<button onclick="smartTestConnection('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const r=s.plex;if(r){const v=r.status==="connected";u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<button onclick="smartTestConnection('${m}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const l=i.plex;if(l){const m=l.status==="connected";z+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span>
${D(r.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
${N(l.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span>
</div>
</div>`}const f=s.seerr;if(f){const v=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
</div>`}const w=i.seerr;if(w){const m=w.status==="connected";let n="";if(w.configuredServices){const e=w.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};">
</div>`}z+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${D(f.status)}
${N(w.status)}
</div>
${t}
</div>`}L.innerHTML=u}window.smartTestConnection=async function(p){const u=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),d=document.getElementById(`smart-${p}-status`),r=u?.value.trim(),f=s?.value.trim();if(!r||!f){d.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}d.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?d.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:d.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(v){d.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(v.message)}</span>`}};async function y(){M("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const p={};for(const s of["radarr","sonarr","prowlarr"]){const d=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&d?p[s]={apiKey:r,url:d}:r&&(p[s]={apiKey:r})}const u={services:Object.keys(p).length>0?p:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const d=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();let r="";for(const f of d.steps||[]){const v=f.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${v}
<span>${escapeHtml(f.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(f.details||"")}</span>
</div>`}w.innerHTML=r,setTimeout(()=>m(d),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function m(p){M("results");const u=p.summary||{},s=u.failed===0&&u.succeeded>0,d=s?"var(--ok-fg)":"#f39c12",r=s?"&#10003;":"&#9888;",f=s?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.totalSteps))} Steps Succeeded`;let v=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${d} 12%, transparent); border-radius: 10px; border: 1px solid ${d}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${d};">${r}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${d};">${f}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
</div>`;v+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of p.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';v+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
</div>`}v+="</div>",T.innerHTML=v,B.style.display=u.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(u.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const d of u.libraries){const r=d.type==="movie"?"\u{1F3AC}":d.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${r} <strong>${escapeHtml(d.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(d.count))} items</span>
</div>`}s+="</div></div>",k.innerHTML=s,k.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,O),E?.addEventListener("click",y),B?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
${n}
</div>`}T.innerHTML=z}window.smartTestConnection=async function(b){const z=document.getElementById(`smart-${b}-url`),i=document.getElementById(`smart-${b}-key`),c=document.getElementById(`smart-${b}-status`),l=z?.value.trim(),w=i?.value.trim();if(!l||!w){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:b,url:l,apiKey:w})})).json();n.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(n.error)}</span>`}catch(m){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(m.message)}</span>`}};async function B(){v("progress"),x.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const b={};for(const i of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${i}-url`)?.value.trim(),l=document.getElementById(`smart-${i}-key`)?.value.trim();l&&c?b[i]={apiKey:l,url:c}:l&&(b[i]={apiKey:l})}const z={services:Object.keys(b).length>0?b:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(z)})).json();let l="";for(const w of c.steps||[]){const m=w.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',n=w.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${m}
<span>${escapeHtml(w.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml(w.details||"")}</span>
</div>`}x.innerHTML=l,setTimeout(()=>f(c),500)}catch(i){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(i.message)}</div>`}}function f(b){v("results");const z=b.summary||{},i=z.failed===0&&z.succeeded>0,c=i?"var(--ok-fg)":"#f39c12",l=i?"&#10003;":"&#9888;",w=i?"All Connected!":`${escapeHtml(String(z.succeeded))}/${escapeHtml(String(z.totalSteps))} Steps Succeeded`;let m=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${c};">${l}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${w}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(z.succeeded))} succeeded, ${escapeHtml(String(z.failed))} failed</div>
</div>`;m+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of b.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';m+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span>
</div>`}m+="</div>",$.innerHTML=m,H.style.display=z.failed>0?"block":"none",b.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&I()}async function I(){try{const z=await(await fetch("/api/v1/plex/libraries")).json();if(z.success&&z.libraries?.length>0){let i=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(z.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of z.libraries){const l=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";i+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${l} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}i+="</div></div>",k.innerHTML=i,k.style.display="block"}}catch{}}C?.addEventListener("click",()=>{y.classList.add("show"),k.style.display="none",g()}),wireModal(y,O),E?.addEventListener("click",B),H?.addEventListener("click",B)})(),(function(){const y=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3>
@@ -1000,15 +1013,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="notifications-save" class="btn-accent">Save Settings</button>
</div>
</div>
</div>`);const S=document.getElementById("notifications-modal"),O=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),I=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{I.style.display=B.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const I=B.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function g(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");B.success&&B.history?.length>0?I.innerHTML=B.history.map(C=>{const j=new Date(C.timestamp).toLocaleString();return`
</div>`);const C=document.getElementById("notifications-modal"),O=document.getElementById("manage-notifications"),E=document.getElementById("notifications-save"),M=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const H=document.getElementById(`${k}-enabled`),L=document.getElementById(`${k}-config`);H?.addEventListener("change",()=>{L.style.display=H.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),P=document.getElementById("health-check-config");R?.addEventListener("change",()=>{P.style.opacity=R.checked?"1":"0.5"});async function A(){try{const H=await(await fetch("/api/v1/notifications/config")).json();if(H.success){const L=H.config;document.getElementById("notifications-enabled").checked=L.enabled,document.getElementById("discord-enabled").checked=L.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=L.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=L.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=L.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=L.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=L.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=L.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=L.providers?.email?.enabled?"block":"none",L.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=L.providers.ntfy.serverUrl),L.providers?.email?.host&&(document.getElementById("email-host").value=L.providers.email.host),L.providers?.email?.from&&(document.getElementById("email-from").value=L.providers.email.from),document.getElementById("health-check-enabled").checked=L.healthCheck?.enabled||!1,L.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=L.healthCheck.intervalMinutes),L.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(L.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=L.events?.containerDown!==!1,document.getElementById("event-container-up").checked=L.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=L.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=L.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=L.events?.resourceAlert!==!1}}catch(k){y.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function p(){try{const H=await(await fetch("/api/v1/notifications/history?limit=10")).json(),L=document.getElementById("notification-history");H.success&&H.history?.length>0?L.innerHTML=H.history.map(u=>{const j=new Date(u.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[C.type]||"var(--muted)"}">${C.type==="success"?"\u2713":C.type==="error"?"\u2717":"\u2139"}</span>
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[u.type]||"var(--muted)"}">${u.type==="success"?"\u2713":u.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(C.title)}</div>
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(u.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
</div>
</div>
`}).join(""):I.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function w(k){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();I.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),O?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),E?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const C=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();C.success?(showNotification("Test notification sent!","success",3e3),T()):showNotification(`Test failed: ${C.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function T(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const O=S.dataset.panel;if(!O)return;const E=S.closest(".panel-tabs"),N=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+O);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca Line truncated
`}).join(""):L.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){y.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function T(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},L=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();L.success?(showNotification("Notification settings saved","success",3e3),C.classList.remove("show")):showNotification(`Failed to save: ${L.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function x(k){try{const L=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();L.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${L.error}`,"error",3e3)}catch(H){showNotification(`Error: ${H.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>x("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>x("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>x("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>x("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const H=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();H.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(H.lastCheck).toLocaleString()} (${H.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),O?.addEventListener("click",()=>{C.classList.add("show"),A(),p()}),E?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),H=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const u=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();u.success?(showNotification("Test notification sent!","success",3e3),$()):showNotification(`Test failed: ${u.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(L){showNotification(`Error: ${L.message}`,"error",3e3)}finally{k.textContent=H,k.disabled=!1}});async function $(){try{const H=await(await fetch("/api/v1/notifications/status")).json();if(H.success&&H.lastSent){const L=document.getElementById("last-notification-sent");L&&(L.textContent=`Last sent: ${new Date(H.lastSent).toLocaleString()}`)}}catch{}}wireModal(C,M)})(),(function(){document.addEventListener("click",y=>{const C=y.target.closest(".panel-tab");if(!C)return;const O=C.dataset.panel;if(!O)return;const E=C.closest(".panel-tabs"),M=E.closest(".weather-modal-content");E.querySelectorAll(".panel-tab").forEach(P=>P.classList.remove("active")),C.classList.add("active"),M.querySelectorAll(".panel-section").forEach(P=>P.classList.remove("active"));const R=M.querySelector("#"+O);R&&R.classList.add("active")})})(),(function(){var y=["dashcaddy_site_config","dashca Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle">
@@ -1114,9 +1127,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button>
</div>
</div>
</div>`);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),T=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),C=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),D=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),A=null;z?.addEventListener("click",function(){R.classList.add("show"),C&&(C.style.display="none"),k&&(k.style.display="none"),T&&(T.style.display="none"),A=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=S();var a=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),l=URL.createObjectURL(a),o=document.createElement("a");o.href=l,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(l);var i=Object.keys(n.browserState).length,c=n.themes?Object.keys(n.themes).length:0;C.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),C.style.display="block",C.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",C.style.border="1px solid var(--ok-fg)"}catch($){C.innerHTML="\u274C Export failed: "+escapeHtml($.message),C.style.display="block",C.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",C.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){T.textContent="\u{1F4C4} "+n.name,T.style.display="block",C.style.display="none";try{var a=await n.text(),l=JSON.parse(a);if(E(l)){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(l.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',l.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+l.services.length+" services</span>"),l.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+l.customApps.length+" custom apps</span>"),l.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(l.theme)+"</span>"),l.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(l.userThemes).length+" custom themes</span>"),o+="</div>",B.innerHTML=o,k.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),c=await i.json();if(c.success){A=l;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(l.exportedAt).toLocaleString()+" (v"+escapeHtml(l.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var $ in c.preview.files){var H=c.preview.files[$],U=H.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(H.description)+"</span>"}o+="</div>",c.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=o,k.style.display="block"}else C.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),C.style.display="block",C.style.background="color-mix(in srgb, #f39c12 15%, transparent)",C.style.border="1px solid #f39c12",k.style.display="none"}catch(q){C.innerHTML="\u274CLine truncated
</div>`);var R=document.getElementById("backup-modal"),P=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),p=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),x=document.getElementById("backup-file-input"),$=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),H=document.getElementById("backup-preview-content"),L=document.getElementById("backup-do-restore-btn"),u=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),v=document.getElementById("backup-history-container"),N=document.getElementById("backup-disk-container"),g=document.getElementById("pointintime-container"),D=null;P?.addEventListener("click",function(){R.classList.add("show"),u&&(u.style.display="none"),k&&(k.style.display="none"),$&&($.style.display="none"),D=null}),wireModal(R,A),p?.addEventListener("click",async function(){p.disabled=!0,p.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),t=await e.json();t.browserState=C();var o=new Blob([JSON.stringify(t,null,2)],{type:"application/json"}),r=URL.createObjectURL(o),a=document.createElement("a");a.href=r,a.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(r);var s=Object.keys(t.browserState).length,d=t.themes?Object.keys(t.themes).length:0;u.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(d?" + "+d+" themes":""),u.style.display="block",u.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",u.style.border="1px solid var(--ok-fg)"}catch(S){u.innerHTML="\u274C Export failed: "+escapeHtml(S.message),u.style.display="block",u.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",u.style.border="1px solid var(--bad-fg)"}p.disabled=!1,p.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){x.click()}),x?.addEventListener("change",async function(e){var t=e.target.files[0];if(t){$.textContent="\u{1F4C4} "+t.name,$.style.display="block",u.style.display="none";try{var o=await t.text(),r=JSON.parse(o);if(E(r)){D=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";a+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),a+="</div>",H.innerHTML=a,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),d=await s.json();if(d.success){D=r;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";a+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',a+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var S in d.preview.files){var h=d.preview.files[S],U=h.action==="create"?"\u{1F195}":"\u{1F4DD}";a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(h.description)+"</span>"}a+="</div>",d.preview.serviceCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(a+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',a+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),H.innerHTML=a,k.style.display="block"}else u.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),u.style.display="block",u.style.background="color-mix(in srgb, #f39c12 15%, transparent)",u.style.border="1px solid #f39c12",k.style.display="none"}catch(_){u.innerHTML="\u274CLine truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(n),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),l=await a.json();l.success?(showNotification(e+" restored to "+n,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(l.error||"Unknown"),"error")}catch(o){showNotification("Restore error: "+o.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(t),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await o.json();r.success?(showNotification(e+" restored to "+t,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(a){showNotification("Restore error: "+a.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle">
@@ -1196,11 +1209,11 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),O=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let L=null,w=null;function T(r){if(r===0||!r)return"0 B";const f=1024,v=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+v[t]}function k(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function B(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const n of e.stats)r[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let v='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(r)){const n=e.current||e,a=n.cpu?.percent||0,l=n.memory?.percent||0,o=k(a),i=B(l),c=n.memory?.usage||n.memory?.used||0,$=n.memory?.limit||0,H=n.network?.rxBytes||n.network?.rx||0,U=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;v+=`
</div>`);const y=document.getElementById("stats-modal"),C=document.getElementById("container-stats-btn"),O=document.getElementById("stats-cancel"),E=document.getElementById("stats-refresh-btn"),M=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),P=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),p=document.getElementById("stats-last-update");let T=null,x=null;function $(l){if(l===0||!l)return"0 B";const w=1024,m=["B","KB","MB","GB"],n=Math.floor(Math.log(l)/Math.log(w));return parseFloat((l/Math.pow(w,n)).toFixed(1))+" "+m[n]}function k(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function H(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function L(){try{let l=null,w=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,w=!0,x=e.stats)}catch{}if(!w){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const t of e.stats)l[t.name]={name:t.name,current:{cpu:t.cpu,memory:{percent:t.memory.percent,usage:t.memory.used,limit:t.memory.limit,usageMB:Math.round(t.memory.used/1048576),limitMB:Math.round(t.memory.limit/1048576)},network:{rxBytes:t.network.rx,txBytes:t.network.tx,rxMB:(t.network.rx/1048576).toFixed(1),txMB:(t.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:t.status};x=l}}if(!l||Object.keys(l).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(l)){const t=e.current||e,o=t.cpu?.percent||0,r=t.memory?.percent||0,a=k(o),s=H(r),d=t.memory?.usage||t.memory?.used||0,S=t.memory?.limit||0,h=t.network?.rxBytes||t.network?.rx||0,U=t.network?.txBytes||t.network?.tx||0,_=e.aggregated;m+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-weight: 600; flex: 1;">${e.name||n}</span>
${_?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${_.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
@@ -1208,32 +1221,32 @@ This will replace current configuration, credentials, and data. Containers will
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
<div style="height: 100%; width: ${Math.min(o,100)}%; background: ${a}; border-radius: 3px; transition: width 0.3s;"></div>
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
<span style="font-size: 0.8rem; font-weight: 500; color: ${a}; min-width: 45px; text-align: right;">${o.toFixed(1)}%</span>
</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(l,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
<div style="height: 100%; width: ${Math.min(r,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${l.toFixed(1)}%</span>
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${r.toFixed(1)}%</span>
</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${T(c)} / ${T($)}</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${$(d)} / ${$(S)}</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${T(H)}</span>
<span style="color: #3498db;">\u2193 ${$(h)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${T(U)}</span>
<span style="color: #e74c3c;">\u2191 ${$(U)}</span>
</div>
</div>
</div>
</div>`}v+="</div>",R.innerHTML=v,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function C(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[v,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||v}</div>
</div>`}m+="</div>",R.innerHTML=m,p.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function u(){if(!P)return;const l=x;if(!l||Object.keys(l).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let w='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,n]of Object.entries(l)){const e=n.aggregated;e&&(w+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||m}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1241,27 +1254,27 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}f+="</div>",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let v=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(v=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const n=Object.entries(r).map(([o,i])=>{const c=t[o]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${o}">
<td style="font-weight: 600; padding: 8px;">${i.name||o}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
</div>`)}w+="</div>",P.innerHTML=w}async function j(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=x;if(!l||Object.keys(l).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let w=!1;try{w=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{w=!1}let m=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(m=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const t=Object.entries(l).map(([a,s])=>{const d=n[a]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${a}">
<td style="font-weight: 600; padding: 8px;">${s.name||a}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${d.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${d.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${d.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${d.autoRestart?"checked":""} ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${o}" data-name="${i.name||o}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
<button class="alert-test-btn btn-xs" data-container="${a}" data-name="${s.name||a}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td>
</tr>
`}).join(""),a=v.map(o=>{const i=new Date(o.timestamp).toLocaleString(),c=o.notified?"\u2713":"\u2014";return`
`}).join(""),o=m.map(a=>{const s=new Date(a.timestamp).toLocaleString(),d=a.notified?"\u2713":"\u2014";return`
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
<td style="padding: 6px 8px; font-weight: 500;">${o.containerName||o.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${o.metric||o.type}</td>
<td style="padding: 6px 8px;">${typeof o.value=="number"?o.value.toFixed(1):o.value}${o.metric==="disk"?" MB/s":"%"}</td>
<td style="padding: 6px 8px; text-align: center;">${c}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${o.autoRestartTriggered?"#f39c12":"var(--muted)"};">${o.autoRestartTriggered?"\u21BB":""}</td>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
<td style="padding: 6px 8px; font-weight: 500;">${a.containerName||a.containerId}</td>
<td style="padding: 6px 8px; text-transform: capitalize;">${a.metric||a.type}</td>
<td style="padding: 6px 8px;">${typeof a.value=="number"?a.value.toFixed(1):a.value}${a.metric==="disk"?" MB/s":"%"}</td>
<td style="padding: 6px 8px; text-align: center;">${d}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${a.autoRestartTriggered?"#f39c12":"var(--muted)"};">${a.autoRestartTriggered?"\u21BB":""}</td>
</tr>
`}).join(""),l=f?`
`}).join(""),r=w?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1279,7 +1292,7 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${n}</tbody>
<tbody>${t}</tbody>
</table>
</div>
<div style="margin-top: 12px; display: flex; justify-content: flex-end;">
@@ -1292,11 +1305,11 @@ This will replace current configuration, credentials, and data. Containers will
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
</div>
`;P.innerHTML=`
${l}
`;A.innerHTML=`
${r}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
${a?`
${o?`
<div style="overflow-x: auto;">
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
<thead>
@@ -1309,26 +1322,26 @@ This will replace current configuration, credentials, and data. Containers will
<th style="padding: 6px 8px;"></th>
</tr>
</thead>
<tbody>${a}</tbody>
<tbody>${o}</tbody>
</table>
</div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const o={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;o[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:o})})).json(),$=document.getElementById("save-all-alerts");$.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{$.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",o=>{o.preventDefault(),h.classList.remove("show"),D(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.textContent;o.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${o.dataset.container}/test`,{method:"POST"}),o.textContent="\u2705",showNotification("Test alert sent for "+o.dataset.name,"success",3e3)}catch{o.textContent="\u274C"}setTimeout(()=>{o.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),D(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){L&&clearInterval(L),N?.checked&&(L=setInterval(I,DC.POLL.STATS))}function D(){L&&(clearInterval(L),L=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),O?.addEventListener("click",()=>{h.classList.remove("show"),D()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),D())}),E?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():D()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",C),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),A=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function u(r,f,v,t,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const n=r.map(f).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=Math.max(...n,1),l=Math.min(...n,0),o=a-l||1,i=600,c=80,$=4,H=(i-$*2)/Math.max(n.length-1,1),U=n.map((G,J)=>{const X=$+J*H,Q=c-$-(G-l)/o*(c-$*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],F=n.reduce((G,J)=>G+J,0)/n.length;return`
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const a={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const d=s.dataset.container;a[d]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:a})})).json(),S=document.getElementById("save-all-alerts");S.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{S.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",a=>{a.preventDefault(),y.classList.remove("show"),N(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.textContent;a.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${a.dataset.container}/test`,{method:"POST"}),a.textContent="\u2705",showNotification("Test alert sent for "+a.dataset.name,"success",3e3)}catch{a.textContent="\u274C"}setTimeout(()=>{a.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{y.classList.remove("show"),N(),typeof openLicenseModal=="function"&&openLicenseModal()})}function v(){T&&clearInterval(T),M?.checked&&(T=setInterval(L,DC.POLL.STATS))}function N(){T&&(clearInterval(T),T=null)}C?.addEventListener("click",()=>{y.classList.add("show"),L(),v()}),O?.addEventListener("click",()=>{y.classList.remove("show"),N()}),y?.addEventListener("click",l=>{l.target===y&&(y.classList.remove("show"),N())}),E?.addEventListener("click",L),M?.addEventListener("change",()=>{M.checked?v():N()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",u),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const g=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),B=document.querySelectorAll(".stats-range-btn");let f="1h";function I(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function b(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function z(l,w,m,n,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const t=l.map(w).filter(W=>W!=null);if(t.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const o=Math.max(...t,1),r=Math.min(...t,0),a=o-r||1,s=600,d=80,S=4,h=(s-S*2)/Math.max(t.length-1,1),U=t.map((W,J)=>{const X=S+J*h,Q=d-S-(W-r)/a*(d-S*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=t[t.length-1],F=t.reduce((W,J)=>W+J,0)/t.length;return`
<div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(n)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
</div>
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${v}" stroke-width="1.5" points="${U}" />
<svg viewBox="0 0 ${s} ${d}" preserveAspectRatio="none" style="width: 100%; height: ${d}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${m}" stroke-width="1.5" points="${U}" />
</svg>
</div>
`}function s(){if(!x)return;const r=w||{},f=x.value,v=Object.entries(r);if(v.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=v.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),f&&r[f]&&(x.value=f)}async function d(){if(!A||!x)return;const r=x.value;if(!r){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),v=f-b(m);A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${v}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],a=e.tier||"raw";if(n.length===0){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${p(a)}.</div>`;return}const l=a==="raw",o=l?U=>U.cpu?.percent:U=>U.cpu?.avg,i=l?U=>U.memory?.percent:U=>U.memory?.avgPercent,c=l?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,$=l?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let H=`
`}function i(){if(!g)return;const l=x||{},w=g.value,m=Object.entries(l);if(m.length===0){g.innerHTML='<option value="">No containers</option>';return}g.innerHTML=m.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),w&&l[w]&&(g.value=w)}async function c(){if(!D||!g)return;const l=g.value;if(!l){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const w=Date.now(),m=w-I(f);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${m}&endTime=${w}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const t=e.samples||[],o=e.tier||"raw";if(t.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${f}. Tier: ${b(o)}.</div>`;return}const r=o==="raw",a=r?U=>U.cpu?.percent:U=>U.cpu?.avg,s=r?U=>U.memory?.percent:U=>U.memory?.avgPercent,d=r?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,S=r?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let h=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${n.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(v).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
${t.length} samples \xB7 ${escapeHtml(b(o))} \xB7 ${new Date(m).toLocaleString()} \u2192 ${new Date(w).toLocaleString()}
</div>
`;H+=u(n,o,"#2ecc71","CPU","%"),H+=u(n,i,"#3498db","Memory","%"),H+=u(n,c,"#9b59b6","Network RX"," MB"),H+=u(n,$,"#e67e22","Network TX"," MB"),A.innerHTML=H}catch(t){A.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,d()})}),x?.addEventListener("change",d),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),d()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
`;h+=z(t,a,"#2ecc71","CPU","%"),h+=z(t,s,"#3498db","Memory","%"),h+=z(t,d,"#9b59b6","Network RX"," MB"),h+=z(t,S,"#e67e22","Network TX"," MB"),D.innerHTML=h}catch(n){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}B.forEach(l=>{l.addEventListener("click",()=>{B.forEach(w=>w.classList.remove("active")),l.classList.add("active"),f=l.dataset.range,c()})}),g?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{i(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle">
@@ -1361,6 +1374,34 @@ This will replace current configuration, credentials, and data. Containers will
<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</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;">\u{1F30D} 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>
@@ -1414,24 +1455,24 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),O=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),T=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function C(y){const m={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${m[y]||"var(--muted)"}20; color: ${m[y]||"var(--muted)"};">${y}</span>`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const b=Object.values(m.status);let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',p+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',p+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',p+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const u of b){const s=u.status==="up",d=s?"var(--dot-ok)":"var(--dot-bad)",r=u.uptime?.["24h"]??"-",f=u.uptime?.["7d"]??"-",v=u.avgResponseTime!=null?Math.round(u.avgResponseTime)+"ms":"-",t=u.timestamp?timeAgo(u.timestamp):"-";p+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(u.serviceId)}">`,p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${d}; margin-right: 6px;"></span>${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px; color: ${typeof r=="number"?I(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,p+=`<td style="padding: 8px; color: ${typeof f=="number"?I(f):"var(--muted)"};">${typeof f=="number"?f.toFixed(1)+"%":f}</td>`,p+=`<td style="padding: 8px;">${v}</td>`,p+=`<td style="padding: 8px; color: var(--muted);">${t}</td>`,p+="</tr>",p+=`<tr id="health-detail-${escapeHtml(u.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}p+="</table>",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(u=>{u.addEventListener("click",async()=>{const s=u.dataset.healthId,d=document.getElementById("health-detail-"+s);if(d){if(d.style.display!=="none"){d.style.display="none";return}d.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const v=f.stats,t=v.responseTime||{};d.querySelector("td").innerHTML=`
</div>`);const y=document.getElementById("health-modal"),C=document.getElementById("health-check-btn"),O=document.getElementById("health-cancel"),E=document.getElementById("health-refresh-btn"),M=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),P=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),p=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),x=document.getElementById("health-form-title"),$=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),H="dashcaddy-health-settings",L={retentionDays:30,pollingInterval:30,diskUsageThreshold:80},u=document.getElementById("health-global-save"),j=document.getElementById("health-global-reset"),v=document.getElementById("health-global-status"),N=document.getElementById("health-setting-retention"),g=document.getElementById("health-setting-interval"),D=document.getElementById("health-setting-disk-threshold");function B(){try{const t=safeGet(H),o=t?JSON.parse(t):{};return Object.assign({},L,o)}catch{return Object.assign({},L)}}function f(){const t=B();N&&(N.value=t.retentionDays),g&&(g.value=t.pollingInterval),D&&(D.value=t.diskUsageThreshold)}function I(){const t={retentionDays:Math.max(1,Math.min(3650,parseInt(N?.value)||L.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(g?.value)||L.pollingInterval)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(D?.value)||L.diskUsageThreshold))};try{safeSet(H,JSON.stringify(t)),f(),v&&(v.textContent="Saved \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(o){v&&(v.textContent="Save failed",v.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+o.message,"error")}}function b(){try{safeSet(H,JSON.stringify(L))}catch{}f(),v&&(v.textContent="Reset to defaults \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500))}f(),u?.addEventListener("click",I),j?.addEventListener("click",b);let z=null;function i(t){return t>=99.9?"var(--ok-fg)":t>=95?"#f39c12":"var(--bad-fg)"}function c(t){const o={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${o[t]||"var(--muted)"}20; color: ${o[t]||"var(--muted)"};">${t}</span>`}async function l(){try{const o=await(await fetch("/api/v1/health-checks/status")).json();if(!o.success||!o.status||Object.keys(o.status).length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const r=Object.values(o.status);let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',a+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',a+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',a+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const s of r){const d=s.status==="up",S=d?"var(--dot-ok)":"var(--dot-bad)",h=s.uptime?.["24h"]??"-",U=s.uptime?.["7d"]??"-",_=s.avgResponseTime!=null?Math.round(s.avgResponseTime)+"ms":"-",F=s.timestamp?timeAgo(s.timestamp):"-";a+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(s.serviceId)}">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(s.name||s.serviceId)}</td>`,a+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${S}; margin-right: 6px;"></span>${d?"Up":"Down"}</td>`,a+=`<td style="padding: 8px; color: ${typeof h=="number"?i(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,a+=`<td style="padding: 8px; color: ${typeof U=="number"?i(U):"var(--muted)"};">${typeof U=="number"?U.toFixed(1)+"%":U}</td>`,a+=`<td style="padding: 8px;">${_}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${F}</td>`,a+="</tr>",a+=`<tr id="health-detail-${escapeHtml(s.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}a+="</table>",M.innerHTML=a,A.textContent="Updated "+new Date().toLocaleTimeString(),M.querySelectorAll("tr[data-health-id]").forEach(s=>{s.addEventListener("click",async()=>{const d=s.dataset.healthId,S=document.getElementById("health-detail-"+d);if(S){if(S.style.display!=="none"){S.style.display="none";return}S.style.display="";try{const U=await(await fetch(`/api/v1/health-checks/${d}/Line truncated
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${v.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${I(v.uptime||0)};">${(v.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(t.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(t.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(t.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${v.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${v.downChecks||0}</strong></div>
</div>`}else d.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){d.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(y.message)}</div>`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let u="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){u+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+s.length+")</h4>";for(const r of s)u+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${_.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${i(_.uptime||0)};">${(_.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(F.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(F.p95||0)}ms / ${Math.round(F.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(F.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(F.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${_.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${_.downChecks||0}</strong></div>
</div>`}else S.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(h){S.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}})})}catch(t){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(t.message)}</div>`}}async function w(){try{const[t,o]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),r=await t.json(),a=await o.json();let s="";const d=r.success&&r.incidents?r.incidents:[];if(d.length>0){s+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+d.length+")</h4>";for(const h of d)s+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
<span>${C(r.severity)}</span>
<span style="font-weight: 500;">${escapeHtml(h.serviceId)}</span>
<span>${c(h.severity)}</span>
</div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
</div>`;u+="</div>"}else u+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const d=p.success&&p.history?p.history:[];if(d.length>0){u+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',u+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of d){const f=r.status==="resolved",v=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,u+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,u+=`<td style="padding: 6px;">${C(r.severity)}</td>`,u+=`<td style="padding: 6px;"><span style="color: ${f?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,u+=`<td style="padding: 6px;">${v}</td>`,u+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,u+="</tr>"}u+="</table>"}R.innerHTML=u||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function D(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const u of b){const s=u.status==="up";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,p+=`<td style="padding: 8px; color: ${s?"var(--ok-fg)":"var(--bad-fg)"};">${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px;">${u.sla?.target?u.sla.target+"%":"-"}</td>`,p+='<td style="padding: 8px; text-align: right;">',p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,p+="</td></tr>"}p+="</table>",z.innerHTML=p}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}function x(y,m,b,p,u,s,d){B=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=u||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=d||5e3,L.style.display="",g.style.display="none"}function A(){L.style.display="none",g.style.display="",B=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),T?.addEventListener("click",A),k?.addEventListener("click",async()=>{const y=B||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(u=>parseInt(u.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(h.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(h.createdAt)} \xB7 ${h.occurrences||1} occurrence(s)</div>
</div>`;s+="</div>"}else s+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const S=a.success&&a.history?a.history:[];if(S.length>0){s+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',s+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',s+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const h of S){const U=h.status==="resolved",_=U&&h.duration?h.duration<6e4?Math.round(h.duration/1e3)+"s":Math.round(h.duration/6e4)+"m":"-";s+='<tr style="border-bottom: 1px solid var(--border);">',s+=`<td style="padding: 6px;">${escapeHtml(h.serviceId)}</td>`,s+=`<td style="padding: 6px;">${escapeHtml(h.type)}</td>`,s+=`<td style="padding: 6px;">${c(h.severity)}</td>`,s+=`<td style="padding: 6px;"><span style="color: ${U?"var(--ok-fg)":"var(--bad-fg)"};">${h.status}</span></td>`,s+=`<td style="padding: 6px;">${_}</td>`,s+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(h.createdAt)}</td>`,s+="</tr>"}s+="</table>"}R.innerHTML=s||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(t){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(t.message)}</div>`}}async function m(){try{const o=await(await fetch("/api/v1/health-checks/status")).json(),r=o.success&&o.status?Object.values(o.status):[];if(r.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const s of r){const d=s.status==="up";a+='<tr style="border-bottom: 1px solid var(--border);">',a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(s.name||s.serviceId)}</td>`,a+=`<td style="padding: 8px; color: ${d?"var(--ok-fg)":"var(--bad-fg)"};">${d?"Up":"Down"}</td>`,a+=`<td style="padding: 8px;">${s.sla?.target?s.sla.target+"%":"-"}</td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(s.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,a+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(s.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,a+="</td></tr>"}a+="</table>",P.innerHTML=a}catch(t){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(t.message)}</div>`}}function n(t,o,r,a,s,d,S){z=t||null,x.textContent=t?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=t||"",document.getElementById("health-form-id").disabled=!!t,document.getElementById("health-form-name").value=o||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=a||1e4,document.getElementById("health-form-codes").value=s||"200",document.getElementById("health-form-sla").value=d||99.9,document.getElementById("health-form-slow").value=S||5e3,T.style.display="",p.style.display="none"}function e(){T.style.display="none",p.style.display="",z=null}p?.addEventListener("click",()=>n("","","",1e4,"200",99.9,5e3)),$?.addEventListener("click",e),k?.addEventListener("click",async()=>{const t=z||document.getElementById("health-form-id").value.trim();if(!t)return showNotification("Service ID is required","warning");const o=document.getElementById("health-form-url").value.trim();if(!o)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(s=>parseInt(s.trim())).filter(Boolean),a={name:document.getElementById("health-form-name").value.trim()||t,url:o,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle">
@@ -1505,17 +1546,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),O=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const o of e){const i=(()=>{const c=window.APPS||[];for(const $ of c)if($.containerId===o.containerId||$.name===o.containerName||$.id===o.containerName)return $.id;return o.containerName})();n+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.containerName)}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(o.imageName)}</td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.currentDigest)}</code></td>`,n+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(o.latestDigest)}</code></td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button class="update-now-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,n+=`<button class="rollback-btn" data-id="${escapeHtml(o.containerId)}" data-name="${escapeHtml(o.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,n+="</td></tr>"}n+="</table>",N.innerHTML=n,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),l=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),l&&e.length>0&&(l.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){o.textContent="Updating...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(H.success)o.textContent="Done!",o.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Update failed")}catch($){o.textContent="Failed",o.style.color="var(--bad-fg)",showNotification("Update error: "+$.message,"error"),setTimeout(()=>{o.textContent="Update",o.disabled=!1,o.style.color="",o.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(o=>{o.addEventListener("click",async()=>{const i=o.dataset.id,c=o.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){o.textContent="Rolling back...",o.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(H.success)o.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Rollback failed")}catch($){o.textContent="Failed",showNotification("Rollback error: "+$.message,"error"),setTimeout(()=>{o.textContent="Rollback",o.disabled=!1},3e3)}}})})}catch(v){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}async function L(){const v=window._pendingUpdates||[];if(!v.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${v.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,n=0;for(const a of v)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:n++}catch{n++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml($)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
</div>`);const y=document.getElementById("updates-modal"),C=document.getElementById("updates-btn"),O=document.getElementById("updates-cancel"),E=document.getElementById("updates-check-btn"),M=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),P=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function p(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const e=n.updates||[];if(e.length===0){M.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let t='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const a of e){const s=(()=>{const d=window.APPS||[];for(const S of d)if(S.containerId===a.containerId||S.name===a.containerName||S.id===a.containerName)return S.id;return a.containerName})();t+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,t+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,t+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,t+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.currentDigest)}</code></td>`,t+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.latestDigest)}</code></td>`,t+='<td style="padding: 8px; text-align: right;">',t+=`<button class="update-now-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,t+=`<button class="rollback-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,t+="</td></tr>"}t+="</table>",M.innerHTML=t,A.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,M.querySelectorAll(".update-now-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.dataset.id,d=a.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){a.textContent="Updating...",a.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(h.success)a.textContent="Done!",a.style.background="var(--ok-fg)",setTimeout(()=>p(),2e3);else throw new Error(h.error||"Update failed")}catch(S){a.textContent="Failed",a.style.color="var(--bad-fg)",showNotification("Update error: "+S.message,"error"),setTimeout(()=>{a.textContent="Update",a.disabled=!1,a.style.color="",a.style.background=""},3e3)}}})}),M.querySelectorAll(".rollback-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.dataset.id,d=a.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){a.textContent="Rolling back...",a.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(h.success)a.textContent="Rolled back!",setTimeout(()=>p(),2e3);else throw new Error(h.error||"Rollback failed")}catch(S){a.textContent="Failed",showNotification("Rollback error: "+S.message,"error"),setTimeout(()=>{a.textContent="Rollback",a.disabled=!1},3e3)}}})})}catch(m){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}async function T(){const m=window._pendingUpdates||[];if(!m.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${m.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,t=0;for(const o of m)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:t++}catch{t++}n.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(S)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${U?"":" selected"}>Disabled</option>
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
</select></td>`,o+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml($)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,o+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml($)}"${q?" checked":""} /></td>`,o+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,o+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml($)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,o+="</tr>"}o+="</table>",z.innerHTML=o,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,$=i.closest("tr"),H=$.querySelector(".auto-schedule").value,U=$.querySelector(".auto-rollback").checked,q=$.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!H,schedule:H||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(F){i.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(v){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),C=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),D=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),A=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(v,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=v)}async function u(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;B.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function s(v){v||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",C.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const n=document.getElementById("updates-btn");if(n&&!n.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",n.style.position="relative",n.appendChild(l)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(l)}}else I.style.display="none",C.style.display="none",await u(),v||p("You are running the latest version.","success");v||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){v||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function d(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;D.textContent="Updating...",D.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),D.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(v){throw p("Update failed: "+v.message,"error"),D.textContent="Update Now",D.disabled=!1,v}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
</select></td>`,a+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(S)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,a+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(S)}"${_?" checked":""} /></td>`,a+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${W}</td>`,a+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(S)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,a+="</tr>"}a+="</table>",P.innerHTML=a,P.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const d=s.dataset.id,S=s.closest("tr"),h=S.querySelector(".auto-schedule").value,U=S.querySelector(".auto-rollback").checked,_=S.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const W=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!h,schedule:h||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(W.success)s.textContent="\u2713 Saved";else throw new Error(W.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(m){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}const H=document.getElementById("dashcaddy-current-version"),L=document.getElementById("dashcaddy-update-badge"),u=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),v=document.getElementById("dashcaddy-changelog"),N=document.getElementById("dashcaddy-apply-btn"),g=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),B=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let I=null;function b(m,n){B&&(B.style.display="block",B.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",B.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",B.textContent=m)}async function z(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;H.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{H.textContent="Unable to fetch version"}}async function i(m){m||(g.textContent="Checking...",g.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(I=e,e.success&&e.available&&e.remote){L.style.display="",u.style.display="",j.textContent="v"+e.remote.version,v.textContent=e.remote.changelog||"No changelog available.";const t=document.getElementById("updates-btn");if(t&&!t.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",t.style.position="relative",t.appendChild(r)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(r)}}else L.style.display="none",u.style.display="none",await z(),m||b("You are running the latest version.","success");m||(g.textContent="Check for Updates",g.disabled=!1)}catch(n){m||(b("Failed to check: "+n.message,"error"),g.textContent="Check for Updates",g.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;N.textContent="Updating...",N.disabled=!0,b("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return b("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),N.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(m){throw b("Update failed: "+m.message,"error"),N.textContent="Update Now",N.disabled=!1,m}}async function l(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';t+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(`
`)+`
Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification("Invalid version: "+n,"error");return}if(!confirm("Rollback DashCaddy to v"+n+"? The container will restart."))return;p("Rolling back to v"+n+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:n})})).json();if(l.success)p("Rollback to v"+n+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(v){p("Rollback failed: "+v.message,"error")}}x?.addEventListener("click",()=>s(!1)),D?.addEventListener("click",()=>d().catch(()=>{})),A?.addEventListener("click",f),E?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,O),window.openUpdateModal=function(v){h?.classList.add("show"),g().then(()=>{if(!v)return;const t=N.querySelector(`[data-app-id="${v}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",T),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),r(),b||s(!0)}),window.dcApplyUpdate=d,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
Enter version to rollback to:`);if(!t)return;if(!e.includes(t)){showNotification("Invalid version: "+t,"error");return}if(!confirm("Rollback DashCaddy to v"+t+"? The container will restart."))return;b("Rolling back to v"+t+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:t})})).json();if(r.success)b("Rollback to v"+t+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(m){b("Rollback failed: "+m.message,"error")}}g?.addEventListener("click",()=>i(!1)),N?.addEventListener("click",()=>c().catch(()=>{})),D?.addEventListener("click",w),E?.addEventListener("click",x),C?.addEventListener("click",()=>{y?.classList.add("show"),p()}),wireModal(y,O),window.openUpdateModal=function(m){y?.classList.add("show"),p().then(()=>{if(!m)return;const n=M.querySelector(`[data-app-id="${m}"]`);n&&(n.scrollIntoView({behavior:"smooth",block:"center"}),n.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{n.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",$),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{z(),l(),I||i(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=i,setTimeout(()=>i(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1564,7 +1605,7 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
<button id="dr-close">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),O=document.getElementById("dr-close");function E(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,L)).toFixed(1)+" "+g[L]}async function N(){const P=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=T.name==="buildkit"||T.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(T.name)}">${escapeHtml(T.name.length>40?T.name.substring(0,37)+"...":T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-vol-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete volume "${T.dataset.name}"? Data will be lost.`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(T.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const T of L){const k=["bridge","host","none"].includes(T.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(T.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(T.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${T.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',k||(w+=`<button class="dr-net-del" data-id="${escapeHtml(T.id)}" data-name="${escapeHtml(T.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-net-del").forEach(T=>{T.addEventListener("click",async()=>{if(confirm(`Delete network "${T.dataset.name}"?`)){T.textContent="...",T.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(T.dataset.id)}`),R()}catch(k){showNotification("Delete failed: "+k.message,"error"),T.textContent="Delete",T.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),L=P.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:g.value}),P.value="",showNotification(`Network "${L}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
</div>`);const y=document.getElementById("docker-resources-modal"),C=document.getElementById("docker-resources-btn"),O=document.getElementById("dr-close");function E(A){if(!A||A===0)return"0 B";const p=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,T)).toFixed(1)+" "+p[T]}async function M(){const A=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of T){const k=$.name==="buildkit"||$.name.length===64;x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml($.name)}">${escapeHtml($.name.length>40?$.name.substring(0,37)+"...":$.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-vol-del" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",A.innerHTML=x,A.querySelectorAll(".dr-vol-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete volume "${$.dataset.name}"? Data will be lost.`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent($.dataset.name)}?force=true`),M()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(p){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),p=A.value.trim();if(!p){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:p}),A.value="",showNotification(`Volume "${p}" created`,"success"),M()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function R(){const A=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of T){const k=["bridge","host","none"].includes($.name);x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml($.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+=`<td style="padding: 6px; text-align: center;">${$.containers}</td>`,x+='<td style="padding: 6px; text-align: right;">',k||(x+=`<button class="dr-net-del" data-id="${escapeHtml($.id)}" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",A.innerHTML=x,A.querySelectorAll(".dr-net-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete network "${$.dataset.name}"?`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent($.dataset.id)}`),R()}catch(k){showNotification("Delete failed: "+k.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(p){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),p=document.getElementById("dr-net-driver"),T=A.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:p.value}),A.value="",showNotification(`Network "${T}" created`,"success"),R()}catch(x){showNotification("Create failed: "+x.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1605,8 +1646,8 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),O=document.getElementById("compose-cancel");wireModal(h,O);let E=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),L=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});E=w,E.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=L,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),g+="</div>",!L.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(T=>`${T.host}:${T.container}`).join(", ")}</div>`),L.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const T=[];L.resources.cpus&&T.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&T.push(`Mem: ${L.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${T.join(", ")}</div>`}g+="</div>"}g+="</div>",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of g.results){const T=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${T} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
</div>`);const y=document.getElementById("compose-import-modal"),C=document.getElementById("compose-import-btn"),O=document.getElementById("compose-cancel");wireModal(y,O);let E=null;function M(P){document.getElementById("compose-step-paste").style.display=P==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=P==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=P==="progress"?"":"none"}C?.addEventListener("click",()=>{M("paste"),E=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",y?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",P=>{const A=P.target.files[0];if(!A)return;const p=new FileReader;p.onload=()=>{document.getElementById("compose-yaml").value=p.result},p.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const P=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!P){showNotification("Paste a docker-compose.yml","warning");return}const p=document.getElementById("compose-parse-btn"),T=p.textContent;p.textContent="Parsing...",p.disabled=!0;try{const x=await postJSON("/api/v1/apps/import-compose",{yaml:P,stackName:A});E=x,E.stackName=A,R(x),M("preview")}catch(x){showNotification("Parse failed: "+x.message,"error")}finally{p.textContent=T,p.disabled=!1}});function R(P){const A=document.getElementById("compose-preview-content");let p="";P.networks&&P.networks.length>0&&(p+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${P.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),P.volumes&&P.volumes.length>0&&(p+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${P.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),p+=`<div style="font-weight: 600; margin-bottom: 8px;">${P.services.length} service(s)</div>`,p+='<div class="scroll-container" style="max-height: 350px;">';for(const T of P.services){const x=T.skip?"var(--bad-fg)":"var(--border)";if(p+=`<div style="padding: 10px 14px; border: 1px solid ${x}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,p+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(p+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),p+="</div>",!T.skip&&(p+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(p+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map($=>`${$.host}:${$.container}`).join(", ")}</div>`),T.volumes?.length&&(p+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(p+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(p+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const $=[];T.resources.cpus&&$.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&$.push(`Mem: ${T.resources.memory}MB`),p+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${$.join(", ")}</div>`}p+="</div>"}p+="</div>",A.innerHTML=p}document.getElementById("compose-back-btn")?.addEventListener("click",()=>M("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!E)return;const P=document.getElementById("compose-deploy-btn");P.textContent="Deploying...",P.disabled=!0,M("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const p=await postJSON("/api/v1/apps/deploy-compose",{services:E.services,networks:E.networks,stackName:E.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(p.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const x of p.results){const $=x.status==="deployed"||x.status==="created"?"\u2705":x.status==="exists"?"\u26A1":x.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${$} <strong>${escapeHtml(x.name)}</strong> (${x.type}) \u2014 ${escapeHtml(x.status)}`,x.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(x.error)}</span>`),x.subdomain&&(T+=` \u2192 <code>${escapeHtml(x.subdomain)}</code>`),x.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(x.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>M("paste"))}finally{P.textContent="Deploy All",P.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1614,11 +1655,11 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
<button id="exec-close">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),O=document.getElementById("exec-close");let E=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(E){try{E.dispose()}catch{}E=null}R=null,S.innerHTML=""}function P(g,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,E.loadAddon(R)),E.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),R){const k=R.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){E.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r
`);return}if(B.type==="error"){E.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){E.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},N.onclose=()=>{E&&E.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{E&&E.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),E.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const T=()=>{R&&R.fit()};window.addEventListener("resize",T),h._resizeHandler=T}O?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
</div>`);const y=document.getElementById("exec-modal"),C=document.getElementById("exec-terminal"),O=document.getElementById("exec-close");let E=null,M=null,R=null;function P(){if(M){try{M.close()}catch{}M=null}if(E){try{E.dispose()}catch{}E=null}R=null,C.innerHTML=""}function A(p,T){if(P(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||p}`,y?.classList.add("show"),typeof Terminal>"u"){C.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}E=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,E.loadAddon(R)),E.open(C),R&&setTimeout(()=>R.fit(),50);const x=location.protocol==="https:"?"wss:":"ws:";M=new WebSocket(`${x}//${location.host}/ws/exec/${encodeURIComponent(p)}`),M.binaryType="arraybuffer",M.onopen=()=>{if(E.writeln("\x1B[32mConnecting...\x1B[0m"),R){const k=R.proposeDimensions();k&&M.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},M.onmessage=k=>{if(typeof k.data=="string"){try{const H=JSON.parse(k.data);if(H.type==="connected"){E.writeln(`\x1B[32mConnected (${H.shell})\x1B[0m\r
`);return}if(H.type==="error"){E.writeln(`\x1B[31mError: ${H.message}\x1B[0m`);return}if(H.type==="exit"){E.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}E.write(k.data)}else E.write(new Uint8Array(k.data))},M.onclose=()=>{E&&E.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},M.onerror=()=>{E&&E.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},E.onData(k=>{M&&M.readyState===WebSocket.OPEN&&M.send(k)}),E.onResize(({cols:k,rows:H})=>{M&&M.readyState===WebSocket.OPEN&&M.send(JSON.stringify({type:"resize",cols:k,rows:H}))});const $=()=>{R&&R.fit()};window.addEventListener("resize",$),y._resizeHandler=$}O?.addEventListener("click",()=>{P(),y._resizeHandler&&window.removeEventListener("resize",y._resizeHandler),y?.classList.remove("show")}),y?.addEventListener("click",p=>{p.target===y&&(P(),y._resizeHandler&&window.removeEventListener("resize",y._resizeHandler),y?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle">
@@ -1654,7 +1695,7 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
<button id="audit-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),O=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const L=50;async function w(T){try{T||(g=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=R.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${g}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const C=await(await fetch(B)).json(),j=C.success&&C.entries?C.entries:[];if(j.length===0&&!T){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let M="";T||(M='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',M+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const D of j){const x=D.outcome==="success";M+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',M+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(D.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(D.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(D.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(D.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,M+="</tr>",D.details&&Object.keys(D.details).length>0&&(M+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(D.details,null,2))}</pre></td></tr>`)}if(!T)M+="</table>",z.innerHTML=M;else{const D=z.querySelector("table");D&&D.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(D=>{D.dataset.wired||(D.dataset.wired="true",D.addEventListener("click",()=>{const x=D.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,O),E?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?w(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(T){showNotification("Error: "+T.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
</div>`);const y=document.getElementById("audit-modal"),C=document.getElementById("audit-log-btn"),O=document.getElementById("audit-cancel"),E=document.getElementById("audit-refresh-btn"),M=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),P=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let p=0;const T=50;async function x($){try{$||(p=0,P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=R.value;let H=`/api/v1/audit-logs?limit=${T}&offset=${p}`;k&&(H+=`&action=${encodeURIComponent(k)}`);const u=await(await fetch(H)).json(),j=u.success&&u.entries?u.entries:[];if(j.length===0&&!$){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let v="";$||(v='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const N of j){const g=N.outcome==="success";v+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',v+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(N.timestamp)}</td>`,v+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(N.ip||"-")}</td>`,v+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(N.action||"-")}</td>`,v+=`<td style="padding: 6px;">${escapeHtml(N.resource||"-")}</td>`,v+=`<td style="padding: 6px;"><span style="color: ${g?"var(--ok-fg)":"var(--bad-fg)"};">${g?"\u2713":"\u2717"}</span></td>`,v+="</tr>",N.details&&Object.keys(N.details).length>0&&(v+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(N.details,null,2))}</pre></td></tr>`)}if(!$)v+="</table>",P.innerHTML=v;else{const N=P.querySelector("table");N&&N.insertAdjacentHTML("beforeend",v)}p+=j.length,A.style.display=j.length>=T?"":"none",P.querySelectorAll(".audit-row").forEach(N=>{N.dataset.wired||(N.dataset.wired="true",N.addEventListener("click",()=>{const g=N.nextElementSibling;g&&g.classList.contains("audit-detail")&&(g.style.display=g.style.display==="none"?"":"none")}))})}catch(k){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}C?.addEventListener("click",()=>{y?.classList.add("show"),x(!1)}),wireModal(y,O),E?.addEventListener("click",()=>x(!1)),R?.addEventListener("change",()=>x(!1)),A?.addEventListener("click",()=>x(!0)),M?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?x(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch($){showNotification("Error: "+$.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle">
@@ -1733,39 +1774,39 @@ Enter version to rollback to:`);if(!n)return;if(!e.includes(n)){showNotification
<button id="sec-cancel">Close</button>
</div>
</div>
</div>`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),O=document.getElementById("sec-cancel"),E=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;E.forEach(s=>{s.addEventListener("click",()=>{E.forEach(d=>d.classList.toggle("active",d===s)),N.forEach(d=>d.style.display=d.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&T(),s.dataset.tab==="events"&&D(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),T(),L()}),O.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const d=JSON.parse(s.data);R.unshift(d),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&T()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&L()});async function T(){try{const s=new Date(Date.now()-864e5).toISOString(),[d,r,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),v=(await d.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${v.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${v.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${v.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",v.top_actors||[]),k("sec-top-targets",v.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function k(s,d){const r=document.getElementById(s);if(!d.length){r.innerHTML='<div class="panel-empty">No data</div>';return}r.innerHTML='<table style="width:100%;font-size:0.85rem;">'+d.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${p(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const B=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),C=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[B,I,C].forEach(s=>s.addEventListener("change",D)),j.addEventListener("input",u(D,250)),M.addEventListener("click",D);async function D(){try{const s=new URLSearchParams;s.set("limit","200"),B.value&&s.set("source_type",B.value),I.value&&s.set("severity",I.value),C.value&&s.set("source_host",C.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!C.options.length||C.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=R.slice(0,200).map(A).join("")}function A(s){const d=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[d]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",v=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",n=s.action||"",a=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${r};font-weight:600;">${p(d)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${p(v)}</span>
<span title="${p(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(t)}</span>
<span title="${p(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(n)} ${p(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${p(a)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${p(f)}</span>
</div>`}async function y(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=C.value;C.innerHTML='<option value="">All hosts</option>'+d.map(f=>`<option value="${p(f.id)}">${p(f.label||f.id)}</option>`).join(""),r&&(C.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const d=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=d;const r=document.getElementById("sec-hosts-container");if(!d.length){r.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}r.innerHTML=d.map(f=>{const v=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
</div>`);const y=document.getElementById("security-modal"),C=document.getElementById("security-center-btn"),O=document.getElementById("sec-cancel"),E=y.querySelectorAll(".sec-tab"),M=y.querySelectorAll(".sec-panel");let R=[],P=[],A=null;E.forEach(i=>{i.addEventListener("click",()=>{E.forEach(c=>c.classList.toggle("active",c===i)),M.forEach(c=>c.style.display=c.dataset.panel===i.dataset.tab?"":"none"),i.dataset.tab==="overview"&&$(),i.dataset.tab==="events"&&N(),i.dataset.tab==="hosts"&&f()})}),C&&C.addEventListener("click",()=>{y.classList.add("show"),$(),T()}),O.addEventListener("click",p),y.addEventListener("click",i=>{i.target===y&&p()});function p(){y.classList.remove("show"),x()}function T(){if(x(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{A=new EventSource("/api/v1/security/events/stream"),A.addEventListener("init",i=>{try{R=JSON.parse(i.data).events||[],g()}catch{}}),A.addEventListener("security",i=>{try{const c=JSON.parse(i.data);R.unshift(c),R.length>500&&(R.length=500);const l=y.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?g():l==="overview"&&$()}catch{}}),A.onerror=()=>{}}catch(i){console.warn("[security] SSE failed:",i.message)}}function x(){if(A){try{A.close()}catch{}A=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{y.classList.contains("show")&&T()});async function $(){try{const i=new Date(Date.now()-864e5).toISOString(),[c,l,w]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(i)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(i)}`)]),m=(await c.json()).data||{},n=(await l.json()).data?.hosts||[],e=(await w.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${m.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${m.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${m.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${n.length} hosts`,k("sec-top-actors",m.top_actors||[]),k("sec-top-targets",m.top_targets||[])}catch(i){console.warn("[security] refreshOverview failed:",i.message)}}function k(i,c){const l=document.getElementById(i);if(!c.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(w=>`<tr><td style="padding:3px 0;word-break:break-all;">${b(String(w.key))}</td><td style="text-align:right;color:var(--muted);">${w.count}</td></tr>`).join("")+"</table>"}const H=document.getElementById("sec-filter-source"),L=document.getElementById("sec-filter-severity"),u=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),v=document.getElementById("sec-refresh-btn");[H,L,u].forEach(i=>i.addEventListener("change",N)),j.addEventListener("input",z(N,250)),v.addEventListener("click",N);async function N(){try{const i=new URLSearchParams;i.set("limit","200"),H.value&&i.set("source_type",H.value),L.value&&i.set("severity",L.value),u.value&&i.set("source_host",u.value),j.value&&i.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${i}`)).json()).data.events||[],g(),(!u.options.length||u.options.length===1)&&await B()}catch(i){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+b(i.message)+"</div>"}}function g(){const i=document.getElementById("sec-events-container");if(!R.length){i.innerHTML='<div class="panel-empty">No events</div>';return}i.innerHTML=R.slice(0,200).map(D).join("")}function D(i){const c=i.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",w=i.ts?new Date(i.ts).toLocaleTimeString():"",m=i.source_type||"",n=i.actor||"\u2014",e=i.target||"",t=i.action||"",o=i.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${l};font-weight:600;">${b(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(m)}</span>
<span title="${b(n)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(n)}</span>
<span title="${b(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${b(t)} ${b(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${b(o)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${b(w)}</span>
</div>`}async function B(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=u.value;u.innerHTML='<option value="">All hosts</option>'+c.map(w=>`<option value="${b(w.id)}">${b(w.label||w.id)}</option>`).join(""),l&&(u.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",I),document.getElementById("sec-hosts-refresh").addEventListener("click",f);async function f(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];P=c;const l=document.getElementById("sec-hosts-container");if(!c.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=c.map(w=>{const m=w.enabled?w.last_seen_at?Date.now()-Date.parse(w.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${p(f.label||f.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${p(f.type)}</span>
<strong>${b(w.label||w.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${b(w.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${p(f.id)} \xB7
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
id: ${b(w.id)} \xB7
registered ${new Date(w.registered_at).toLocaleDateString()} \xB7
last seen ${w.last_seen_at?new Date(w.last_seen_at).toLocaleString():"never"}
</div>
</div>
<div>
<span style="font-size:0.85rem;margin-right:12px;">${v}</span>
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${p(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
<span style="font-size:0.85rem;margin-right:12px;">${m}</span>
${w.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${b(w.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div>
</div>`}).join(""),r.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const d=prompt("Display label:",s)||s,r=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:d,type:r})}),v=await f.json();if(!f.ok){alert("Failed: "+(v?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(w=>{w.addEventListener("click",async()=>{confirm(`Remove host ${w.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(w.dataset.id)}`,{method:"DELETE"}),f())})})}catch(i){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+b(i.message)+"</div>"}}async function I(){const i=prompt("Host id (lowercase, no spaces):");if(!i)return;const c=prompt("Display label:",i)||i,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const w=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:i,label:c,type:l})}),m=await w.json();if(!w.ok){alert("Failed: "+(m?.error?.message||w.statusText));return}alert(`\u2705 Host registered!
id: ${v.data.host.id}
label: ${v.data.host.label}
type: ${v.data.host.type}
id: ${m.data.host.id}
label: ${m.data.host.label}
type: ${m.data.host.type}
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
${v.data.api_key}
${m.data.api_key}
Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,d=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[d])}function u(s,d){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),d)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),f()}catch(w){alert("Failed: "+w.message)}}function b(i){return String(i).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function z(i,c){let l;return function(){clearTimeout(l),l=setTimeout(()=>i.apply(this,arguments),c)}}})(),(function(){const y=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1776,23 +1817,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div>
</div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const S="weather-location",O="weather-zip",E="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(O)&&safeSet(S,safeGet(O));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return L[Math.round(M/22.5)%16]}async function T(M){const D=safeGet(E);if(D)try{const b=JSON.parse(D);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const A=await x.json();if(!A.results||!A.results.length)throw new Error("Location not found");const y=A.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(E,JSON.stringify(m)),m}function k(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function B(M){try{const D=await T(M),x=R(),A=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${D.lat}&longitude=${D.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${A}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const u=(await b.json()).current,s=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:k(D),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:x}}catch(D){return console.warn("Weather fetch failed:",D),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const D=safeGet(S);if(!D){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(D);if(x){const A=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${A}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const C=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),D=C.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);D&&(D.checked=!0),C.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(E,""),safeSet(S,M);const x=C.querySelector('input[name="weather-unit-radio"]:checked'),A=x?x.value:"imperial",y=R();safeSet(N,A),y!==A&&safeSet(E,""),C.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(C),document.addEventListener("keydown",M=>{M.keyLine truncated
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const C="weather-location",O="weather-zip",E="weather-geo",M="weather-unit";!safeGet(C)&&safeGet(O)&&safeSet(C,safeGet(O));function R(){return safeGet(M)||"imperial"}function P(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const A={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},p={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function x(v){return T[Math.round(v/22.5)%16]}async function $(v){const N=safeGet(E);if(N)try{const I=JSON.parse(N);if(I.query===v)return I}catch{}const g=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(v)}&count=1&language=en&format=json`);if(!g.ok)throw new Error("Geocoding failed");const D=await g.json();if(!D.results||!D.results.length)throw new Error("Location not found");const B=D.results[0],f={query:v,lat:B.latitude,lon:B.longitude,city:B.name,state:B.admin1||"",country:B.country||"",countryCode:B.country_code||""};return safeSet(E,JSON.stringify(f)),f}function k(v){return v.countryCode==="US"&&v.state?`${v.city}, ${v.state}`:v.country?`${v.city}, ${v.country}`:v.city}async function H(v){try{const N=await $(v),g=R(),D=g==="metric"?"celsius":"fahrenheit",B=g==="metric"?"kmh":"mph",f=`https://api.open-meteo.com/v1/forecast?latitude=${N.lat}&longitude=${N.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${B}`,I=await fetch(f);if(!I.ok)throw new Error("Weather fetch failed");const z=(await I.json()).current,i=z.weather_code;return{temp:Math.round(z.temperature_2m),condition:A[i]||"Unknown",icon:p[i]||"\u{1F324}\uFE0F",locationStr:k(N),windSpeed:Math.round(z.wind_speed_10m),windDir:x(z.wind_direction_10m),unit:g}}catch(N){return console.warn("Weather fetch failed:",N),null}}async function L(){const v=P();if(!v.icon||!v.temp||!v.condition||!v.location||!v.wind){console.warn("Weather widget elements not found");return}const N=safeGet(C);if(!N){v.location.textContent="Set Location",v.temp.textContent="--\xB0",v.condition.textContent="Click \u2699\uFE0F to configure",v.wind.textContent="--",v.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const g=await H(N);if(g){const D=g.unit==="metric"?"\xB0C":"\xB0F",B=g.unit==="metric"?"km/h":"mph";v.location.textContent=g.locationStr,v.temp.textContent=`${g.temp}${D}`,v.condition.textContent=g.condition,v.wind.textContent=`Wind: ${g.windSpeed} ${B} ${g.windDir}`,v.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(g.icon)}</span>`}}catch(g){y.logError("[Weather] Update Error",g,{function:"updateWeather"}),v.location.textContent="Weather Error",v.temp.textContent="Error",v.condition.textContent="Failed to load",v.wind.textContent="--"}}const u=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(C)||"";const v=R(),N=u.querySelector(`input[name="weather-unit-radio"][value="${v}"]`);N&&(N.checked=!0),u.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{u.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const v=j.value.trim();if(v){safeGet(C)!==v&&safeSet(E,""),safeSet(C,v);const g=u.querySelector('input[name="weather-unit-radio"]:checked'),D=g?g.value:"imperial",B=R();safeSet(M,D),B!==D&&safeSet(E,""),u.classList.remove("show"),L()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(u),document.addEventListener("keydown",v=>{v.keyLine truncated
<circle cx="${s}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
${q}
<line x1="${i}" y1="${c}" x2="${i+28*Math.cos(U*Math.PI/180)}" y2="${c+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(H*Math.PI/180)}" y2="${c+38*Math.sin(H*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos($*Math.PI/180)}" y2="${c+42*Math.sin($*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(t)}</span></div></div>`,L="analog"}function m(){const t=new Date,e=t.getHours()%12||12,n=t.getMinutes(),a=t.getSeconds(),l="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==l&&(h.className=l),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":D(t);break;case"binary":A(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}n===0&&a===0&&e!==z&&(z=e,k(e)),n!==0&&(z=-1)}function b(){clearTimeout(T);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;T=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let u='<div class="clock-style-grid">';p.forEach(t=>{u+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${t.id}">
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
</label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
${_}
<line x1="${s}" y1="${d}" x2="${s+28*Math.cos(U*Math.PI/180)}" y2="${d+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${s}" y1="${d}" x2="${s+38*Math.cos(h*Math.PI/180)}" y2="${d+38*Math.sin(h*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${s}" y1="${d}" x2="${s+42*Math.cos(S*Math.PI/180)}" y2="${d+42*Math.sin(S*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${s}" cy="${d}" r="3" fill="var(--fg)"/>
</svg>`,J=n.getHours()>=12?"PM":"AM";C.innerHTML=`<div class="analog-clock-wrap">${W}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${H(n)}</span></div></div>`,T="analog"}function f(){const n=new Date,e=n.getHours()%12||12,t=n.getMinutes(),o=n.getSeconds(),r="clock-widget"+(R!=="default"?" "+R:"");switch(y.className!==r&&(y.className=r),R){case"lcd":v(n);break;case"lcd-blue":v(n);break;case"lcd-amber":v(n);break;case"lcd-retro":v(n);break;case"lcd-taxi":v(n);break;case"flip":N(n);break;case"binary":D(n);break;case"analog":B(n,!1);break;case"roman":B(n,!0);break;default:j(n)}t===0&&o===0&&e!==P&&(P=e,k(e)),t!==0&&(P=-1)}function I(){clearTimeout($);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;$=setTimeout(()=>{f(),I()},e)}document.addEventListener("visibilitychange",()=>{p="",L(),f(),I()}),f(),I();const b=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let z='<div class="clock-style-grid">';b.forEach(n=>{z+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${n.id}">
<span class="clock-style-card"><span class="clock-style-icon">${n.icon}</span><span class="clock-style-label">${n.label}</span></span>
</label>`}),z+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${u}
${z}
</div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1816,7 +1857,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
<button id="clock-settings-save">Save</button>
</div>
</div>
</div>`);const s=document.getElementById("clock-settings-modal"),d=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function v(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),d.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",f.style.opacity=d.checked?"1":"0.4"}d?.addEventListener("change",()=>{f.style.opacity=d.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(d.checked)),safeSet("clock-chime-volume",r.value),R=e,g="",I(),m(),b(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{R=t.value,g="",I(),m()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[g,L]of Object.entries(P.status)){const w=document.getElementById("uptime-"+g),T=document.getElementById("uptime-bar-"+g);if(!w)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);w.textContent=`${B}% uptime`,w.className="uptime-chip",k>=99.9?w.classList.add("excellent"):k>=99?w.classList.add("good"):k>=95?w.classList.add("degraded"):w.classList.add("poor"),T&&(T.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let S;try{S=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{S=new Set}let O=[];async function E(z){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=g.updates||[];if(O=L,z&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const T=window.APPS||[];for(const k of T)if(k.containerId===w.containerId||k.id===w.containerName||k.name===w.containerName){if(S.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),I=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=C=>{C.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),I&&(I.style.background="#f97316",I.style.borderColor="#f97316",I.style.boxShadow="0 0 6px #f9731688",I.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),E()},5e3),setInterval(()=>{h(),E(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,S=null,O={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var z=document.getElementById("theme-label");function P(a){if(E[a])return E[a];var l=safeGetJSON(window.USER_THEMES_KEY,{});return l[a]&&l[a].name||a}function g(){z&&(z.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var a=window.THEMES.slice(),l=window.getActiveTheme(),o=a.indexOf(l),i=a[(o+1)%a.length];window.applyTheme(i),g()}),g();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
</div>`);const i=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),w=document.getElementById("clock-volume-section");function m(){const n=safeGet("clock-style")||"default",e=i.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",w.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{w.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{m(),i.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=i.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",l.value),R=e,p="",L(),f(),I(),i.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{i.classList.remove("show")}),wireModal(i),i?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{R=n.value,p="",L(),f()})})})(),(function(){async function y(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[p,T]of Object.entries(A.status)){const x=document.getElementById("uptime-"+p),$=document.getElementById("uptime-bar-"+p);if(!x)continue;const k=T.uptime?.["24h"];if(k!=null){const H=k.toFixed(1);x.textContent=`${H}% uptime`,x.className="uptime-chip",k>=99.9?x.classList.add("excellent"):k>=99?x.classList.add("good"):k>=95?x.classList.add("degraded"):x.classList.add("poor"),$&&($.style.width=H+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let C;try{C=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{C=new Set}let O=[];async function E(P){try{const p=await(await fetch("/api/v1/updates/available")).json();if(!p.success)return;document.querySelectorAll(".update-available-badge").forEach(x=>x.classList.remove("visible"));const T=p.updates||[];if(O=T,P&&T.length>0){const x=window._lastKnownUpdateCount||0;x>0&&T.length>x&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const x of T){const $=window.APPS||[];for(const k of $)if(k.containerId===x.containerId||k.id===x.containerName||k.name===x.containerName){if(C.has(k.id))break;const H=document.getElementById("update-badge-"+k.id),L=document.getElementById("update-btn-"+k.id);H&&(H.classList.add("visible"),H.title="Update available \u2014 click to open Update Management.",H.style.cursor="pointer",H.onclick=u=>{u.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),L&&(L.style.background="#f97316",L.style.borderColor="#f97316",L.style.boxShadow="0 0 6px #f9731688",L.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function M(){setTimeout(()=>{y(),E()},5e3),setInterval(()=>{y(),E(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(y,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),M()})(),(function(){var y=null,C=null,O={},E={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},M=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var P=document.getElementById("theme-label");function A(o){if(E[o])return E[o];var r=safeGetJSON(window.USER_THEMES_KEY,{});return r[o]&&r[o].name||o}function p(){P&&(P.textContent=A(window.getActiveTheme()))}R.addEventListener("click",function(){var o=window.THEMES.slice(),r=window.getActiveTheme(),a=o.indexOf(r),s=o[(a+1)%o.length];window.applyTheme(s),p()}),p();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -1853,16 +1894,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
<button id="license-activate" class="btn-accent">Activate</button>
</div>
</div>
</div>`);const h=document.getElementById("license-modal"),S=document.getElementById("license-code-input"),O=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),R=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),T=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",R.style.display="none"}function I(b){B(),N.textContent=b,N.style.display="block"}function C(b){B(),R.textContent=b,R.style.display="block"}function j(b){if(k=b,b.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",z.textContent="\u2605",P.textContent="Premium Active";const s=b.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(b.expiresAt).toLocaleDateString()}</strong> (${b.daysRemaining} days remaining)</div>`;L.innerHTML=`
<div>Code: <code style="font-family: monospace;">${b.code||"***"}</code></div>
${s}
`,T.style.display="none",O.style.display="none",E.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",z.textContent="\u2606",P.textContent=b.expired?"License Expired":"Free Tier",L.innerHTML=b.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",T.style.display="",O.style.display="",E.style.display="none";const p=b.premiumFeatures||{},u=new Set(b.features||[]);w.innerHTML=Object.entries(p).map(([s,d])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${u.has(s)?"\u2705":"\u{1F512}"}</span>
</div>`);const y=document.getElementById("license-modal"),C=document.getElementById("license-code-input"),O=document.getElementById("license-activate"),E=document.getElementById("license-deactivate"),M=document.getElementById("license-error"),R=document.getElementById("license-success"),P=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),p=document.getElementById("license-badge"),T=document.getElementById("license-details"),x=document.getElementById("license-feature-list"),$=document.getElementById("license-activate-section");let k=null;function H(){M.style.display="none",R.style.display="none"}function L(I){H(),M.textContent=I,M.style.display="block"}function u(I){H(),R.textContent=I,R.style.display="block"}function j(I){if(k=I,I.active){p.style.background="rgba(46,204,113,0.15)",p.style.color="var(--ok-fg)",P.textContent="\u2605",A.textContent="Premium Active";const i=I.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(I.expiresAt).toLocaleDateString()}</strong> (${I.daysRemaining} days remaining)</div>`;T.innerHTML=`
<div>Code: <code style="font-family: monospace;">${I.code||"***"}</code></div>
${i}
`,$.style.display="none",O.style.display="none",E.style.display=""}else p.style.background="rgba(149,165,166,0.15)",p.style.color="var(--muted)",P.textContent="\u2606",A.textContent=I.expired?"License Expired":"Free Tier",T.innerHTML=I.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",$.style.display="",O.style.display="",E.style.display="none";const b=I.premiumFeatures||{},z=new Set(I.features||[]);x.innerHTML=Object.entries(b).map(([i,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${z.has(i)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${d.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${d.description}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div>
</div>`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),A(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function D(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}B(),O.disabled=!0,O.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();u.success?(C(u.message),S.value="",j(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),A(u.license)):I(u.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{O.disabled=!1,O.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(C(p.message),await M(),showNotification("License deactivated.","info",3e3),A({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function A(b){const p=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),d=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(u.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)d.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;d.textContent=r!=null?"\xB7 "+r+"d remaining":""}else u.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",d.textContent=""}function y(){B(),M(),h.classList.add("show")}S.addEventListener("input",function(){let b=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(b.length>this._prevLength&&(b=b.replace(/-/g,""),b.length>2&&!b.startsWith("DC")&&(b="DC"+b),b.startsWith("DC")&&b.length>2)){const p=["DC"],u=b.substring(2);for(let s=0;s<u.length;s+=5)p.push(u.substring(s,s+5));b=p.join("-")}this._prevLength=b.length,this.value=b}),O.addEventListener("click",D),E.addEventListener("click",x),S.addEventListener("keydown",b=>{b.key==="Enter"&&D()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{k&&A(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const h=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
</div>`).join("")}async function v(){try{const b=await(await fetch("/api/v1/license/status")).json();b.success&&(j(b.license),D(b.license))}catch(I){console.warn("Failed to load license status:",I.message)}}async function N(){const I=C.value.trim();if(!I){L("Please enter a license code.");return}H(),O.disabled=!0,O.textContent="Activating...";try{const z=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:I})})).json();z.success?(u(z.message),C.value="",j(z.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(z.license)):L(z.error||"Activation failed")}catch(b){L("Network error: "+b.message)}finally{O.disabled=!1,O.textContent="Activate"}}async function g(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){E.disabled=!0,E.textContent="Deactivating...";try{const b=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();b.success?(u(b.message),await v(),showNotification("License deactivated.","info",3e3),D({active:!1})):L(b.error||"Deactivation failed")}catch(I){L("Network error: "+I.message)}finally{E.disabled=!1,E.textContent="Deactivate"}}}function D(I){const b=document.getElementById("license-status-topbar"),z=document.getElementById("license-topbar-icon"),i=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(b)if(b.className="license-status-topbar "+(I.active?"premium":"free"),I.active)if(z.textContent="\u2605",i.textContent="PREMIUM",I.lifetime)c.textContent="\xB7 LIFETIME";else{const l=I.daysRemaining;c.textContent=l!=null?"\xB7 "+l+"d remaining":""}else z.textContent="\u2606",i.textContent=I.expired?"EXPIRED":"FREE TIER",c.textContent=""}function B(){H(),v(),y.classList.add("show")}C.addEventListener("input",function(){let I=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(I.length>this._prevLength&&(I=I.replace(/-/g,""),I.length>2&&!I.startsWith("DC")&&(I="DC"+I),I.startsWith("DC")&&I.length>2)){const b=["DC"],z=I.substring(2);for(let i=0;i<z.length;i+=5)b.push(z.substring(i,i+5));I=b.join("-")}this._prevLength=I.length,this.value=I}),O.addEventListener("click",N),E.addEventListener("click",g),C.addEventListener("keydown",I=>{I.key==="Enter"&&N()}),wireModal(y,document.getElementById("license-cancel"));const f=document.getElementById("license-status-topbar");f&&f.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=B,window.checkPremiumFeature=async function(I){try{return(await(await fetch(`/api/v1/license/feature/${I}`)).json()).available}catch{return!1}},v().then(I=>{k&&D(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const y=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -1884,7 +1925,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
<div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${h.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
${y.map(n=>`<option value="${n.ms}">${n.label}</option>`).join("")}
</select>
<button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -1931,16 +1972,17 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f)
</div>
</div>
</div>
`);const S=document.getElementById("share-modal"),O=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),R=document.getElementById("share-issued-copy"),z=document.getElementById("share-issued-meta"),P=document.getElementById("share-error"),g=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),T=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),B=document.getElementById("share-ts-email"),I=document.getElementById("share-public-ttl");let C=null,j="public";function M(){P.style.display="none",g.style.display="none",E.style.display="none"}function D(t){g.style.display="none",E.style.display="none",P.textContent=t,P.style.display="block"}function x(t){P.style.display="none",g.textContent=t,g.style.display="block"}function A(){return window.location.origin}function y(t){return t.startsWith("http")?t:A()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function b(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const n=Math.floor(t%36e5/6e4);return`${e}h ${n}m left`}function p(t){j=t,M(),S.querySelectorAll(".share-tab").forEach(e=>{const n=e.dataset.tab===t;e.classList.toggle("active",n),e.style.borderBottomColor=n?"var(--accent)":"transparent",e.style.color=n?"var(--fg)":"var(--muted)",e.style.fontWeight=n?"600":"400"}),S.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),a=(e&&e.data||[]).filter(l=>l.serviceId===C.id);if(a.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=a.map(l=>{const o=l.expiresAt?b(new Date(l.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(l.id)}"
`);const C=document.getElementById("share-modal"),O=document.getElementById("share-modal-service-name"),E=document.getElementById("share-issued"),M=document.getElementById("share-issued-url"),R=document.getElementById("share-issued-copy"),P=document.getElementById("share-issued-meta"),A=document.getElementById("share-error"),p=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),x=document.getElementById("share-cancel"),$=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),H=document.getElementById("share-ts-email"),L=document.getElementById("share-public-ttl");let u=null,j="public";function v(){A.style.display="none",p.style.display="none",E.style.display="none"}function N(n){p.style.display="none",E.style.display="none",A.textContent=n,A.style.display="block"}function g(n){A.style.display="none",p.textContent=n,p.style.display="block"}function D(){return window.location.origin}function B(n){return n.startsWith("http")?n:D()+n}function f(n){if(!n)return"";const e=new Date(n);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function I(n){if(n<=0)return"expired";const e=Math.floor(n/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const t=Math.floor(n%36e5/6e4);return`${e}h ${t}m left`}function b(n){j=n,v(),C.querySelectorAll(".share-tab").forEach(e=>{const t=e.dataset.tab===n;e.classList.toggle("active",t),e.style.borderBottomColor=t?"var(--accent)":"transparent",e.style.color=t?"var(--fg)":"var(--muted)",e.style.fontWeight=t?"600":"400"}),C.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===n?"":"none"})}async function z(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const n=await fetch("/api/v1/share",{credentials:"same-origin"});if(!n.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await n.json(),o=(e&&e.data||[]).filter(r=>r.serviceId===u.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(r=>{const a=r.expiresAt?I(new Date(r.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(r.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
<strong>${l.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(l.expiresAt))} (${o})</span>
<strong>${r.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(f(r.expiresAt))} (${a})</span>
</span>
<button class="share-revoke" data-share-id="${escapeHtml(l.id)}" type="button"
<button class="share-revoke" data-share-id="${escapeHtml(r.id)}" type="button"
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Revoke
</button>
</div>
`}).join(""),L.querySelectorAll(".share-revoke").forEach(l=>{l.addEventListener("click",()=>s(l.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const n=await e.json().catch(()=>({}));D(n&&n.error||`Revoke failed (HTTP ${e.status}).`);return}x("Share revoked."),u()}catch{D("Network error while revoking.")}}async function d(){if(!C)return;M(),T.disabled=!0;const t=T.textContent;T.textContent="Creating\u2026";try{const e=parseInt(I.value,10),n=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,ttlMs:e})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data.urlPath;N.value=y(l),z.textContent=`Public link \xB7 expires ${m(a.data.expiresAt)}`,E.style.display="block",x("Share link created."),u()}catch{D("Network error while creating share.")}finally{T.disabled=!1,T.textContent=t}}async function r(){if(!C)return;const t=(B.value||"").trim();if(!t||!t.includes("@")){D("A valid recipient email is required.");return}M(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const n=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,email:t})}),a=await n.json().catch(()=>({}));if(!n.ok||!a.success){f(n.status,a);return}const l=a.data&&a.data.urlPath;l?(N.value=y(l),z.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(N.value="",z.textContent=`Tailscale invite sent to ${t}.`),E.style.display="block",x("Tailscale invite created."),B.value="",u()}catch{D("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function f(t,e){const n=e&&e.error||"";if(t===402){D("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(n)){D("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){D("You do not have permission to share services.");return}if(t===404){D("Service not found. It may have been deleted.");return}D(n||`Request failed (HTTP ${t}).`)}function v(t){!t||!t.id||(C={id:t.id,name:t.name||t.id},O.textContent=C.name,M(),p("public"),B.value="",I.value=String(h[1].ms),S.classList.add("show"),u())}S.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>p(t.dataset.tab))}),T.addEventListener("click",d),k.addEventListener("click",r),w.addEventListener("click",()=>S.classList.remove("show")),wireModal(S,w),R.addEventListener("click",async()=>{const t=N.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(N.select(),document.execCommand("copy"));const e=R.textContent;R.textContent="Copied!",setTimeout(()=>{R.textContent=e},1200)}catch{D("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=v})();
`}).join(""),T.querySelectorAll(".share-revoke").forEach(r=>{r.addEventListener("click",()=>i(r.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function i(n){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(n)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const t=await e.json().catch(()=>({}));N(t&&t.error||`Revoke failed (HTTP ${e.status}).`);return}g("Share revoked."),z()}catch{N("Network error while revoking.")}}async function c(){if(!u)return;v(),$.disabled=!0;const n=$.textContent;$.textContent="Creating\u2026";try{const e=parseInt(L.value,10),t=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:u.id,ttlMs:e})}),o=await t.json().catch(()=>({}));if(!t.ok||!o.success){w(t.status,o);return}const r=o.data.urlPath;M.value=B(r),P.textContent=`Public link \xB7 expires ${f(o.data.expiresAt)}`,E.style.display="block",g("Share link created."),z()}catch{N("Network error while creating share.")}finally{$.disabled=!1,$.textContent=n}}async function l(){if(!u)return;const n=(H.value||"").trim();if(!n||!n.includes("@")){N("A valid recipient email is required.");return}v(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const t=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:u.id,email:n})}),o=await t.json().catch(()=>({}));if(!t.ok||!o.success){w(t.status,o);return}const r=o.data&&o.data.urlPath;r?(M.value=B(r),P.textContent=`Tailscale invite \xB7 emailed to ${n} (or use this URL manually if delivery failed).`):(M.value="",P.textContent=`Tailscale invite sent to ${n}.`),E.style.display="block",g("Tailscale invite created."),H.value="",z()}catch{N("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function w(n,e){const t=e&&e.error||"";if(n===402){N("Share is a Pro feature. Activate a license to unlock.");return}if(n===400&&/tailscale/i.test(t)){N("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(n===403){N("You do not have permission to share services.");return}if(n===404){N("Service not found. It may have been deleted.");return}N(t||`Request failed (HTTP ${n}).`)}function m(n){!n||!n.id||(u={id:n.id,name:n.name||n.id},O.textContent=u.name,v(),b("public"),H.value="",L.value=String(y[1].ms),C.classList.add("show"),z())}C.querySelectorAll(".share-tab").forEach(n=>{n.addEventListener("click",()=>b(n.dataset.tab))}),$.addEventListener("click",c),k.addEventListener("click",l),x.addEventListener("click",()=>C.classList.remove("show")),wireModal(C,x),R.addEventListener("click",async()=>{const n=M.value;if(n)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(n):(M.select(),document.execCommand("copy"));const e=R.textContent;R.textContent="Copied!",setTimeout(()=>{R.textContent=e},1200)}catch{N("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=m})(),(function(){"use strict";const y="dashcaddy-language",O=["en","es","fr","de","ar"],E={en:"English",es:"Espa\xF1ol",fr:"Fran\xE7ais",de:"Deutsch",ar:"\u0627\u0644\u0639\u0631\u0628\u064A\u0629"};let M=localStorage.getItem(y)||"en",R={},P=!1;async function A(u){if(u==="en"){R={},P=!0;return}try{const j=await fetch(`/api/v1/i18n/translations/${u}`);j.ok&&(R=(await j.json()).translations||{},P=!0)}catch(j){console.warn("[i18n] Failed to load translations for",u,j)}}function p(u){return M==="en"?u:R[u]||u}function T(u){if(!O.includes(u))return;M=u,localStorage.setItem(y,u);const j=u==="ar";document.documentElement.dir=j?"rtl":"ltr",document.documentElement.lang=u,A(u).then(()=>{$()})}function x(){return M}function $(){document.querySelectorAll("[data-i18n]").forEach(u=>{const j=u.getAttribute("data-i18n"),v=p(j);v&&v!==j&&(u.textContent=v)}),document.querySelectorAll("[data-i18n-placeholder]").forEach(u=>{const j=u.getAttribute("data-i18n-placeholder"),v=p(j);v&&v!==j&&(u.placeholder=v)}),document.querySelectorAll("[data-i18n-title]").forEach(u=>{const j=u.getAttribute("data-i18n-title"),v=p(j);v&&v!==j&&(u.title=v)})}function k(){const u=document.querySelector(".header-actions")||document.querySelector("#auth-settings-btn")?.parentElement||document.querySelector(".top-bar-actions");if(!u){const j=document.getElementById("auth-settings-btn");return j&&j.parentElement?H(j.parentElement):null}return H(u)}function H(u){if(document.getElementById("dc-lang-selector"))return;const j=document.createElement("div");j.id="dc-lang-selector",j.style.cssText="display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;";const v=document.createElement("button");v.id="dc-lang-btn",v.className="lang-selector-btn",v.style.cssText="background: var(--card-base, #2a2a3e); border: 1px solid var(--border, Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
+1
View File
@@ -329,3 +329,4 @@
<span class="quick-search-item-badge">${r.status.toUpperCase()}</span>
</div>
`})),o||(o='<div class="quick-search-empty">No results found</div>'),f.innerHTML=o,s()}function s(){f.querySelectorAll(".quick-search-item").forEach((o,r)=>{o.addEventListener("click",()=>m(o)),r===0&&o.classList.add("selected")})}function l(t){try{const o=f.querySelectorAll(".quick-search-item"),r=f.querySelector(".quick-search-item.selected"),p=Array.from(o).indexOf(r);if(t.key==="ArrowDown"){t.preventDefault(),r&&r.classList.remove("selected");const d=(p+1)%o.length;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),r&&r.classList.remove("selected");const d=p<=0?o.length-1:p-1;o[d]?.classList.add("selected"),o[d]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),r&&m(r))}catch(o){console.warn("[Keyboard Shortcuts] Error handling search navigation:",o.message)}}function m(t){try{const o=t.dataset.action,r=t.dataset.service;switch(y(),o){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(r){const p=document.querySelector(`[data-app="${r}"] [id$="-open"], [data-app="${r}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(p)p.click();else{const d=document.querySelector(`[data-app="${r}"]`);d&&d.click()}}break;default:v("[Keyboard Shortcuts] Unknown action:",o)}}catch(o){console.warn("[Keyboard Shortcuts] Error executing action:",o.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",b):b(),window.DashCaddyKeyboardShortcuts={openQuickSearch:S,closeQuickSearch:y}})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gPT09PT09PT09PSBEQVNIQk9BUkQgSU5JVElBTElaQVRJT04gPT09PT09PT09PVxuKGZ1bmN0aW9uICgpIHtcblxuICBmdW5jdGlvbiBsb2FkQ3VzdG9tU2VydmljZXMoKSB7XG4gICAgY29uc3QgY3VzdG9tU2VydmljZXMgPSBzYWZlR2V0KCdjdXN0b20tc2VydmljZXMnKTtcbiAgICBpZiAoY3VzdG9tU2VydmljZXMpIHtcbiAgICAgIHRyeSB7XG4gICAgICAgIGNvbnN0IHNlcnZpY2VzID0gSlNPTi5wYXJzZShjdXN0b21TZXJ2aWNlcyk7XG4gICAgICAgIC8vIE1lcmdlIHdpdGggZGVmYXVsdCBBUFBTLCBhdm9pZGluZyBkdXBsaWNhdGVzXG4gICAgICAgIHNlcnZpY2VzLmZvckVhY2goc2VydmljZSA9PiB7XG4gICAgICAgICAgaWYgKCF3aW5kb3cuQVBQUy5maW5kKGFwcCA9PiBhcHAuaWQgPT09IHNlcnZpY2UuaWQpKSB7XG4gICAgICAgICAgICB3aW5kb3cuQVBQUy5wdXNoKHNlcnZpY2UpO1xuICAgICAgICAgIH1cbiAgICAgICAgfSk7XG4gICAgICB9IGNhdGNoIChlKSB7XG4gICAgICAgIGNvbnNvbGUud2FybignRmFpbGVkIHRvIGxvYWQgY3VzdG9tIHNlcnZpY2VzOicsIGUpO1xuICAgICAgfVxuICAgIH1cbiAgfVxuXG4gIC8vIEluaXRpYWxpemUgY3VzdG9tIHNlcnZpY2VzIGltbWVkaWF0ZWx5IHNvIHdpbmRvdy5BUFBTIGlzIHBvcHVsYXRlZCBiZWZvcmUgYnVpbGRHcmlkIHJ1bnNcbiAgbG9hZEN1c3RvbVNlcnZpY2VzKCk7XG5cbiAgLy8gU3RhZ2dlcmVkIGFuaW1hdGlvbiBmb3IgdG9wIGNhcmRzIHRvb1xuICBmdW5jdGlvbiBhbmltYXRlVG9wQ2FyZHMoKSB7XG4gICAgY29uc3QgdG9wQ2FyZHMgPSBkb2N1bWVudC5xdWVyeVNlbGVjdG9yQWxsKCcudG9wIC5jYXJkJyk7XG4gICAgdG9wQ2FyZHMuZm9yRWFjaCgoY2FyZCwgaW5kZXgpID0+IHtcbiAgICAgIGNhcmQuc3R5bGUudHJhbnNpdGlvbkRlbGF5ID0gYCR7TWF0aC5taW4oaW5kZXggKiA2MCwgMzAwKX1tc2A7XG4gICAgfSk7XG4gICAgcmVxdWVzdEFuaW1hdGlvbkZyYW1lKCgpID0+IHtcbiAgICAgIHRvcENhcmRzLmZvckVhY2goY2FyZCA9PiBjYXJkLmNsYXNzTGlzdC5hZGQoJ2xvYWRlZCcpKTtcbiAgICB9KTtcbiAgfVxuXG4gIGZ1bmN0aW9uIHJlZ2lzdGVyU2VydmljZVdvcmtlcigpIHtcbiAgICBpZiAoISgnc2VydmljZVdvcmtlcicgaW4gbmF2aWdhdG9yKSkgcmV0dXJuO1xuICAgIGlmICghd2luZG93LmlzU2VjdXJlQ29udGV4dCAmJiBsb2NhdGlvbi5ob3N0bmFtZSAhPT0gJ2xvY2FsaG9zdCcgJiYgbG9jYXRpb24uaG9zdG5hbWUgIT09ICcxMjcuMC4wLjEnKSByZXR1cm47XG5cbiAgICBjb25zdCByZWdpc3RlciA9ICgpID0+IHtcbiAgICAgIG5hdmlnYXRvci5zZXJ2aWNlV29ya2VyLnJlZ2lzdGVyKCcvc3cuanMnLCB7IHVwZGF0ZVZpYUNhY2hlOiAnbm9uZScgfSkuY2F0Y2goKGVycm9yKSA9PiB7XG4gICAgICAgIGNvbnNvbGUud2FybignW2luaXRdIFNlcnZpY2Ugd29ya2VyIHJlZ2lzdHJhdGlvbiBmYWlsZWQ6JywgZXJyb3IpO1xuICAgICAgfSk7XG4gICAgfTtcblxuICAgIGlmIChkb2N1bWVudC5yZWFkeVN0YXRlID09PSAnY29tcGxldGUnKSB7XG4gICAgICByZWdpc3RlcigpO1xuICAgIH0gZWxzZSB7XG4gICAgICB3aW5kb3cuYWRkRXZlbnRMaXN0ZW5lcignbG9hZCcsIHJlZ2lzdGVyLCB7IG9uY2U6IHRydWUgfSk7XG4gICAgfVxuICB9XG5cbiAgLy8gSW5pdGlhbGl6ZSBkYXNoYm9hcmQgKGNhbGxlZCBhZnRlciBUT1RQIGdhdGUgY2hlY2sgb3IgZGlyZWN0bHkgaWYgVE9UUCBkaXNhYmxlZClcbiAgLy8gTk9URTogbG9hZFNlcnZpY2VzIGNvbWVzIGZyb20gd2luZG93LmxvYWRTZXJ2aWNlcyAoZXhwb3J0ZWQgYnkgZ3JpZC5qcylcbiAgbGV0IF9kYXNoYm9hcmRJbml0aWFsaXplZCA9IGZhbHNlO1xuICBhc3luYyBmdW5jdGlvbiBpbml0aWFsaXplRGFzaGJvYXJkKCkge1xuICAgIGlmIChfZGFzaGJvYXJkSW5pdGlhbGl6ZWQpIHtcbiAgICAgIGNvbnNvbGUud2FybignW2luaXRdIGluaXRpYWxpemVEYXNoYm9hcmQgY2FsbGVkIGFnYWluLCBza2lwcGluZyBkdXBsaWNhdGUnKTtcbiAgICAgIHJldHVybjtcbiAgICB9XG4gICAgX2Rhc2hib2FyZEluaXRpYWxpemVkID0gdHJ1ZTtcbiAgICBhd2FpdCB3aW5kb3cubG9hZFNlcnZpY2VzKCk7XG4gICAgYXdhaXQgbG9hZFRlbXBsYXRlQ2F0ZWdvcmllcygpO1xuICAgIHdpbmRvdy5idWlsZEdyaWQoKTtcbiAgICBhbmltYXRlVG9wQ2FyZHMoKTtcbiAgICB3aW5kb3cucmVmcmVzaEFsbCgpO1xuICAgIHNldEludGVydmFsKHdpbmRvdy5yZWZyZXNoQWxsLCBEQy5QT0xMLkRBU0hCT0FSRCk7XG4gICAgaWYgKHR5cGVvZiB3aW5kb3cucmVmcmVzaENyZWRzQnV0dG9ucyA9PT0gJ2Z1bmN0aW9uJykgd2luZG93LnJlZnJlc2hDcmVkc0J1dHRvbnMoKTtcbiAgICBpZiAodHlwZW9mIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMgPT09ICdmdW5jdGlvbicpIHdpbmRvdy5yZWZyZXNoTW9uaXRvcmluZ1dpZGdldHMoKTtcbiAgICAvLyBVcGRhdGUgYXV0aCBjYXJkIChtYXkgaGF2ZSBhbHJlYWR5IGJlZW4gdXBkYXRlZCBieSB0aGUgYXV0by1sb2FkIElJRkUgYnV0IGVuc3VyZSBpdCdzIGNvcnJlY3QpXG4gICAgaWYgKHR5cGVvZiB3aW5kb3cuX3VwZGF0ZUF1dGhDYXJkID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBjb25zdCByID0gYXdhaXQgZmV0Y2goJy9hcGkvdjEvdG90cC9jb25maWcnLCB7IGNhY2hlOiAnbm8tc3RvcmUnIH0pO1xuICAgICAgICBjb25zdCBkID0gYXdhaXQgci5qc29uKCk7XG4gICAgICAgIGlmIChkLnN1Y2Nlc3MpIHdpbmRvdy5fdXBkYXRlQXV0aENhcmQoZC5jb25maWcuZW5hYmxlZCAmJiBkLmNvbmZpZy5pc1NldFVwLCBkLmNvbmZpZy5zZXNzaW9uRHVyYXRpb24pO1xuICAgICAgfSBjYXRjaCAoZSkgeyAvKiBpZ25vcmUgKi8gfVxuICAgIH1cbiAgICBpZiAod2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZCkge1xuICAgICAgdHJ5IHtcbiAgICAgICAgYXdhaXQgd2luZG93Ll9fZGFzaGNhZGR5U2l0ZUNvbmZpZ0xvYWRlZDtcbiAgICAgIH0gY2F0Y2ggKF8pIHsgLyogaWdub3JlICovIH1cbiAgICB9XG4gICAgLy8gTGF6eS1sb2FkIG9uYm9hcmRpbmcgb25seSBvbmNlIHBlciBpbnN0YWxsLCBvdGhlcndpc2UganVzdCBhZGQgdGhlIHRvdXIgYnV0dG9uXG4gICAgYWRkVG91ckJ1dHRvbigpO1xuICAgIGlmIChzaG91bGRMb2FkT25ib2FyZGluZygpKSB7XG4gICAgICBsb2FkT25ib2FyZGluZygpO1xuICAgIH1cblxuICAgIC8vIERDLTA0ODogaW5qZWN0IHRoZSBcIkFkbWluXCIgdHJpZ2dlciBidXR0b24gaW50byB0aGUgdG9wIGJhci4gVGhlXG4gICAgLy8gYnV0dG9uIG9ubHkgcmVuZGVycyB3aGVuIC9tZSByZXR1cm5zIGlzQWRtaW49dHJ1ZTsgdGhlIG1vZHVsZVxuICAgIC8vIHJlLWNoZWNrcyBldmVyeSA2MHMgc28gYSBwZXJtaXNzaW9uIGRvd25ncmFkZSB0YWtlcyBlZmZlY3QuXG4gICAgaWYgKHdpbmRvdy5BZG1pblBhbmVsICYmIHR5cGVvZiB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyID09PSAnZnVuY3Rpb24nKSB7XG4gICAgICB0cnkge1xuICAgICAgICBhd2FpdCB3aW5kb3cuQWRtaW5QYW5lbC5hdHRhY2hUcmlnZ2VyKGRvY3VtZW50LmJvZHkpO1xuICAgICAgfSBjYXRjaCAoZ Line truncated
+1
View File
@@ -198,3 +198,4 @@ ${b}`)}}y.TooltipValidation={validateTooltipDefinition:a,validateTooltipDefiniti
Possible issues:
- Driver.js library failed to load
- JavaScript errors during initialization`))},l=document.getElementById("restart-tour-btn");if(l){l.onclick=C;return}const u=document.createElement("button");u.id="restart-tour-btn",u.textContent="Help Tour",u.title="Restart the onboarding tour",u.onclick=C,m.appendChild(u)}function A(){return typeof(window.driver?.js?.driver||window.driver?.driver||window.driver)!="function"?(console.warn("[Onboarding] Driver.js not loaded yet, will retry... window.driver:",window.driver),!1):!0}function k(){let m=0;const C=10;function l(){A()?n():(m++,m<C?setTimeout(l,500):r?r.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure())}l()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",k):k(),y("[Onboarding] System loaded")})();
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsidGhpcy5kcml2ZXI9dGhpcy5kcml2ZXJ8fHt9O3RoaXMuZHJpdmVyLmpzPWZ1bmN0aW9uKEQpe1widXNlIHN0cmljdFwiO2xldCBGPXt9O2Z1bmN0aW9uIHooZT17fSl7Rj17YW5pbWF0ZTohMCxhbGxvd0Nsb3NlOiEwLG92ZXJsYXlPcGFjaXR5Oi43LHNtb290aFNjcm9sbDohMSxkaXNhYmxlQWN0aXZlSW50ZXJhY3Rpb246ITEsc2hvd1Byb2dyZXNzOiExLHN0YWdlUGFkZGluZzoxMCxzdGFnZVJhZGl1czo1LHBvcG92ZXJPZmZzZXQ6MTAsc2hvd0J1dHRvbnM6W1wibmV4dFwiLFwicHJldmlvdXNcIixcImNsb3NlXCJdLGRpc2FibGVCdXR0b25zOltdLG92ZXJsYXlDb2xvcjpcIiMwMDBcIiwuLi5lfX1mdW5jdGlvbiBhKGUpe3JldHVybiBlP0ZbZV06Rn1mdW5jdGlvbiBXKGUsbyx0LGkpe3JldHVybihlLz1pLzIpPDE/dC8yKmUqZStvOi10LzIqKC0tZSooZS0yKS0xKStvfWZ1bmN0aW9uIHEoZSl7Y29uc3Qgbz0nYVtocmVmXTpub3QoW2Rpc2FibGVkXSksIGJ1dHRvbjpub3QoW2Rpc2FibGVkXSksIHRleHRhcmVhOm5vdChbZGlzYWJsZWRdKSwgaW5wdXRbdHlwZT1cInRleHRcIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwicmFkaW9cIl06bm90KFtkaXNhYmxlZF0pLCBpbnB1dFt0eXBlPVwiY2hlY2tib3hcIl06bm90KFtkaXNhYmxlZF0pLCBzZWxlY3Q6bm90KFtkaXNhYmxlZF0pJztyZXR1cm4gZS5mbGF0TWFwKHQ9Pntjb25zdCBpPXQubWF0Y2hlcyhvKSxwPUFycmF5LmZyb20odC5xdWVyeVNlbGVjdG9yQWxsKG8pKTtyZXR1cm5bLi4uaT9bdF06W10sLi4ucF19KS5maWx0ZXIodD0+Z2V0Q29tcHV0ZWRTdHlsZSh0KS5wb2ludGVyRXZlbnRzIT09XCJub25lXCImJmFlKHQpKX1mdW5jdGlvbiBWKGUpe2lmKCFlfHxzZShlKSlyZXR1cm47Y29uc3Qgbz1hKFwic21vb3RoU2Nyb2xsXCIpO2Uuc2Nyb2xsSW50b1ZpZXcoe2JlaGF2aW9yOiFvfHxyZShlKT9cImF1dG9cIjpcInNtb290aFwiLGlubGluZTpcImNlbnRlclwiLGJsb2NrOlwiY2VudGVyXCJ9KX1mdW5jdGlvbiByZShlKXtpZighZXx8IWUucGFyZW50RWxlbWVudClyZXR1cm47Y29uc3Qgbz1lLnBhcmVudEVsZW1lbnQ7cmV0dXJuIG8uc2Nyb2xsSGVpZ2h0Pm8uY2xpZW50SGVpZ2h0fWZ1bmN0aW9uIHNlKGUpe2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKTtyZXR1cm4gby50b3A+PTAmJm8ubGVmdD49MCYmby5ib3R0b208PSh3aW5kb3cuaW5uZXJIZWlnaHR8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRIZWlnaHQpJiZvLnJpZ2h0PD0od2luZG93LmlubmVyV2lkdGh8fGRvY3VtZW50LmRvY3VtZW50RWxlbWVudC5jbGllbnRXaWR0aCl9ZnVuY3Rpb24gYWUoZSl7cmV0dXJuISEoZS5vZmZzZXRXaWR0aHx8ZS5vZmZzZXRIZWlnaHR8fGUuZ2V0Q2xpZW50UmVjdHMoKS5sZW5ndGgpfWxldCBOPXt9O2Z1bmN0aW9uIGIoZSxvKXtOW2VdPW99ZnVuY3Rpb24gbChlKXtyZXR1cm4gZT9OW2VdOk59ZnVuY3Rpb24gSygpe049e319bGV0IEU9e307ZnVuY3Rpb24gTyhlLG8pe0VbZV09b31mdW5jdGlvbiBfKGUpe3ZhciBvOyhvPUVbZV0pPT1udWxsfHxvLmNhbGwoRSl9ZnVuY3Rpb24gY2UoKXtFPXt9fWZ1bmN0aW9uIGxlKGUsbyx0LGkpe2xldCBwPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIik7Y29uc3Qgbj1wfHx0LmdldEJvdW5kaW5nQ2xpZW50UmVjdCgpLGY9aS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx3PVcoZSxuLngsZi54LW4ueCxvKSxyPVcoZSxuLnksZi55LW4ueSxvKSx2PVcoZSxuLndpZHRoLGYud2lkdGgtbi53aWR0aCxvKSxzPVcoZSxuLmhlaWdodCxmLmhlaWdodC1uLmhlaWdodCxvKTtwPXt4OncseTpyLHdpZHRoOnYsaGVpZ2h0OnN9LFkocCksYihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHApfWZ1bmN0aW9uIFgoZSl7aWYoIWUpcmV0dXJuO2NvbnN0IG89ZS5nZXRCb3VuZGluZ0NsaWVudFJlY3QoKSx0PXt4Om8ueCx5Om8ueSx3aWR0aDpvLndpZHRoLGhlaWdodDpvLmhlaWdodH07YihcIl9fYWN0aXZlU3RhZ2VQb3NpdGlvblwiLHQpLFkodCl9ZnVuY3Rpb24gZGUoKXtjb25zdCBlPWwoXCJfX2FjdGl2ZVN0YWdlUG9zaXRpb25cIiksbz1sKFwiX19vdmVybGF5U3ZnXCIpO2lmKCFlKXJldHVybjtpZighbyl7Y29uc29sZS53YXJuKFwiTm8gc3RhZ2Ugc3ZnIGZvdW5kLlwiKTtyZXR1cm59Y29uc3QgdD13aW5kb3cuaW5uZXJXaWR0aCxpPXdpbmRvdy5pbm5lckhlaWdodDtvLnNldEF0dHJpYnV0ZShcInZpZXdCb3hcIixgMCAwICR7dH0gJHtpfWApfWZ1bmN0aW9uIHBlKGUpe2NvbnN0IG89dWUoZSk7ZG9jdW1lbnQuYm9keS5hcHBlbmRDaGlsZChvKSxHKG8sdD0+e3QudGFyZ2V0LnRhZ05hbWU9PT1cInBhdGhcIiYmXyhcIm92ZXJsYXlDbGlja1wiKX0pLGIoXCJfX292ZXJsYXlTdmdcIixvKX1mdW5jdGlvbiBZKGUpe2NvbnN0IG89bChcIl9fb3ZlcmxheVN2Z1wiKTtpZighbyl7cGUoZSk7cmV0dXJufWNvbnN0IHQ9by5maXJzdEVsZW1lbnRDaGlsZDtpZigodD09bnVsbD92b2lkIDA6dC50YWdOYW1lKSE9PVwicGF0aFwiKXRocm93IG5ldyBFcnJvcihcIm5vIHBhdGggZWxlbWVudCBmb3VuZCBpbiBzdGFnZSBzdmdcIik7dC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSl9ZnVuY3Rpb24gdWUoZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWRvY3VtZW50LmNyZWF0ZUVsZW1lbnROUyhcImh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnXCIsXCJzdmdcIik7aS5jbGFzc0xpc3QuYWRkKFwiZHJpdmVyLW92ZXJsYXlcIixcImRyaXZlci1vdmVybGF5LWFuaW1hdGVkXCIpLGkuc2V0QXR0cmlidXRlKFwidmlld0JveFwiLGAwIDAgJHtvfSAke3R9YCksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxTcGFjZVwiLFwicHJlc2VydmVcIiksaS5zZXRBdHRyaWJ1dGUoXCJ4bWxuc1hsaW5rXCIsXCJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rXCIpLGkuc2V0QXR0cmlidXRlKFwidmVyc2lvblwiLFwiMS4xXCIpLGkuc2V0QXR0cmlidXRlKFwicHJlc2VydmVBc3BlY3RSYXRpb1wiLFwieE1pbllNaW4gc2xpY2VcIiksaS5zdHlsZS5maWxsUnVsZT1cImV2ZW5vZGRcIixpLnN0eWxlLmNsaXBSdWxlPVwiZXZlbm9kZFwiLGkuc3R5bGUuc3Ryb2tlTGluZWpvaW49XCJyb3VuZFwiLGkuc3R5bGUuc3Ryb2tlTWl0ZXJsaW1pdD1cIjJcIixpLnN0eWxlLnpJbmRleD1cIjEwMDAwXCIsaS5zdHlsZS5wb3NpdGlvbj1cImZpeGVkXCIsaS5zdHlsZS50b3A9XCIwXCIsaS5zdHlsZS5sZWZ0PVwiMFwiLGkuc3R5bGUud2lkdGg9XCIxMDAlXCIsaS5zdHlsZS5oZWlnaHQ9XCIxMDAlXCI7Y29uc3QgcD1kb2N1bWVudC5jcmVhdGVFbGVtZW50TlMoXCJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2Z1wiLFwicGF0aFwiKTtyZXR1cm4gcC5zZXRBdHRyaWJ1dGUoXCJkXCIsaihlKSkscC5zdHlsZS5maWxsPWEoXCJvdmVybGF5Q29sb3JcIil8fFwicmdiKDAsMCwwKVwiLHAuc3R5bGUub3BhY2l0eT1gJHthKFwib3ZlcmxheU9wYWNpdHlcIil9YCxwLnN0eWxlLnBvaW50ZXJFdmVudHM9XCJhdXRvXCIscC5zdHlsZS5jdXJzb3I9XCJhdXRvXCIsaS5hcHBlbmRDaGlsZChwKSxpfWZ1bmN0aW9uIGooZSl7Y29uc3Qgbz13aW5kb3cuaW5uZXJXaWR0aCx0PXdpbmRvdy5pbm5lckhlaWdodCxpPWEoXCJzd Line truncated
+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',