# Conflicts:
#	status/dist/features.js
This commit is contained in:
Sami committed 2026-05-16 22:52:07 -07:00
commit edac587c5c
33 files changed
+1785 -476

No files matched your search

+24 -34
View File
@@ -49,7 +49,7 @@ class SelfUpdater extends EventEmitter {
// hostUpdatesDir is the HOST path that maps to updatesDir inside the container. // hostUpdatesDir is the HOST path that maps to updatesDir inside the container.
// Used when writing trigger.json so the host-side script can find staging files. // Used when writing trigger.json so the host-side script can find staging files.
hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'), hostUpdatesDir: options.hostUpdatesDir || (platformPaths.isWindows ? options.updatesDir || DEFAULTS.UPDATES_DIR : '/opt/dashcaddy/updates'),
apiSourceDir: options.apiSourceDir || process.env.DASHCADDY_API_SOURCE_DIR || DEFAULTS.API_SOURCE_DIR, apiSourceDir: options.apiSourceDir || DEFAULTS.API_SOURCE_DIR,
frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR, frontendDir: options.frontendDir || DEFAULTS.FRONTEND_DIR,
maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10), maxBackups: parseInt(options.maxBackups || DEFAULTS.MAX_BACKUPS, 10),
channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL, channel: options.channel || process.env.DASHCADDY_UPDATE_CHANNEL || DEFAULTS.CHANNEL,
@@ -311,15 +311,25 @@ class SelfUpdater extends EventEmitter {
// Delete the result file so we don't process it again // Delete the result file so we don't process it again
await fsp.unlink(resultPath).catch(() => {}); await fsp.unlink(resultPath).catch(() => {});
// Update history // Update the matching history entry, preferring the newest pending item
// for the same target version. Fall back to the newest pending item if
// older result files lack enough metadata to match more precisely.
const history = this.getUpdateHistory(); const history = this.getUpdateHistory();
const updated = history.filter(h => h.status === 'pending'); const pendingIndex = history.findIndex(
if (updated.length > 0) { h => h.status === 'pending' && (!result.version || h.version === result.version)
for (const pending of updated) { );
pending.status = result.success ? 'success' : 'rolled-back'; const fallbackIndex = pendingIndex === -1
pending.duration = result.duration; ? history.findIndex(h => h.status === 'pending')
if (result.error) pending.error = result.error; : -1;
} const historyIndex = pendingIndex !== -1 ? pendingIndex : fallbackIndex;
if (historyIndex !== -1) {
const pending = history[historyIndex];
pending.status = result.success ? 'success' : 'rolled-back';
pending.duration = result.duration;
if (result.error) pending.error = result.error;
if (result.version) pending.version = result.version;
if (result.timestamp) pending.completedAt = result.timestamp;
this._saveHistory(history); this._saveHistory(history);
} }
@@ -400,17 +410,10 @@ class SelfUpdater extends EventEmitter {
async _autoCheckAndApply() { async _autoCheckAndApply() {
try { try {
const result = await this.checkForUpdate(); const result = await this.checkForUpdate();
if (!result.available || !result.remote) return; if (result.available && result.remote) {
// Belt-and-suspenders: never auto-apply a same-version update. A bug here console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
// creates an infinite rebuild loop (each fresh container has commit='unknown' await this.applyUpdate(result.remote);
// so it keeps comparing as different from remote forever).
if (this._compareVersions(result.local.version, result.remote.version) >= 0) {
console.log('[SelfUpdater] Skipping auto-apply: local %s is not older than remote %s',
result.local.version, result.remote.version);
return;
} }
console.log('[SelfUpdater] Update available: %s → %s', result.local.version, result.remote.version);
await this.applyUpdate(result.remote);
} catch (e) { } catch (e) {
console.error('[SelfUpdater] Auto-update error:', e.message); console.error('[SelfUpdater] Auto-update error:', e.message);
} }
@@ -503,24 +506,11 @@ class SelfUpdater extends EventEmitter {
const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version); const versionCompare = this._compareVersions(local.version || '0.0.0', remote.version);
if (versionCompare < 0) return true; if (versionCompare < 0) return true;
if (versionCompare > 0) return false; if (versionCompare > 0) return false;
// Same version — only flag as newer if BOTH commits are known and differ. // Same version — check commit hash
// If local.commit is missing or 'unknown' (Dockerfile default when no build arg if (remote.commit && local.commit && remote.commit !== local.commit) return true;
// is passed), we can't distinguish builds, so trust the version number and
// treat them as equivalent. Otherwise the updater loops forever applying
// the same version because every fresh container build has commit='unknown'.
const localCommit = this._normalizeCommit(local.commit);
const remoteCommit = this._normalizeCommit(remote.commit);
if (localCommit && remoteCommit && localCommit !== remoteCommit) return true;
return false; return false;
} }
_normalizeCommit(value) {
if (!value) return null;
const str = String(value).trim().toLowerCase();
if (!str || str === 'unknown' || str === 'null' || str === 'undefined') return null;
return str;
}
_compareVersions(a, b) { _compareVersions(a, b) {
const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const av = String(a || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0); const bv = String(b || '0.0.0').split('.').map(part => parseInt(part, 10) || 0);
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c latest.tar.gz
+9
View File
@@ -0,0 +1,9 @@
{
"version": "1.2.0",
"commit": "a216dd8",
"channel": "stable",
"url": "https://get.dashcaddy.net/release/latest.tar.gz",
"sha256": "a8a670311c60172ef38098819436a6ff081e5379da9e3ab18a291c74a1ea4f5c",
"publishedAt": "2026-05-06T23:06:32Z",
"changelog": "Release published via scripts/publish-release.sh"
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STATUS_DIR="${ROOT_DIR}/status"
API_DIR="${ROOT_DIR}/dashcaddy-api"
log() { echo "[prepare-release] $*"; }
fail() { echo "[prepare-release] ERROR: $*" >&2; exit 1; }
[[ -f "${STATUS_DIR}/package.json" ]] || fail "Missing status/package.json"
[[ -f "${API_DIR}/package.json" ]] || fail "Missing dashcaddy-api/package.json"
log "Building dashboard frontend..."
cd "${STATUS_DIR}"
npm run build
log "Verifying CSP hash present in status/index.html..."
grep -q "Content-Security-Policy" "${STATUS_DIR}/index.html" || fail "Missing CSP meta tag"
grep -q "sha256-" "${STATUS_DIR}/index.html" || fail "Missing CSP script hash"
grep -q "license-topbar-version" "${STATUS_DIR}/index.html" || fail "Expected version-chip markup missing"
log "Verifying API version metadata..."
node -e "const fs=require('fs'); const pkg=JSON.parse(fs.readFileSync('${API_DIR}/package.json','utf8')); if(!pkg.version) process.exit(1); console.log('[prepare-release] API version', pkg.version);"
log "Release prep complete. Package from repo root after this step."
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API_DIR="${ROOT_DIR}/dashcaddy-api"
STATUS_DIR="${ROOT_DIR}/status"
PREP_SCRIPT="${ROOT_DIR}/scripts/prepare-release.sh"
OUT_DIR="${1:-${ROOT_DIR}/release}"
CHANNEL="${CHANNEL:-stable}"
BASE_URL="${BASE_URL:-https://get.dashcaddy.net/release}"
CHANGELOG_FILE="${CHANGELOG_FILE:-}"
log() { echo "[publish-release] $*"; }
fail() { echo "[publish-release] ERROR: $*" >&2; exit 1; }
[[ -x "$PREP_SCRIPT" ]] || fail "Missing prepare script: $PREP_SCRIPT"
[[ -f "${API_DIR}/package.json" ]] || fail "Missing API package.json"
VERSION="$(node -pe "require('${API_DIR}/package.json').version")"
COMMIT="$(git -C "${ROOT_DIR}" rev-parse --short HEAD)"
TARBALL_NAME="latest.tar.gz"
VERSION_JSON="version.json"
SHA256_FILE="latest.tar.gz.sha256"
mkdir -p "$OUT_DIR"
rm -f "${OUT_DIR:?}/${TARBALL_NAME}" "${OUT_DIR}/${VERSION_JSON}" "${OUT_DIR}/${SHA256_FILE}"
log "Preparing release inputs..."
"$PREP_SCRIPT"
TMP_DIR="$(mktemp -d)"
cleanup() { rm -rf "$TMP_DIR"; }
trap cleanup EXIT
STAGE_DIR="${TMP_DIR}/dashcaddy"
mkdir -p "$STAGE_DIR"
log "Staging API and dashboard files..."
cp -a "${API_DIR}" "$STAGE_DIR/dashcaddy-api"
cp -a "${STATUS_DIR}" "$STAGE_DIR/status"
cp -a "${ROOT_DIR}/ca" "$STAGE_DIR/ca" 2>/dev/null || true
cp -a "${ROOT_DIR}/dashcaddy-installer" "$STAGE_DIR/dashcaddy-installer" 2>/dev/null || true
cp -a "${ROOT_DIR}/README.md" "$STAGE_DIR/README.md" 2>/dev/null || true
find "$STAGE_DIR" \( -name node_modules -o -name .git -o -name coverage -o -name .nyc_output \) -prune -exec rm -rf {} +
find "$STAGE_DIR" -type f \( -name '*.test.js' -o -name '*.spec.js' \) -delete
log "Creating tarball..."
tar -czf "${OUT_DIR}/${TARBALL_NAME}" -C "$TMP_DIR" dashcaddy
SHA256="$(sha256sum "${OUT_DIR}/${TARBALL_NAME}" | awk '{print $1}')"
printf '%s %s\n' "$SHA256" "$TARBALL_NAME" > "${OUT_DIR}/${SHA256_FILE}"
CHANGELOG=""
if [[ -n "$CHANGELOG_FILE" && -f "$CHANGELOG_FILE" ]]; then
CHANGELOG="$(python3 - <<'PY' "$CHANGELOG_FILE"
from pathlib import Path
import json, sys
print(json.dumps(Path(sys.argv[1]).read_text().strip()))
PY
)"
else
CHANGELOG='"Release published via scripts/publish-release.sh"'
fi
log "Writing version manifest..."
cat > "${OUT_DIR}/${VERSION_JSON}" <<EOF
{
"version": "${VERSION}",
"commit": "${COMMIT}",
"channel": "${CHANNEL}",
"url": "${BASE_URL}/${TARBALL_NAME}",
"sha256": "${SHA256}",
"publishedAt": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"changelog": ${CHANGELOG}
}
EOF
log "Release artifacts created in ${OUT_DIR}"
log " - ${TARBALL_NAME}"
log " - ${SHA256_FILE}"
log " - ${VERSION_JSON}"
+36 -1
View File
@@ -1,9 +1,11 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto');
const esbuild = require('esbuild'); const esbuild = require('esbuild');
const JS = (...parts) => path.join(__dirname, 'js', ...parts); const JS = (...parts) => path.join(__dirname, 'js', ...parts);
const DIST = path.join(__dirname, 'dist'); const DIST = path.join(__dirname, 'dist');
const INDEX_HTML = path.join(__dirname, 'index.html');
// Bundle definitions — files are concatenated in order, then minified // Bundle definitions — files are concatenated in order, then minified
const bundles = { const bundles = {
@@ -23,6 +25,8 @@ const bundles = {
JS('core', 'service-crud.js'), JS('core', 'service-crud.js'),
JS('core', 'service-create.js'), JS('core', 'service-create.js'),
JS('live-events.js'), JS('live-events.js'),
JS('service-filter.js'),
JS('batch-operations.js'),
], ],
'features.js': [ 'features.js': [
JS('logo-customization.js'), JS('logo-customization.js'),
@@ -31,6 +35,8 @@ const bundles = {
JS('recipes.js'), JS('recipes.js'),
JS('import-export.js'), JS('import-export.js'),
JS('error-logs.js'), JS('error-logs.js'),
JS('container-logs.js'),
JS('snapshot.js'),
JS('smart-arr-connect.js'), JS('smart-arr-connect.js'),
JS('notification-settings.js'), JS('notification-settings.js'),
JS('panel-tabs.js'), JS('panel-tabs.js'),
@@ -64,6 +70,32 @@ const bundles = {
], ],
}; };
function updateInlineScriptCspHash() {
const html = fs.readFileSync(INDEX_HTML, 'utf8');
const scripts = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)];
const target = scripts.find(match => {
const block = match[1] || '';
return block.includes("license-topbar-version") || block.includes("openVersionInfo") || block.includes("widget-");
});
if (!target) {
throw new Error('Could not find inline dashboard bootstrap script in index.html');
}
const scriptContent = target[1];
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
const updatedHtml = html.replace(
/script-src 'self' 'sha256-[^']+';/,
`script-src 'self' 'sha256-${hash}';`
);
if (updatedHtml !== html) {
fs.writeFileSync(INDEX_HTML, updatedHtml);
}
return hash;
}
async function build() { async function build() {
// Ensure dist/ exists // Ensure dist/ exists
if (!fs.existsSync(DIST)) fs.mkdirSync(DIST); if (!fs.existsSync(DIST)) fs.mkdirSync(DIST);
@@ -96,6 +128,8 @@ async function build() {
results[outName] = { rawSize, minSize, fileCount: files.length }; results[outName] = { rawSize, minSize, fileCount: files.length };
} }
const cspHash = updateInlineScriptCspHash();
// Summary // Summary
console.log('\n DashCaddy Frontend Build\n'); console.log('\n DashCaddy Frontend Build\n');
console.log(' Bundle Files Raw Min'); console.log(' Bundle Files Raw Min');
@@ -108,7 +142,8 @@ async function build() {
} }
console.log(' ─────────────────────────────────────────'); console.log(' ─────────────────────────────────────────');
console.log(` ${'Total'.padEnd(18)} ${totalRaw.toFixed(1).padStart(6)} KB ${totalMin.toFixed(1).padStart(6)} KB`); console.log(` ${'Total'.padEnd(18)} ${totalRaw.toFixed(1).padStart(6)} KB ${totalMin.toFixed(1).padStart(6)} KB`);
console.log(`\n Output: ${DIST}\n`); console.log(`\n Output: ${DIST}`);
console.log(` CSP Hash: sha256-${cspHash}\n`);
} }
// Watch mode // Watch mode
+39 -2
View File
@@ -170,13 +170,20 @@ button:focus-visible {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-end; align-items: flex-end;
gap: 6px;
} }
.reload-caddy-main { .reload-caddy-main {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.license-version-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 18px; gap: 8px;
margin-top: 12px;
} }
.dashboard-version { .dashboard-version {
@@ -196,6 +203,36 @@ button:focus-visible {
opacity: 0.9; opacity: 0.9;
} }
.dashboard-version-inline {
white-space: nowrap;
align-self: center;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 4px;
}
.dashboard-version-inline.has-update {
color: var(--accent);
}
.dashboard-version-inline.has-update .dashboard-version-bullet,
.dashboard-version-inline.has-update #license-topbar-version-text {
color: var(--accent) !important;
}
.dashboard-version-bullet {
color: var(--muted);
font-weight: 400;
}
#license-topbar-version-text {
color: var(--fg) !important;
font-weight: 700;
font-size: 0.82rem;
letter-spacing: 0.01em;
}
.version-info-modal-content { .version-info-modal-content {
min-width: 420px; min-width: 420px;
max-width: 620px; max-width: 620px;
+99 -99
View File
@@ -1,35 +1,35 @@
const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const f=await fetch("/api/v1/config");if(f.ok){const m=await f.json();if(m.tld&&(SITE.tld=m.tld.startsWith(".")?m.tld:"."+m.tld),m.dns&&(SITE.dnsIp=m.dns.ip||"",SITE.dnsPort=m.dns.port||DC.DEFAULTS.DNS_PORT),m.dnsServers&&typeof m.dnsServers=="object")for(const[b,a]of Object.entries(m.dnsServers))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(SITE.dnsServers[b]=a);m.configurationType&&(SITE.configurationType=m.configurationType),m.domain&&(SITE.domain=m.domain),m.defaults&&(SITE.defaults=m.defaults),m.routingMode&&(SITE.routingMode=m.routingMode),SITE.onboardingCompleted=m.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const l=document.getElementById("manage-tokens");l&&(l.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(f=>f.textContent=SITE.tld);const i=document.getElementById("edit-tld-suffix");i&&(i.textContent=SITE.tld);const v=document.getElementById("external-proxy-ip");v&&SITE.dnsIp&&(v.value=SITE.dnsIp,v.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const i=SITE.dnsServers[o];return i?`${i.ip}:${i.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,i]of Object.entries(SITE.dnsServers))if(i.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const i=Object.keys(SITE.dnsServers);if(!i.length)return;const v='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',f=o.firstElementChild;i.forEach(m=>{const l=escapeHtml(m),b=escapeHtml((SITE.dnsServers[m].name||m).toUpperCase()),a=document.createElement("div");a.className="card",a.setAttribute("data-app",m),a.setAttribute("data-status","off"),a.innerHTML=`<span id="${l}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${v}</div><span class="name">${b}</span><span class="spacer"></span><span id="${l}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${l}-time" class="response-time">--</span></div><div class="health-row" id="health-${l}"><span id="uptime-${l}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${l}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${l}-restart" class="restart-btn">Restart</button><button id="${l}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${l}-open">Open</button><button id="${l}-logs" class="logs-btn">Logs</button><button id="${l}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(a,f)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw console.error("Failed to get CSRF token:",o),o}}async function secureFetch(o,i={}){const v=(i.method||"GET").toUpperCase(),f=!["GET","HEAD","OPTIONS"].includes(v);if(f)try{const l=await getCSRFToken();i.headers={...i.headers,"X-CSRF-Token":l}}catch(l){console.error("Failed to add CSRF token to request:",l)}i.signal||(i={...i,signal:AbortSignal.timeout(15e3)});const m=await fetch(o,i);if(f&&m.status===403)try{const l=await m.clone().json();if(l.error&&(l.error.includes("DC-100")||l.error.includes("DC-101"))){csrfToken=nLine truncated const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},errorHandler=new ErrorHandler,_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const w=await fetch("/api/v1/config");if(w.ok){const v=await w.json();if(v.tld&&(SITE.tld=v.tld.startsWith(".")?v.tld:"."+v.tld),v.dns&&(SITE.dnsIp=v.dns.ip||"",SITE.dnsPort=v.dns.port||DC.DEFAULTS.DNS_PORT),v.dnsServers&&typeof v.dnsServers=="object")for(const[h,n]of Object.entries(v.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=n);v.configurationType&&(SITE.configurationType=v.configurationType),v.domain&&(SITE.domain=v.domain),v.defaults&&(SITE.defaults=v.defaults),v.routingMode&&(SITE.routingMode=v.routingMode),SITE.onboardingCompleted=v.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const r=document.getElementById("manage-tokens");r&&(r.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(w=>w.textContent=SITE.tld);const m=document.getElementById("edit-tld-suffix");m&&(m.textContent=SITE.tld);const l=document.getElementById("external-proxy-ip");l&&SITE.dnsIp&&(l.value=SITE.dnsIp,l.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const m=SITE.dnsServers[o];return m?`${m.ip}:${m.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,m]of Object.entries(SITE.dnsServers))if(m.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const m=Object.keys(SITE.dnsServers);if(!m.length)return;const l='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',w=o.firstElementChild;m.forEach(v=>{const r=escapeHtml(v),h=escapeHtml((SITE.dnsServers[v].name||v).toUpperCase()),n=document.createElement("div");n.className="card",n.setAttribute("data-app",v),n.setAttribute("data-status","off"),n.innerHTML=`<span id="${r}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${l}</div><span class="name">${h}</span><span class="spacer"></span><span id="${r}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${r}-time" class="response-time">--</span></div><div class="health-row" id="health-${r}"><span id="uptime-${r}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${r}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${r}-restart" class="restart-btn">Restart</button><button id="${r}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${r}-open">Open</button><button id="${r}-logs" class="logs-btn">Logs</button><button id="${r}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(n,w)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw errorHandler.logError("[CSRF] Get Token",o,{function:"getCSRFToken"}),o}}async function secureFetch(o,m={}){const l=(m.method||"GET").toUpperCase(),w=!["GET","HEAD","OPTIONS"].includes(l);if(w)try{const r=await getCSRFToken();m.headers={...m.headers,"X-CSRF-Token":r}}catch(r){errorHandler.logError("[CSRF] Add to Request",r,{function:"secureFetch"})}m.signal||(m={...m,signal:AbortSignal.timeout(15e3)});const v=await fetch(o,m);if(w&&v.status===403)try{const r=await v.clone().json();if(r.errLine truncated
position: fixed; top: 20px; right: 20px; position: fixed; top: 20px; right: 20px;
background: ${l.bg}; color: ${l.fg}; background: ${r.bg}; color: ${r.fg};
padding: 16px 24px; border-radius: 8px; padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3); box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out; z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px; max-width: 400px; white-space: pre-line; font-size: 14px;
`,document.body.appendChild(b),v>0&&setTimeout(()=>b.remove(),v)}function timeAgo(o){const i=Date.now()-new Date(o).getTime();return i<6e4?"just now":i<36e5?Math.floor(i/6e4)+"m ago":i<864e5?Math.floor(i/36e5)+"h ago":Math.floor(i/864e5)+"d ago"}function safeGet(o,i=null){try{const v=localStorage.getItem(o);return v!==null?v:i}catch{return i}}function safeSet(o,i){try{localStorage.setItem(o,i)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,i=null){try{const v=sessionStorage.getItem(o);return v!==null?v:i}catch{return i}}function safeSessionSet(o,i){try{sessionStorage.setItem(o,i)}catch{}}function safeGetJSON(o,i=null){try{const v=localStorage.getItem(o);return v?JSON.parse(v):i}catch{return i}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,i){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",i)}const DC_BUS={_handlers:{},on(o,i){var v;((v=this._handlers)[o]||(v[o]=[])).push(i)},off(o,i){this._handlers[o]=this._handlers[o]?.filter(v=>v!==i)},emit(o,i){this._handlers[o]?.forEach(v=>v(i))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(i=>i.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const i=this._apps.findIndex(v=>v.id===o);return i>-1&&(this._apps.splice(i,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),i>-1},updateApp(o,i){const v=this._apps.find(f=>f.id===o);if(v){for(const[f,m]of Object.entries(i))f!=="__proto__"&&f!=="constructor"&&f!=="prototype"&&(v[f]=m);DC_BUS.emit("apps:changed",this._apps)}return v}};(function(){function o(){const f=document.createElement("div");return f.className="skeleton-card",f.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',f}function i(f){const m=document.getElementById("cards");if(!(!m||m.querySelector(".card"))){f=f||6;for(let l=0;l<f;l++){const b=o();m.appendChild(b),setTimeout(function(){b.classList.add("loaded")},l*60)}}}function v(){const f=document.getElementById("cards");if(f){var m=f.querySelectorAll(".skeleton-card");m.length&&(m.forEach(function(l,b){setTimeout(function(){l.style.opacity="0",l.style.transform="translateY(-10px)"},b*25)}),setTimeout(function(){m.forEach(function(l){l.parentNode&&l.remove()})},m.length*25+300))}}window.SkeletonLoader={show:i,hide:v}})(),(function(){var o="theme",i="user-themes",v="custom-theme",f=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],m=f.slice(),l=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],b=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],a=["fg-muted","hover","card-hover","base","success","error","warning"],k={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated `,document.body.appendChild(h),l>0&&setTimeout(()=>h.remove(),l)}function timeAgo(o){const m=Date.now()-new Date(o).getTime();return m<6e4?"just now":m<36e5?Math.floor(m/6e4)+"m ago":m<864e5?Math.floor(m/36e5)+"h ago":Math.floor(m/864e5)+"d ago"}function safeGet(o,m=null){try{const l=localStorage.getItem(o);return l!==null?l:m}catch{return m}}function safeSet(o,m){try{localStorage.setItem(o,m)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,m=null){try{const l=sessionStorage.getItem(o);return l!==null?l:m}catch{return m}}function safeSessionSet(o,m){try{sessionStorage.setItem(o,m)}catch{}}function safeGetJSON(o,m=null){try{const l=localStorage.getItem(o);return l?JSON.parse(l):m}catch{return m}}function escapeHtml(o){return String(o??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(o,m){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",m)}const DC_BUS={_handlers:{},on(o,m){var l;((l=this._handlers)[o]||(l[o]=[])).push(m)},off(o,m){this._handlers[o]=this._handlers[o]?.filter(l=>l!==m)},emit(o,m){this._handlers[o]?.forEach(l=>l(m))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(m=>m.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const m=this._apps.findIndex(l=>l.id===o);return m>-1&&(this._apps.splice(m,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),m>-1},updateApp(o,m){const l=this._apps.find(w=>w.id===o);if(l){for(const[w,v]of Object.entries(m))w!=="__proto__"&&w!=="constructor"&&w!=="prototype"&&(l[w]=v);DC_BUS.emit("apps:changed",this._apps)}return l}};(function(){function o(){const w=document.createElement("div");return w.className="skeleton-card",w.innerHTML='<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px"><div class="skeleton-bar" style="width:48px;height:48px;border-radius:8px;flex-shrink:0"></div><div style="flex:1"><div class="skeleton-bar" style="width:60%;height:14px;margin-bottom:6px"></div><div class="skeleton-bar" style="width:35%;height:10px"></div></div><div class="skeleton-bar" style="width:42px;height:22px;border-radius:11px"></div></div><div class="skeleton-bar" style="width:45%;height:12px;margin-bottom:10px"></div><div style="display:flex;gap:8px;margin-top:auto"><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div><div class="skeleton-bar" style="width:64px;height:28px;border-radius:8px"></div></div>',w}function m(w){const v=document.getElementById("cards");if(!(!v||v.querySelector(".card"))){w=w||6;for(let r=0;r<w;r++){const h=o();v.appendChild(h),setTimeout(function(){h.classList.add("loaded")},r*60)}}}function l(){const w=document.getElementById("cards");if(w){var v=w.querySelectorAll(".skeleton-card");v.length&&(v.forEach(function(r,h){setTimeout(function(){r.style.opacity="0",r.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){v.forEach(function(r){r.parentNode&&r.remove()})},v.length*25+300))}}window.SkeletonLoader={show:m,hide:l}})(),(function(){var o="theme",m="user-themes",l="custom-theme",w=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],v=w.slice(),r=["bg","fg","muted","fg-muted","card-base","card-bg","border","hover","card-hover","base","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","success","error","warning","accent","accent-strong"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],n=["fg-muted","hover","card-hover","base","success","error","warning"],b={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background: background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+g.r+","+g.g+","+g.b+`, .08), transparent 60%), radial-gradient(1200px 800px at 10% -10%, rgba(`+y.r+","+y.g+","+y.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+g.r+","+g.g+","+g.b+`, .05), transparent 55%), radial-gradient(1000px 700px at 110% 10%, rgba(`+y.r+","+y.g+","+y.b+`, .05), transparent 55%),
var(--bg); var(--bg);
} }
`:":root."+x+` body { `:":root."+E+` body {
background: background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+g.r+","+g.g+","+g.b+`, .10), transparent 60%), radial-gradient(1200px 900px at 8% -12%, rgba(`+y.r+","+y.g+","+y.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+g.r+","+g.g+","+g.b+`, .07), transparent 55%), radial-gradient(1000px 700px at 110% -10%, rgba(`+y.r+","+y.g+","+y.b+`, .07), transparent 55%),
var(--bg); var(--bg);
} }
`}function p(x,T){var $=T.lightBg||T.bg&&h(T.bg)>.4;return $?":root."+x+` button:hover { `}function u(E,L){var $=L.lightBg||L.bg&&g(L.bg)>.4;return $?":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%); background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
border-color: rgba(0, 0, 0, .15); border-color: rgba(0, 0, 0, .15);
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8); box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
} }
`:":root."+x+` button:hover { `:":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent); background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
} }
`}function n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){l.forEach(function(x){document.documentElement.style.removeProperty("--"+x)})}function y(x,T){var $=x.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),f.indexOf($)!==-1&&($=$+"-custom");for(var w=safeGetJSON(i,{}),g=$,C=2;w[$]&&$!==T;)$=g+"-"+C++;return $}function r(x){var T=document.getElementById("user-theme-styles");T&&T.remove(),m.length=f.length,Object.keys(k).forEach(function(E){f.indexOf(E)===-1&&delete k[E]});var $=x||safeGetJSON(i,{}),w=Object.keys($);if(w=w.filter(function(E){return f.indexOf(E)===-1}),!!w.length){var g="";w.forEach(function(E){var S=$[E];m.indexOf(E)===-1&&m.push(E);var P={};l.forEach(function(O){S[O]&&(P[O]=S[O])}),P["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(P.lightBg=!0);var N=e(P);a.forEach(function(O){!P[O]&&N[O]&&(P[O]=N[O])}),k[E]=P,g+=":root."+E+` { `}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function d(){r.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function f(E,L){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),w.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(m,{}),y=$,I=2;x[$]&&$!==L;)$=y+"-"+I++;return $}function i(E){var L=document.getElementById("user-theme-styles");L&&L.remove(),v.length=w.length,Object.keys(b).forEach(function(S){w.indexOf(S)===-1&&delete b[S]});var $=E||safeGetJSON(m,{}),x=Object.keys($);if(x=x.filter(function(S){return w.indexOf(S)===-1}),!!x.length){var y="";x.forEach(function(S){var C=$[S];v.indexOf(S)===-1&&v.push(S);var P={};r.forEach(function(O){C[O]&&(P[O]=C[O])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var D=e(P);n.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),b[S]=P,y+=":root."+S+` {
`,l.forEach(function(O){P[O]&&(g+=" --"+O+": "+P[O]+`; `,r.forEach(function(O){P[O]&&(y+=" --"+O+": "+P[O]+`;
`)}),g+=`} `)}),y+=`}
`,g+=s(E,P),g+=p(E,P)});var C=document.createElement("style");C.id="user-theme-styles",C.textContent=g,document.head.appendChild(C)}}function I(){secureFetch("/api/v1/themes").then(function(x){return x.json()}).then(function(x){if(!(!x.success||!x.themes)){var T=x.themes,$=safeGetJSON(i,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(i,JSON.stringify(T)),r(T);var w=safeGet(o);w&&m.indexOf(w)!==-1&&L(w)}}}).catch(function(){})}function B(){var x=safeGetJSON(v);if(x){var T=x.name||"Custom",$=y(T),w={name:T};l.forEach(function(E){x[E]&&(w[E]=x[E])});var g=safeGetJSON(i,{});g[$]=w,safeSet(i,JSON.stringify(g)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(v);var C={};l.forEach(function(E){w[E]&&(C[E]=w[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:C})}).catch(function(){})}}function L(x){document.documentElement.classList.add("theme-transitioning"),m.forEach(function(g){g!=="dark"&&document.documentElement.classList.remove(g)}),c(),x!=="dark"&&document.documentElement.classList.add(x),safeSet(o,x);var T=k[x],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var w=T&&T.lightBg;!w&&T&&T.bg&&(w=h(T.bg)>.4),w?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),r();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&m.indexOf(A)===-1&&(A=null),L(A||n()),I(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(x){safeGet(o)||L(x.matches?"dark":"light")}),window.THEMES=m,window.BUILTIN_THEMES=f,window.THEME_COLORS=k,window.THEME_PROPS=l,window.BASE_PROPS=b,window.DERIVED_PROPS=a,window.USER_THEMES_KEY=i,window.applyTheme=L,window.clearCustomProperties=c,window.injectUserThemeStyles=r,window.syncThemesFromServer=I,window.slugifyThemeName=y,window.getActiveTheme=function(){return safeGet(o)||n()},window.deriveExtendedColors=e,window.hexToRgb=t,window.rgbToHex=d,window.blendColors=u})(),(function(){function o(){const b=document.querySelector(".totp-card");if(!b)return;const k=getComputedStyle(b).backgroundColor.match(/\d+/g);if(!k)return;const t=(.299*+k[0]+.587*+k[1]+.114*+k[2])/255,d=b.querySelector(".totp-logo-dark"),u=b.querySelector(".totp-logo-light");d&&(d.style.display=t>.5?"none":""),u&&(u.style.display=t>.5?"":"none")}function i(){const b=document.getElementById("totp-overlay");if(b){b.classList.add("show"),setTimeout(o,50);const a=b.querySelector(".totp-digits input");a&&setTimeout(()=>a.focus(),100)}}function v(){const b=document.getElementById("totp-overlay");b&&b.classList.remove("show")}const f=document.getElementById("totp-digits");if(f){const b=f.querySelectorAll("input");b.forEach((a,k)=>{a.addEventListener("input",t=>{const d=t.target.value.replace(/\D/g,"");t.target.value=d.slice(0,1),d&&k<b.length-1&&b[k+1].focus();const u=Array.from(b).map(h=>h.value).join("");u.length===6&&m(u)}),a.addEventListener("keydown",t=>{t.key==="Backspace"&&!t.target.value&&k>0&&(b[k-1].focus(),b[k-1].value="")}),a.addEventListener("paste",t=>{t.preventDefault();const d=(t.clipboardData.getData("text")||"").replace(/\D/g,"");d.length>=6&&(b.forEach((u,h)=>{u.value=d[h]||""}),b[5].focus(),m(d.slice(0,6)))})})}async function m(b){const a=document.getElementById("totp-error");a.textContent="Verifying...",a.className="totp-error verifying";try{const t=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();if(t.success){a.textContent="",t.csrfToken&&(csrfToken=t.csrfToken),v();const d=safeSessionGet("totp_redirect");if(d){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=d;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{a.textContent=t.error||"Invalid code",a.className="totp-error";const d=document.querySelectorAll("#totp-digits input");d.forEach(u=>{u.value=""}),d[0]?.focus()}}catch{a.textContent="Connection error",a.className="totp-error"}}const l=new URLSearchParams(window.location.search);if(l.get("auth")==="required"){const b=l.get("return");if(b)try{const a=new URL(b,window.location.origin),k=a.hostname,t=a.origin===window.location.origin,d=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,u=k.endsWith(d)||k===d.substring(1);(t||u)&&safeSessionSet("totp_redirect",b)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=i})(),(function(){injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal"> `,y+=a(S,P),y+=u(S,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=y,document.head.appendChild(I)}}function k(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var L=E.themes,$=safeGetJSON(m,{});if(JSON.stringify(L)!==JSON.stringify($)){safeSet(m,JSON.stringify(L)),i(L);var x=safeGet(o);x&&v.indexOf(x)!==-1&&T(x)}}}).catch(function(){})}function B(){var E=safeGetJSON(l);if(E){var L=E.name||"Custom",$=f(L),x={name:L};r.forEach(function(S){E[S]&&(x[S]=E[S])});var y=safeGetJSON(m,{});y[$]=x,safeSet(m,JSON.stringify(y)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(l);var I={};r.forEach(function(S){x[S]&&(I[S]=x[S])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:L,colors:I})}).catch(function(){})}}function T(E){document.documentElement.classList.add("theme-transitioning"),v.forEach(function(y){y!=="dark"&&document.documentElement.classList.remove(y)}),d(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(o,E);var L=b[E],$=document.querySelector('meta[name="theme-color"]');$&&L&&$.setAttribute("content",L.bg);var x=L&&L.lightBg;!x&&L&&L.bg&&(x=g(L.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),i();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&v.indexOf(A)===-1&&(A=null),T(A||t()),k(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(o)||T(E.matches?"dark":"light")}),window.THEMES=v,window.BUILTIN_THEMES=w,window.THEME_COLORS=b,window.THEME_PROPS=r,window.BASE_PROPS=h,window.DERIVED_PROPS=n,window.USER_THEMES_KEY=m,window.applyTheme=T,window.clearCustomProperties=d,window.injectUserThemeStyles=i,window.syncThemesFromServer=k,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=p,window.rgbToHex=s,window.blendColors=c})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const b=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!b)return;const p=(.299*+b[0]+.587*+b[1]+.114*+b[2])/255,s=h.querySelector(".totp-logo-dark"),c=h.querySelector(".totp-logo-light");s&&(s.style.display=p>.5?"none":""),c&&(c.style.display=p>.5?"":"none")}function m(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const n=h.querySelector(".totp-digits input");n&&setTimeout(()=>n.focus(),100)}}function l(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const w=document.getElementById("totp-digits");if(w){const h=w.querySelectorAll("input");h.forEach((n,b)=>{n.addEventListener("input",p=>{const s=p.target.value.replace(/\D/g,"");p.target.value=s.slice(0,1),s&&b<h.length-1&&h[b+1].focus();const c=Array.from(h).map(g=>g.value).join("");c.length===6&&v(c)}),n.addEventListener("keydown",p=>{p.key==="Backspace"&&!p.target.value&&b>0&&(h[b-1].focus(),h[b-1].value="")}),n.addEventListener("paste",p=>{p.preventDefault();const s=(p.clipboardData.getData("text")||"").replace(/\D/g,"");s.length>=6&&(h.forEach((c,g)=>{c.value=s[g]||""}),h[5].focus(),v(s.slice(0,6)))})})}async function v(h){const n=document.getElementById("totp-error");n.textContent="Verifying...",n.className="totp-error verifying";try{const p=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(p.success){n.textContent="",p.csrfToken&&(csrfToken=p.csrfToken),l();const s=safeSessionGet("totp_redirect");if(s){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=s;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{n.textContent=p.error||"Invalid code",n.className="totp-error";const s=document.querySelectorAll("#totp-digits input");s.forEach(c=>{c.value=""}),s[0]?.focus()}}catch{n.textContent="Connection error",n.className="totp-error"}}const r=new URLSearchParams(window.location.search);if(r.get("auth")==="required"){const h=r.get("return");if(h)try{const n=new URL(h,window.location.origin),b=n.hostname,p=n.origin===window.location.origin,s=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,c=b.endsWith(s)||b===s.substring(1);(p||c)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=m})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>\u{1F4C2} Browse for Media Folders</h3> <h3>\u{1F4C2} Browse for Media Folders</h3>
@@ -127,7 +127,7 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</button> </button>
</div> </div>
</div> </div>
</div>`);const o=document.getElementById("service-creds-modal");let i=null;const v=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function m(t){return t.externalUrl||t.url||""}function l(t){const d=document.getElementById("svc-creds-error");d.textContent=t,d.style.display=""}function b(){const t=document.getElementById("svc-creds-error");t.textContent="",t.style.display="none"}window.openServiceCredsModal=async function(t){i=t,b();const d=document.getElementById("svc-creds-title"),u=document.getElementById("svc-creds-desc"),h=document.getElementById("svc-creds-seedhost"),e=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic"),p=document.getElementById("svc-creds-quality");d.textContent=t.name+" Credentials";const n=!!t.isExternal,c=v.includes(t.id)||v.includes(t.appTemplate),y=f.includes(t.id)||f.includes(t.appTemplate);h.style.display=n?"":"none",e.style.display=c?"":"none",p.style.display=y?"":"none",s.style.display=n?"none":"";const r=document.getElementById("svc-quality-select");r.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",n?(u.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${t.name}`):c?u.textContent="API key bypasses the app login screen automatically.":u.textContent="Credentials are injected automatically when accessing this service.",await a(t),o.classList.add("show")};async function a(t){const d=document.getElementById("svc-creds-dot"),u=document.getElementById("svc-creds-status"),h=document.getElementById("svc-creds-clear");let e=!1;if(t.isExternal){try{const n=await(await fetch(`/api/v1/seedhost-creds?serviceId=${t.id}`)).json();n.success?(document.getElementById("svc-seedhost-user").value=n.username||"",n.hasCredentials&&(e=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const n=await(await fetch(`/api/v1/services/${t.id}/credentials`)).json();n.success&&(n.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",e=!0):document.getElementById("svc-apikey-input").value="",n.hasBasicAuth&&!t.isExternal?(document.getElementById("svc-basic-user").value=n.username||"",e=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const s=t.id||t.appTemplate;if(f.includes(s)&&await k(t),e){d.style.background="var(--ok-fg, #74dfc4)",u.style.color="var(--ok-fg, #74dfc4)",u.textContent="Credentials stored",h.style.display="";const p=document.getElementById(`creds-btn-${t.id}`);p&&p.classList.add("has-creds")}else d.style.background="var(--muted)",u.style.color="var(--muted)",u.textContent="No credentials stored",h.style.display="none"}async function k(t){const d=document.getElementById("svc-quality-select"),u=document.getElementById("svc-quality-status"),h=t.id||t.appTemplate,e=m(t);if(!e){d.innerHTML='<option value="">-- No service URL --</option>';return}d.innerHTML='<option value="">Loading...</option>',u.textContent="";try{const s=new URLSearchParams({service:h,url:e}),n=await(await fetch(`/api/v1/arr/quality-profiles?${s}`)).json();if(!n.success||!n.profiles?.length){d.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}d.innerHTML="";for(const c of n.profiles){const y=document.createElement("option");y.value=c.id,y.textContent=c.name,d.appendChild(y)}if(n.storedProfileId&&(d.value=String(n.storedProfileId)),!d.value){const c=n.profiles.find(y=>/720/i.test(y.name));c&&(d.value=String(c.id))}!d.value&&n.profiles.length&&(d.value=String(n.profiles[0].id)),u.innerHTML=`<span style="color: var(--ok-fg);">${n.profiles.length} profiles loaded</span>`}catch(s){d.innerHTML='<option value="">-- Failed to load --</option>',u.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${s.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!i)return;const t=i.id||i.appTemplate,d=m(i),h=document.getElementById("svc-apikey-input")?.value.trim(),e=document.getElementById("svc-quality-select"),s=document.getElementById("svc-quality-status");if(!d){s.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!h||h==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){s.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}e.innerHTML='<option value="">Fetching...</option>',s.textContent="";try{const p=new URLSearchParams({service:t,url:d,apiKey:h}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success){e.innerHTML='<option value="">-- Error --</option>',s.innerHTML=`<span style="color: var(--error, #c62828);">${c.error||"Failed to feLine truncated </div>`);const m=document.getElementById("service-creds-modal");let l=null;const w=["sonarr","radarr","prowlarr","overseerr"],v=["sonarr","radarr"];function r(s){return s.externalUrl||s.url||""}function h(s){const c=document.getElementById("svc-creds-error");c.textContent=s,c.style.display=""}function n(){const s=document.getElementById("svc-creds-error");s.textContent="",s.style.display="none"}window.openServiceCredsModal=async function(s){l=s,n();const c=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),a=document.getElementById("svc-creds-apikey"),u=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");c.textContent=s.name+" Credentials";const d=!!s.isExternal,f=w.includes(s.id)||w.includes(s.appTemplate),i=v.includes(s.id)||v.includes(s.appTemplate);e.style.display=d?"":"none",a.style.display=f?"":"none",t.style.display=i?"":"none",u.style.display=d?"none":"";const k=document.getElementById("svc-quality-select");k.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",d?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${s.name}`):f?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await b(s),m.classList.add("show")};async function b(s){const c=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let a=!1;if(s.isExternal){try{const d=await(await fetch(`/api/v1/seedhost-creds?serviceId=${s.id}`)).json();d.success?(document.getElementById("svc-seedhost-user").value=d.username||"",d.hasCredentials&&(a=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const d=await(await fetch(`/api/v1/services/${s.id}/credentials`)).json();d.success&&(d.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",a=!0):document.getElementById("svc-apikey-input").value="",d.hasBasicAuth&&!s.isExternal?(document.getElementById("svc-basic-user").value=d.username||"",a=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const u=s.id||s.appTemplate;if(v.includes(u)&&await p(s),a){c.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${s.id}`);t&&t.classList.add("has-creds")}else c.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function p(s){const c=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=s.id||s.appTemplate,a=r(s);if(!a){c.innerHTML='<option value="">-- No service URL --</option>';return}c.innerHTML='<option value="">Loading...</option>',g.textContent="";try{const u=new URLSearchParams({service:e,url:a}),d=await(await fetch(`/api/v1/arr/quality-profiles?${u}`)).json();if(!d.success||!d.profiles?.length){c.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}c.innerHTML="";for(const f of d.profiles){const i=document.createElement("option");i.value=f.id,i.textContent=f.name,c.appendChild(i)}if(d.storedProfileId&&(c.value=String(d.storedProfileId)),!c.value){const f=d.profiles.find(i=>/720/i.test(i.name));f&&(c.value=String(f.id))}!c.value&&d.profiles.length&&(c.value=String(d.profiles[0].id)),g.innerHTML=`<span style="color: var(--ok-fg);">${d.profiles.length} profiles loaded</span>`}catch(u){c.innerHTML='<option value="">-- Failed to load --</option>',g.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${u.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!l)return;const s=l.id||l.appTemplate,c=r(l),e=document.getElementById("svc-apikey-input")?.value.trim(),a=document.getElementById("svc-quality-select"),u=document.getElementById("svc-quality-status");if(!c){u.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){u.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}a.innerHTML='<option value="">Fetching...</option>',u.textContent="";try{const t=new URLSearchParams({service:s,url:c,apiKey:e}),f=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!f.success){a.innerHTML='<option value="">-- Error --</option>',u.innerHTML=`<span style="color: var(--error, #c62828);">${f.error||"Failed to feLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3> <h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -222,7 +222,7 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</button> </button>
</div> </div>
</div> </div>
</div>`);async function o(){try{const m=await(await fetch("/api/v1/totp/config")).json();if(!m.success)return;const{enabled:l,sessionDuration:b,isSetUp:a}=m.config,k=document.getElementById("totp-status-dot"),t=document.getElementById("totp-status-text"),d=document.getElementById("totp-status-banner"),u=document.getElementById("totp-setup-section"),h=document.getElementById("totp-qr-section"),e=document.getElementById("totp-duration-section"),s=document.getElementById("totp-disable-section");l&&a?(k.style.background="var(--ok-fg, #7ef2ff)",d.style.borderColor="var(--ok-fg, #7ef2ff)",d.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",t.textContent="TOTP is active",t.style.color="var(--ok-fg, #7ef2ff)",u.style.display="none",h.style.display="none",e.style.display="block",s.style.display="block",document.getElementById("totp-duration-select").value=b):(k.style.background="var(--muted)",d.style.borderColor="var(--border)",d.style.background="transparent",t.textContent="TOTP is not configured",t.style.color="var(--muted)",u.style.display="block",h.style.display="none",e.style.display="none",s.style.display="none"),v(l&&a,b)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const i={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function v(f,m){const l=document.getElementById("auth-card"),b=document.getElementById("auth-pill"),a=document.getElementById("auth-dot"),k=document.getElementById("auth-status-text");l&&(f?(l.setAttribute("data-status","on"),b.className="badge on",b.textContent="YES",a.className="dot ok at-bl",k.textContent="Session: "+(i[m]||m)):(l.setAttribute("data-status","off"),b.className="badge off",b.textContent="NO",a.className="dot bad at-bl",k.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const m=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();m.success&&(document.getElementById("totp-qr-image").src=m.qrCode,document.getElementById("totp-manual-key").textContent=m.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){console.error("TOTP setup failed:",f)}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),m=document.getElementById("totp-import-error");if(m.textContent="",!f){m.textContent="Paste a Base32 secret key first";return}try{const b=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();b.success?(m.textContent="",document.getElementById("totp-qr-image").src=b.qrCode,document.getElementById("totp-manual-key").textContent=b.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):m.textContent=b.error||b.message||"Import failed"}catch{m.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const m=document.getElementById("totp-copy-key");m.textContent="\u2705",setTimeout(()=>{m.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=document.getElementById("totp-setup-code").value,m=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(f)){m.textContent="Enter a 6-digit code";return}try{const b=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();b.success?(m.textContent="",o()):m.textContent=b.error||"Invalid code"}catch{m.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",f=>{f.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async f=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:f.target.value})}),o()}catch(m){console.error("Failed to update session duration:",m)}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POLine truncated </div>`);async function m(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:h,sessionDuration:n,isSetUp:b}=r.config,p=document.getElementById("totp-status-dot"),s=document.getElementById("totp-status-text"),c=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),a=document.getElementById("totp-duration-section"),u=document.getElementById("totp-disable-section");h&&b?(p.style.background="var(--ok-fg, #7ef2ff)",c.style.borderColor="var(--ok-fg, #7ef2ff)",c.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",s.textContent="TOTP is active",s.style.color="var(--ok-fg, #7ef2ff)",g.style.display="none",e.style.display="none",a.style.display="block",u.style.display="block",document.getElementById("totp-duration-select").value=n):(p.style.background="var(--muted)",c.style.borderColor="var(--border)",c.style.background="transparent",s.textContent="TOTP is not configured",s.style.color="var(--muted)",g.style.display="block",e.style.display="none",a.style.display="none",u.style.display="none"),w(h&&b,n)}catch(v){console.warn("Failed to load TOTP settings:",v)}}const l={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function w(v,r){const h=document.getElementById("auth-card"),n=document.getElementById("auth-pill"),b=document.getElementById("auth-dot"),p=document.getElementById("auth-status-text");h&&(v?(h.setAttribute("data-status","on"),n.className="badge on",n.textContent="YES",b.className="dot ok at-bl",p.textContent="Session: "+(l[r]||r)):(h.setAttribute("data-status","off"),n.className="badge off",n.textContent="NO",b.className="dot bad at-bl",p.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(v){o.logError("[TOTP] Setup Failed",v,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const v=document.getElementById("totp-import-key").value.trim(),r=document.getElementById("totp-import-error");if(r.textContent="",!v){r.textContent="Paste a Base32 secret key first";return}try{const n=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:v})})).json();n.success?(r.textContent="",document.getElementById("totp-qr-image").src=n.qrCode,document.getElementById("totp-manual-key").textContent=n.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):r.textContent=n.error||n.message||"Import failed"}catch{r.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const v=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(v).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const v=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(v)){r.textContent="Enter a 6-digit code";return}try{const n=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();n.success?(r.textContent="",m()):r.textContent=n.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",v=>{v.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async v=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:v.target.value})}),m()}catch(r){o.logError("[TOTP] Update Session Duration",r,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(awaLine truncated
<div id="token-management-modal" class="weather-modal"> <div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3> <h3>\u{1F511} DNS Credentials</h3>
@@ -240,40 +240,40 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</div> </div>
</div> </div>
</div> </div>
`);function o(){return Object.keys(SITE.dnsServers||{})}function i(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function v(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const c=o();if(c.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const y of c)n.insertAdjacentHTML("beforeend",` `);function o(){return Object.keys(SITE.dnsServers||{})}function m(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function l(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const d=o();if(d.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const f of d)t.insertAdjacentHTML("beforeend",`
<div class="token-section"> <div class="token-section">
<h4 class="token-section-title">${i(y)}</h4> <h4 class="token-section-title">${m(f)}</h4>
<div class="token-grid"> <div class="token-grid">
<div class="token-field"> <div class="token-field">
<label for="${y}-readonly-username">\u{1F4D6} Read-Only (Logs):</label> <label for="${f}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${y}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${f}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${y}-readonly-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${f}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${y}-readonly-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${f}-readonly-token">\u{1F441}</button>
</div> </div>
</div> </div>
<div class="token-field"> <div class="token-field">
<label for="${y}-admin-username">\u{1F527} Admin:</label> <label for="${f}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${y}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" /> <input type="text" id="${f}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row"> <div class="token-input-row">
<input type="password" id="${y}-admin-token" placeholder="Password" autocomplete="off" /> <input type="password" id="${f}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${y}-admin-token">\u{1F441}</button> <button type="button" class="token-toggle" data-target="${f}-admin-token">\u{1F441}</button>
</div> </div>
</div> </div>
</div> </div>
<div class="token-status" id="${y}-token-status"></div> <div class="token-status" id="${f}-token-status"></div>
</div> </div>
`)}function f(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const y=new Uint8Array(32);return crypto.getRandomValues(y),n=Array.from(y,r=>r.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const m=f();function l(n,c){if(!n)return"";const y=crypto.getRandomValues(new Uint8Array(8)),r=Array.from(y,L=>L.toString(16).padStart(2,"0")).join(""),I=new TextEncoder().encode(c+r);let B="";for(let L=0;L<n.length;L++){const A=n.charCodeAt(L)^I[L%I.length]^y[L%y.length]^L*31+17&255;B+=String.fromCharCode(A)}return r+":"+btoa(B)}function b(n,c){if(!n)return"";try{if(n.indexOf(":")===16){const B=n.substring(0,16),L=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(n.substring(17)),x=new TextEncoder().encode(c+B);let T="";for(let $=0;$<A.length;$++){const w=A.charCodeAt($)^x[$%x.length]^L[$%L.length]^$*31+17&255;T+=String.fromCharCode(w)}return T}const r=atob(n);let I="";for(let B=0;B<r.length;B++){const L=r.charCodeAt(B)^c.charCodeAt(B%c.length);I+=String.fromCharCode(L)}return I}catch{return""}}function a(n,c,y){const r=safeGet(`${n}-${c}-${y}-enc`);return b(r,m)}function k(n,c,y,r){const I=`${n}-${c}-${y}-enc`;r?safeSet(I,l(r,m)):safeRemove(I)}function t(n,c){return a(n,c,"token")}function d(n,c){return a(n,c,"username")}function u(n,c,y){k(n,c,"token",y)}function h(n,c,y){k(n,c,"username",y)}function e(){const n={};for(const c of o())n[c]={readonly:{username:d(c,"readonly"),token:t(c,"readonly")},admin:{username:d(c,"admin"),token:t(c,"admin")}};return n}function s(){o().forEach(n=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(y=>{safeRemove(`${n}-${c}-${y}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function p(n){const c=t(n,"readonly"),y=d(n,"readonly"),r=t(n,"admin"),I=d(n,"admin"),B=b(safeGet(`${n}-token-enc`),m),L=b(safeGet(`${n}-username-enc`),m);return{username:I||y||L,token:r||c||B,readonlyToken:c||B,readonlyUsername:y||L,adminToken:r||B,adminUsername:I||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{v();const n=document.getElementById("token-management-modal"),c=e();o().forEach(y=>{const r=c[y];document.getElementById(`${y}-readonly-username`).value=r.readonly.username,document.getElementById(`${y}-readonly-token`).value=r.readonly.token,document.getElementById(`${y}-admin-username`).value=r.admin.username,document.getElementById(`${y}-admin-token`).value=r.admin.token,document.getElementById(`${y}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const c=n.target.closest(".token-toggle");if(c){const y=c.dataset.target,r=document.getElementById(y);r.type==="password"?(r.type="text",c.textContent="\u{1F648}"):(r.type="password",c.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=o();n.forEach(r=>{h(r,"readonly",document.getElementById(`${r}-readonly-username`).value.trim()),u(r,"readonly",document.getElementById(`${r}-readonly-token`).value.trim()),h(r,"admin",document.getElementById(`${r}-admin-username`).value.trim()),u(r,"admin",document.getElementById(`${r}-admin-token`).value.trim())});const c={};let y=!1;if(n.forEach(r=>{const I={},B=document.getElementById(`${r}-readonly-username`).value.trim(),L=document.getElementById(`${r}-readonly-token`).value.trim(),A=document.getElementById(`${r}-admin-username`).value.trim(),x=document.getElementById(`${r}-admin-token`).value.trim();B&&L&&(I.readonly={username:B,password:L},y=!0),A&&x&&(I.admin={username:A,password:x},y=!0),Object.keys(I).length>0&&(c[r]=I)}),y){n.forEach(r=>{c[r]&&(document.getElementById(`${r}-token-status`).textContent="Verifying...",document.getElementById(`${r}-token-status`).className="token-status")});try{const I=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();I.results?n.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!c[B]){L.textContent="";return}const A=I.results[B];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):I.success?n.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):n.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(I.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(r){console.erro Line truncated `)}function w(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const d=safeGet("dashcaddy-encryption-key");if(d)return safeSessionSet("dashcaddy-encryption-key",d),safeRemove("dashcaddy-encryption-key"),d;const f=new Uint8Array(32);return crypto.getRandomValues(f),t=Array.from(f,i=>i.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const v=w();function r(t,d){if(!t)return"";const f=crypto.getRandomValues(new Uint8Array(8)),i=Array.from(f,T=>T.toString(16).padStart(2,"0")).join(""),k=new TextEncoder().encode(d+i);let B="";for(let T=0;T<t.length;T++){const A=t.charCodeAt(T)^k[T%k.length]^f[T%f.length]^T*31+17&255;B+=String.fromCharCode(A)}return i+":"+btoa(B)}function h(t,d){if(!t)return"";try{if(t.indexOf(":")===16){const B=t.substring(0,16),T=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),E=new TextEncoder().encode(d+B);let L="";for(let $=0;$<A.length;$++){const x=A.charCodeAt($)^E[$%E.length]^T[$%T.length]^$*31+17&255;L+=String.fromCharCode(x)}return L}const i=atob(t);let k="";for(let B=0;B<i.length;B++){const T=i.charCodeAt(B)^d.charCodeAt(B%d.length);k+=String.fromCharCode(T)}return k}catch{return""}}function n(t,d,f){const i=safeGet(`${t}-${d}-${f}-enc`);return h(i,v)}function b(t,d,f,i){const k=`${t}-${d}-${f}-enc`;i?safeSet(k,r(i,v)):safeRemove(k)}function p(t,d){return n(t,d,"token")}function s(t,d){return n(t,d,"username")}function c(t,d,f){b(t,d,"token",f)}function g(t,d,f){b(t,d,"username",f)}function e(){const t={};for(const d of o())t[d]={readonly:{username:s(d,"readonly"),token:p(d,"readonly")},admin:{username:s(d,"admin"),token:p(d,"admin")}};return t}function a(){o().forEach(t=>{["readonly","admin"].forEach(d=>{["token","username"].forEach(f=>{safeRemove(`${t}-${d}-${f}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function u(t){const d=p(t,"readonly"),f=s(t,"readonly"),i=p(t,"admin"),k=s(t,"admin"),B=h(safeGet(`${t}-token-enc`),v),T=h(safeGet(`${t}-username-enc`),v);return{username:k||f||T,token:i||d||B,readonlyToken:d||B,readonlyUsername:f||T,adminToken:i||B,adminUsername:k||T}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{l();const t=document.getElementById("token-management-modal"),d=e();o().forEach(f=>{const i=d[f];document.getElementById(`${f}-readonly-username`).value=i.readonly.username,document.getElementById(`${f}-readonly-token`).value=i.readonly.token,document.getElementById(`${f}-admin-username`).value=i.admin.username,document.getElementById(`${f}-admin-token`).value=i.admin.token,document.getElementById(`${f}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const d=t.target.closest(".token-toggle");if(d){const f=d.dataset.target,i=document.getElementById(f);i.type==="password"?(i.type="text",d.textContent="\u{1F648}"):(i.type="password",d.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(i=>{g(i,"readonly",document.getElementById(`${i}-readonly-username`).value.trim()),c(i,"readonly",document.getElementById(`${i}-readonly-token`).value.trim()),g(i,"admin",document.getElementById(`${i}-admin-username`).value.trim()),c(i,"admin",document.getElementById(`${i}-admin-token`).value.trim())});const d={};let f=!1;if(t.forEach(i=>{const k={},B=document.getElementById(`${i}-readonly-username`).value.trim(),T=document.getElementById(`${i}-readonly-token`).value.trim(),A=document.getElementById(`${i}-admin-username`).value.trim(),E=document.getElementById(`${i}-admin-token`).value.trim();B&&T&&(k.readonly={username:B,password:T},f=!0),A&&E&&(k.admin={username:A,password:E},f=!0),Object.keys(k).length>0&&(d[i]=k)}),f){t.forEach(i=>{d[i]&&(document.getElementById(`${i}-token-status`).textContent="Verifying...",document.getElementById(`${i}-token-status`).className="token-status")});try{const k=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:d})})).json();k.results?t.forEach(B=>{const T=document.getElementById(`${B}-token-status`);if(!d[B]){T.textContent="";return}const A=k.results[B];A?.success?(T.textContent="\u2713 Verified & saved",T.className="token-status success"):A?.partial?(T.textContent="\u2713 "+A.partial,T.className="token-status success"):(T.textContent="\u2717 "+(A?.error||"Login failed"),T.className="token-status error")}):k.success?t.forEach(B=>{d[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):t.forEach(B=>{d[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(k.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(i){console.erro Line truncated
Current: ${h.currentVersion} Current: ${g.currentVersion}
New: ${h.updateVersion} New: ${g.updateVersion}
`+(h.updateTitle?`${h.updateTitle} `+(g.updateTitle?`${g.updateTitle}
`:"")+`The DNS server will restart during the update. `:"")+`The DNS server will restart during the update.
Proceed?`)){t.textContent=d,t.disabled=!1,t.title="Update DNS server";return}t.textContent="\u{1F504}",t.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(k)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){t.textContent="\u2B06\uFE0F",t.title=`Update available: ${p.newVersion}`;const n=p.downloadLink?` Proceed?`)){p.textContent=s,p.disabled=!1,p.title="Update DNS server";return}p.textContent="\u{1F504}",p.title="Updating...";const u=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(b)}`,{method:"POST"})).json();if(!u.success)throw new Error(u.error||"Update failed");if(u.manualUpdateRequired){p.textContent="\u2B06\uFE0F",p.title=`Update available: ${u.newVersion}`;const t=u.downloadLink?`
Download: ${p.downloadLink}`:"",c=p.instructionsLink?` Download: ${u.downloadLink}`:"",d=u.instructionsLink?`
Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),t.disabled=!1;return}t.textContent="\u2705",t.title="Updated successfully!",showNotification(`${a.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server",window.refreshAll()},1e4)}catch(u){console.error("DNS update error:",u),t.textContent="\u274C",t.title="Update failed",showNotification(`Failed to update ${a.toUpperCase()}: ${u.message}`,"error"),setTimeout(()=>{t.textContent=d,t.disabled=!1,t.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",a=>{const k=a.target.closest('[id$="-update"]');if(!k)return;const t=k.id.replace("-update","");SITE.dnsServers[t]&&i(t,SITE.dnsServers[t]?.ip)}),injectModal("dns-settings-modal",` Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} update requires manual installation. Current: ${u.previousVersion} \u2192 ${u.newVersion}. Please update manually on the host machine.`,"warning",8e3),p.disabled=!1;return}p.textContent="\u2705",p.title="Updated successfully!",showNotification(`${n.toUpperCase()} updated successfully! ${u.previousVersion} \u2192 ${u.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{p.textContent=s,p.disabled=!1,p.title="Update DNS server",window.refreshAll()},1e4)}catch(c){console.error("DNS update error:",c),p.textContent="\u274C",p.title="Update failed",showNotification(`Failed to update ${n.toUpperCase()}: ${c.message}`,"error"),setTimeout(()=>{p.textContent=s,p.disabled=!1,p.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",n=>{const b=n.target.closest('[id$="-update"]');if(!b)return;const p=b.id.replace("-update","");SITE.dnsServers[p]&&m(p,SITE.dnsServers[p]?.ip)}),injectModal("dns-settings-modal",`
<div id="dns-settings-modal" class="weather-modal"> <div id="dns-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3> <h3 id="dns-settings-title">DNS Settings</h3>
@@ -300,7 +300,7 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button> <button id="dns-settings-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);let v=null;function f(a){v=a;const k=SITE.dnsServers[a]||{},t=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(k.name||a).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=k.ip||"",document.getElementById("dns-edit-port").value=k.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=k.name||"",t.classList.add("show")}async function m(){if(!v)return;const a=document.getElementById("dns-edit-ip").value.trim(),k=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,t=document.getElementById("dns-edit-name").value.trim();if(!a){showNotification("Server IP is required","warning");return}const d={dnsServers:{}};d.dnsServers[v]={ip:a,port:String(k)},t&&(d.dnsServers[v].name=t);try{const h=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})).json();h.success?(SITE.dnsServers[v]=d.dnsServers[v],showNotification(`${v.toUpperCase()} settings saved`,"success"),b(),window.refreshAll()):showNotification(h.error||"Failed to save settings","error")}catch(u){showNotification("Failed to save: "+u.message,"error")}}async function l(){if(v&&confirm(`Remove ${v.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const k=await(await secureFetch("/api/v1/config")).json();k.dnsServers&&delete k.dnsServers[v];const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:k.dnsServers||{}})})).json();if(d.success){delete SITE.dnsServers[v];const u=document.querySelector(`.top [data-app="${v}"]`);u&&u.remove(),showNotification(`${v.toUpperCase()} removed from dashboard`,"success"),b()}else showNotification(d.error||"Failed to remove","error")}catch(a){showNotification("Failed to remove: "+a.message,"error")}}function b(){closeModal("dns-settings-modal"),v=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",b),document.getElementById("dns-settings-save")?.addEventListener("click",m),document.getElementById("dns-settings-delete")?.addEventListener("click",l),document.getElementById("dns-settings-modal")?.addEventListener("click",a=>{a.target.id==="dns-settings-modal"&&b()}),document.querySelector(".top")?.addEventListener("click",a=>{const k=a.target.closest('[id$="-settings"]');if(!k)return;const t=k.id.replace("-settings","");SITE.dnsServers[t]&&(a.stopPropagation(),f(t))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",` </div>`);let l=null;function w(n){l=n;const b=SITE.dnsServers[n]||{},p=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(b.name||n).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=b.ip||"",document.getElementById("dns-edit-port").value=b.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=b.name||"",p.classList.add("show")}async function v(){if(!l)return;const n=document.getElementById("dns-edit-ip").value.trim(),b=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,p=document.getElementById("dns-edit-name").value.trim();if(!n){showNotification("Server IP is required","warning");return}const s={dnsServers:{}};s.dnsServers[l]={ip:n,port:String(b)},p&&(s.dnsServers[l].name=p);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})).json();g.success?(SITE.dnsServers[l]=s.dnsServers[l],showNotification(`${l.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(c){showNotification("Failed to save: "+c.message,"error")}}async function r(){if(l&&confirm(`Remove ${l.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const b=await(await secureFetch("/api/v1/config")).json();b.dnsServers&&delete b.dnsServers[l];const s=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:b.dnsServers||{}})})).json();if(s.success){delete SITE.dnsServers[l];const c=document.querySelector(`.top [data-app="${l}"]`);c&&c.remove(),showNotification(`${l.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(s.error||"Failed to remove","error")}catch(n){showNotification("Failed to remove: "+n.message,"error")}}function h(){closeModal("dns-settings-modal"),l=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",v),document.getElementById("dns-settings-delete")?.addEventListener("click",r),document.getElementById("dns-settings-modal")?.addEventListener("click",n=>{n.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",n=>{const b=n.target.closest('[id$="-settings"]');if(!b)return;const p=b.id.replace("-settings","");SITE.dnsServers[p]&&(n.stopPropagation(),w(p))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
<div id="logs-modal" class="logs-modal"> <div id="logs-modal" class="logs-modal">
<div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header"> <div class="logs-header">
@@ -324,66 +324,66 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
</div> </div>
</div> </div>
</div> </div>
</div>`);let o=null,i=null,v=!1,f=null,m=null,l=!1,b=null,a=null,k=!1,t=null,d=!1;async function u(w,g=25){try{const C=getDnsServerAddr(w),E=await fetch(`/api/v1/dns/logs?server=${C}&limit=${g}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(C){return console.error("DNS logs fetch failed:",C),{error:C.message}}}function h(w){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[w]||"var(--fg)"}function e(w){const g=document.createElement("div");if(g.className="log-entry",g.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",w.parsed===!1)return g.style.gridTemplateColumns="1fr",g.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(w.raw)}</span>`,g;const C=h(w.rcode),E=w.rcode==="Refused"||w.rcode==="REFUSED";return g.innerHTML=` </div>`);let o=null,m=null,l=!1,w=null,v=null,r=!1,h=null,n=null,b=!1,p=null,s=!1;async function c(x,y=25){try{const I=getDnsServerAddr(x),S=await fetch(`/api/v1/dns/logs?server=${I}&limit=${y}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return S.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${S.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function g(x){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[x]||"var(--fg)"}function e(x){const y=document.createElement("div");if(y.className="log-entry",y.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",x.parsed===!1)return y.style.gridTemplateColumns="1fr",y.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,y;const I=g(x.rcode),S=x.rcode==="Refused"||x.rcode==="REFUSED";return y.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.timestamp)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(w.client)}">${escapeHtml(w.client)}</span> <span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(x.client)}">${escapeHtml(x.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${E?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(w.domain)}">${escapeHtml(w.domain)}</span> <span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${S?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(x.domain)}">${escapeHtml(x.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(w.type)}</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.type)}</span>
<span style="color: ${C}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(w.rcode)}</span> <span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span>
`,g}async function s(){if(k){await T();return}if(l){await B();return}if(v||!o)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await u(o,w);if(C.error){g.innerHTML=` `,y}async function a(){if(b){await L();return}if(r){await B();return}if(l||!o)return;const x=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await c(o,x);if(I.error){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}g.innerHTML=` </div>`;return}y.innerHTML=`
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span>Time</span> <span>Time</span>
<span>Client</span> <span>Client</span>
<span>Domain</span> <span>Domain</span>
<span>Type</span> <span>Type</span>
<span>Status</span> <span>Status</span>
</div>`,C.logs&&C.logs.length>0?C.logs.forEach(E=>{const S=e(E);g.appendChild(S)}):g.innerHTML+=` </div>`,I.logs&&I.logs.length>0?I.logs.forEach(S=>{const C=e(S);y.appendChild(C)}):y.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet No DNS queries logged yet
</div>`}catch(C){g.innerHTML=` </div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function p(w){o=w,v=!1,l=!1;const g=document.getElementById("logs-modal"),C=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");C.textContent=`${w.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),S&&(S.style.display="none"),g.classList.add("show"),s(),i=setInterval(s,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),i&&(clearInterval(i),i=null),y(),o=null,l=!1,f=null,m=null,k=!1,b=null,a=null,v=!1}function c(w){t&&y();const g=document.getElementById("logs-stream"),C=document.getElementById("logs-pause"),E=document.getElementById("logs-content");i&&(clearInterval(i),i=null);try{t=new EventSource(`/api/v1/logs/stream/${w}`),d=!0,g.classList.add("active"),g.textContent="\u{1F534} Live",g.title="Streaming - click to stop",C.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),t.onmessage=P=>{try{const N=JSON.parse(P.data);if(N.error){console.error("Stream error:",N.error),y();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(N.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=` </div>`}}function u(x){o=x,l=!1,r=!1;const y=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");I.textContent=`${x.toUpperCase()} DNS Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),C&&(C.style.display="none"),y.classList.add("show"),a(),m=setInterval(a,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),m&&(clearInterval(m),m=null),f(),o=null,r=!1,w=null,v=null,b=!1,h=null,n=null,l=!1}function d(x){p&&f();const y=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),S=document.getElementById("logs-content");m&&(clearInterval(m),m=null);try{p=new EventSource(`/api/v1/logs/stream/${x}`),s=!0,y.classList.add("active"),y.textContent="\u{1F534} Live",y.title="Streaming - click to stop",I.style.display="none";const C=document.getElementById("logs-title");C.textContent.includes("\u{1F534}")||(C.innerHTML=C.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),p.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),f();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
<div style="flex-shrink: 0;">${M}</div> <div style="flex-shrink: 0;">${M}</div>
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(N.text)}</div> <div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
`,E.appendChild(O),E.scrollTop=E.scrollHeight;E.children.length>500;)E.removeChild(E.firstChild)}catch(N){console.error("Error parsing stream data:",N)}},t.onerror=P=>{console.error("EventSource error:",P),y()}}catch(S){console.error("Failed to start streaming:",S),y()}}function y(){t&&(t.close(),t=null),d=!1;const w=document.getElementById("logs-stream"),g=document.getElementById("logs-pause"),C=document.getElementById("logs-title");w&&(w.classList.remove("active"),w.textContent="\u{1F4E1} Live",w.title="Enable real-time streaming"),g&&(g.style.display=""),C&&(C.textContent=C.textContent.replace(" \u{1F534}","")),l&&f&&!i&&(i=setInterval(B,DC.POLL.LOGS))}async function r(w,g=100){try{const C=`/api/v1/logs/container/${w}?tail=${g}&timestamps=true`,E=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${E.status}: ${E.statusText}`}}catch(C){return console.error("Container logs fetch failed:",C),{error:C.message}}}function I(w){const g=document.createElement("div");g.className="log-entry",g.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=w.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=w.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return g.innerHTML=` `,S.appendChild(O),S.scrollTop=S.scrollHeight;S.children.length>500;)S.removeChild(S.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},p.onerror=P=>{console.error("EventSource error:",P),f()}}catch(C){console.error("Failed to start streaming:",C),f()}}function f(){p&&(p.close(),p=null),s=!1;const x=document.getElementById("logs-stream"),y=document.getElementById("logs-pause"),I=document.getElementById("logs-title");x&&(x.classList.remove("active"),x.textContent="\u{1F4E1} Live",x.title="Enable real-time streaming"),y&&(y.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),r&&w&&!m&&(m=setInterval(B,DC.POLL.LOGS))}async function i(x,y=100){try{const I=`/api/v1/logs/container/${x}?tail=${y}&timestamps=true`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,containerName:C.containerName,containerId:C.containerId}:{error:C.error||"Failed to fetch container logs"}}else return{error:`HTTP ${S.status}: ${S.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function k(x){const y=document.createElement("div");y.className="log-entry",y.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",S=x.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return y.innerHTML=`
<div style="flex-shrink: 0;">${E}</div> <div style="flex-shrink: 0;">${S}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(w.text)}</div> <div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
`,g}async function B(){if(v||!f||!l)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await r(f,w);if(C.error){g.innerHTML=` `,y}async function B(){if(l||!w||!r)return;const x=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await i(w,x);if(I.error){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}g.innerHTML=` </div>`;return}y.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex-shrink: 0; width: 80px;">Stream</span> <span style="flex-shrink: 0; width: 80px;">Stream</span>
<span style="flex: 1;">Log Output</span> <span style="flex: 1;">Log Output</span>
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(E=>{const S=I(E);g.appendChild(S)}),g.scrollTop=g.scrollHeight):g.innerHTML+=` </div>`,I.logs&&I.logs.length>0?(I.logs.forEach(S=>{const C=k(S);y.appendChild(C)}),y.scrollTop=y.scrollHeight):y.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container No logs available for this container
</div>`}catch(C){g.innerHTML=` </div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function L(w,g){f=w,m=g,l=!0,k=!1,v=!1,y();const C=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${g} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display=""),C.classList.add("show"),B(),i=setInterval(B,DC.POLL.LOGS)}async function A(w,g=100){try{const C=`/api/v1/logs/file?path=${encodeURIComponent(w)}&tail=${g}`,E=await fetch(C,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const S=await E.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(C){return console.error("File logs fetch failed:",C),{error:C.message}}}function x(w){const g=document.createElement("div");g.className="log-entry",g.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const C=w.text;let E="INFO",S="var(--fg)";C.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",S="var(--bad-fg)"):C.match(/WARN|WARNING/i)?(E="WARN",S="#f39c12"):C.match(/DEBUG/i)&&(E="DEBUG",S="var(--muted)");const N=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${E}</span>`;return g.innerHTML=` </div>`}}function T(x,y){w=x,v=y,r=!0,b=!1,l=!1,f();const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${y} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),I.classList.add("show"),B(),m=setInterval(B,DC.POLL.LOGS)}async function A(x,y=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${y}`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,logPath:C.logPath,totalLines:C.totalLines}:{error:C.error||"Failed to fetch file logs"}}else return{error:(await S.json().catch(()=>({}))).error||`HTTP ${S.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(x){const y=document.createElement("div");y.className="log-entry",y.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.text;let S="INFO",C="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(S="ERROR",C="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(S="WARN",C="#f39c12"):I.match(/DEBUG/i)&&(S="DEBUG",C="var(--muted)");const D=`<span style="background: ${C==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${C}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${S}</span>`;return y.innerHTML=`
<div style="flex-shrink: 0;">${N}</div> <div style="flex-shrink: 0;">${D}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C)}</div> <div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
`,g}async function T(){if(v||!b||!k)return;const w=parseInt(document.getElementById("log-lines").value),g=document.getElementById("logs-content");try{const C=await A(b,w);if(C.error){g.innerHTML=` `,y}async function L(){if(l||!h||!b)return;const x=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await A(h,x);if(I.error){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
<div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div> <div style="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(C.error)}</div> <div>${escapeHtml(I.error)}</div>
</div>`;return}g.innerHTML=` </div>`;return}y.innerHTML=`
<div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;"> <div style="display: flex; gap: 12px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
<span style="flex: 1;">Log Output (${C.count} of ${C.totalLines} lines)</span> <span style="flex: 1;">Log Output (${I.count} of ${I.totalLines} lines)</span>
</div>`,C.logs&&C.logs.length>0?(C.logs.forEach(E=>{const S=x(E);g.appendChild(S)}),g.scrollTop=g.scrollHeight):g.innerHTML+=` </div>`,I.logs&&I.logs.length>0?(I.logs.forEach(S=>{const C=E(S);y.appendChild(C)}),y.scrollTop=y.scrollHeight):y.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);"> <div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file No logs available in this file
</div>`}catch(C){g.innerHTML=` </div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);"> <div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(C.message)} Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function $(w,g){b=w,a=g,k=!0,l=!1,v=!1;const C=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${g} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),P&&(P.style.display="none"),C.classList.add("show"),T(),i=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",w=>{const g=w.target.closest('[id$="-logs"]');if(!g)return;const C=g.id.replace("-logs","");SITE.dnsServers[C]&&p(C)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{v=!v;const w=document.getElementById("logs-pause");v?(w.textContent="\u25B6\uFE0F Resume",w.classList.add("paused")):(w.textContent="\u23F8\uFE0F Pause",w.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{v||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!l||!f||(d?y():c(f))}),document.getElementById("logs-modal")?.addEventListener("click",w=>{w.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",w=>{w.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=p})(),(function(){injectModal("service-edit-modal",` </div>`}}function $(x,y){h=x,n=y,b=!0,r=!1,l=!1;const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${y} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),L(),m=setInterval(L,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const y=x.target.closest('[id$="-logs"]');if(!y)return;const I=y.id.replace("-logs","");SITE.dnsServers[I]&&u(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{l=!l;const x=document.getElementById("logs-pause");l?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),a())}),document.getElementById("log-lines")?.addEventListener("change",()=>{l||a()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!r||!w||(s?f():d(w))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=T,window.openFileLogsModal=$,window.openLogsModal=u})(),(function(){injectModal("service-edit-modal",`
<div id="service-edit-modal" class="weather-modal"> <div id="service-edit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3> <h3 id="service-edit-title">Edit Service</h3>
@@ -712,44 +712,44 @@ Instructions: ${p.instructionsLink}`:"";showNotification(`${a.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button> <button id="add-service-create" class="btn-accent">Create Service</button>
</div> </div>
</div> </div>
</div>`)})(),(function(){async function o(l){try{const b=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(l)}`);if(!b.ok)throw new Error(`Failed to load CAs: ${b.status}`);const a=await b.json();if(a.status==="success"){const k=document.getElementById("existing-ca-select");return k.innerHTML="",a.data.cas.length===0?k.innerHTML='<option value="">No CAs found in Caddyfile</option>':(k.innerHTML='<option value="">Select existing CA...</option>',a.data.cas.forEach(t=>{const d=document.createElement("option");typeof t=="object"?(d.value=t.id,d.textContent=t.displayName||t.name):(d.value=t,d.textContent=t),k.appendChild(d)})),a.data.cas}else throw new Error(a.message)}catch(b){console.error("Error loading CAs:",b);const a=document.getElementById("existing-ca-select");return a.innerHTML='<option value="">Error loading CAs</option>',[]}}function i(l){const{subdomain:b,port:a,ip:k,sslType:t,caName:d,existingCa:u,enableAuth:h,enableCors:e,customHeaders:s,upstreamPath:p,healthCheck:n,timeout:c,tailscaleOnly:y}=l;let r=`${buildDomain(b)} { </div>`)})(),(function(){async function o(r){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(r)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const n=await h.json();if(n.status==="success"){const b=document.getElementById("existing-ca-select");return b.innerHTML="",n.data.cas.length===0?b.innerHTML='<option value="">No CAs found in Caddyfile</option>':(b.innerHTML='<option value="">Select existing CA...</option>',n.data.cas.forEach(p=>{const s=document.createElement("option");typeof p=="object"?(s.value=p.id,s.textContent=p.displayName||p.name):(s.value=p,s.textContent=p),b.appendChild(s)})),n.data.cas}else throw new Error(n.message)}catch(h){console.error("Error loading CAs:",h);const n=document.getElementById("existing-ca-select");return n.innerHTML='<option value="">Error loading CAs</option>',[]}}function m(r){const{subdomain:h,port:n,ip:b,sslType:p,caName:s,existingCa:c,enableAuth:g,enableCors:e,customHeaders:a,upstreamPath:u,healthCheck:t,timeout:d,tailscaleOnly:f}=r;let i=`${buildDomain(h)} {
`;switch(y&&(r+=` @blocked not remote_ip 100.64.0.0/10 `;switch(f&&(i+=` @blocked not remote_ip 100.64.0.0/10
`,r+=` respond @blocked "Access denied. Tailscale connection required." 403 `,i+=` respond @blocked "Access denied. Tailscale connection required." 403
`),t){case"letsencrypt":break;case"caddy-managed":r+=` tls internal `),p){case"letsencrypt":break;case"caddy-managed":i+=` tls internal
`;break;case"existing-ca":u&&(r+=` tls { `;break;case"existing-ca":c&&(i+=` tls {
ca ${u} ca ${c}
} }
`);break;case"custom-ca":d&&(r+=` tls { `);break;case"custom-ca":s&&(i+=` tls {
ca ${d} ca ${s}
} }
`);break}if(h&&(r+=` basicauth { `);break}if(g&&(i+=` basicauth {
admin $2a$14$hashed_password_here admin $2a$14$hashed_password_here
} }
`),e&&(r+=` header { `),e&&(i+=` header {
`,r+=` Access-Control-Allow-Origin "*" `,i+=` Access-Control-Allow-Origin "*"
`,r+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" `,i+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,r+=` Access-Control-Allow-Headers "Content-Type, Authorization" `,i+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,r+=` } `,i+=` }
`),s)try{const I=JSON.parse(s);r+=` header { `),a)try{const k=JSON.parse(a);i+=` header {
`,Object.entries(I).forEach(([B,L])=>{r+=` ${B} "${L}" `,Object.entries(k).forEach(([B,T])=>{i+=` ${B} "${T}"
`}),r+=` } `}),i+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(r+=` health_uri ${n} `}catch{console.warn("Invalid JSON in custom headers")}return t&&(i+=` health_uri ${t}
`),r+=` reverse_proxy ${k}:${a} { `),i+=` reverse_proxy ${b}:${n} {
`,p&&p!=="/"&&(r+=` rewrite ${p} `,u&&u!=="/"&&(i+=` rewrite ${u}
`),c&&c!==30&&(r+=` transport http { `),d&&d!==30&&(i+=` transport http {
`,r+=` dial_timeout ${c}s `,i+=` dial_timeout ${d}s
`,r+=` response_header_timeout ${c}s `,i+=` response_header_timeout ${d}s
`,r+=` } `,i+=` }
`),r+=` } `),i+=` }
`,r+=`} `,i+=`}
`,r}async function v(l,b,a=DC.DEFAULTS.TTL){const k=window.getToken(getPrimaryDnsId(),"admin");if(!k)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const t=buildDomain(l),d=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:t,ip:b,ttl:a,token:k,server:SITE.dnsIp})});if(!d.ok){const h=await d.text();throw new Error(`DNS API Error: ${d.status} - ${h}`)}const u=await d.json();if(!u.success)throw new Error(`DNS Error: ${u.error||"Unknown error"}`);return u}async function f(l){const b={id:l.subdomain,name:l.name,logo:l.logo||`/assets/${l.subdomain}.png`};try{const a=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});if(!a.ok){const k=await a.json();throw new Error(k.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),b}catch(a){throw console.error("Failed to add service to config:",a),a}}async function m(l){const b=document.getElementById("service-subdomain-input").value.trim(),a=document.getElementById("service-ip-input").value.trim()||"localhost",k=document.getElementById("service-port-input").value.trim()||"80",t=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(b),upstream:`${a}:${k}`,config:l})}),d=await t.json();if(!t.ok||!d.success)throw new Error(d.error||`Caddy API Error: ${t.status}`);return d}window.loadExistingCAs=o,window.generateCaddyConfig=i,window.createDnsRecord=v,window.addServiceToConfig=f,window.addToCaddyfile=m})(),(function(){let o=null;function i(a){o=a;const k=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${a.name}`,document.getElementById("edit-service-name").value=a.name,document.getElementById("edit-service-url-display").textContent=a.url||buildServiceUrl(a.id),document.getElementById("edit-service-logo-preview").src=a.logo||`/assets/${a.id}.png`,document.getElementById("edit-subdomain").value=a.id,document.getElementById("edit-port").value=a.port||"",document.getElementById("edit-ip").value=a.ip||"localhost",document.getElementById("edit-tailscale-only").checked=a.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=a.logo||"",k.classList.add("show")}function v(){closeModal("service-edit-modal"),o=null}async function f(){if(!o)return;const a=document.getElementById("edit-subdomain").value.trim().toLowerCase(),k=document.getElementById("edit-service-name").value.trim(),t=document.getElementById("edit-port").value.trim(),d=document.getElementById("edit-ip").value.trim()||"localhost",u=document.getElementById("edit-tailscale-only").checked,h=document.getElementById("edit-logo-url").value.trim();if(!a){showNotification("Subdomain is required","warning");return}const e=o.id,s=[];if(a!==e&&s.push("subdomain"),k&&k!==o.name&&s.push("name"),t&&t!==String(o.port)&&s.push("port"),d!==o.ip&&s.push("ip"),u!==(o.tailscaleOnly||!1)&&s.push("tailscale"),h&&h!==o.logo&&s.push("logo"),s.length===0){v();return}const p=document.getElementById("service-edit-save");p.textContent="Saving...",p.disabled=!0;try{const c=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:a,name:k||o.name,port:t||o.port,ip:d,tailscaleOnly:u,logo:h||void 0})})).json();if(!c.success)throw new Error(c.error||"Failed to update service");const y=window.APPS.findIndex(r=>r.id===e);y!==-1&&(window.APPS[y]={...window.APPS[y],id:a,name:k||window.APPS[y].name,port:t||window.APPS[y].port,ip:d,tailscaleOnly:u,logo:h||window.APPS[y].logo}),v(),window.buildGrid(),window.refreshAll()}catch(n){console.error("Error saving service changes:",n),showNotification(`Error saving changes: ${n.message}`,"error")}finally{p.textContent="Save Changes",p.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async a=>{const k=a.target.files[0];if(!k)return;if(!k.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const t=new FileReader;t.onload=async d=>{const u=d.target.result;if(document.getElementById("edit-service-logo-preview").src=u,document.getElementById("edit-logo-url").value=u,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:u})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},t.readAsDataURL(k)}),document.getElementById("service-edit-cancel")?.addEventListener("click",v),document.getElementById("service-edit-save")?.addEventListener("click",f),document.getElementById("service-edit-modal")?.addEventListener("click",a=>{a.target.id==="service-edit-modal"&&v()});function m(a,k,t){return new Promise(d=>{const u=document.getElementById("delete-ser Line truncated `,i}async function l(r,h,n=DC.DEFAULTS.TTL){const b=window.getToken(getPrimaryDnsId(),"admin");if(!b)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const p=buildDomain(r),s=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:p,ip:h,ttl:n,token:b,server:SITE.dnsIp})});if(!s.ok){const g=await s.text();throw new Error(`DNS API Error: ${s.status} - ${g}`)}const c=await s.json();if(!c.success)throw new Error(`DNS Error: ${c.error||"Unknown error"}`);return c}async function w(r){const h={id:r.subdomain,name:r.name,logo:r.logo||`/assets/${r.subdomain}.png`};try{const n=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!n.ok){const b=await n.json();throw new Error(b.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(n){throw console.error("Failed to add service to config:",n),n}}async function v(r){const h=document.getElementById("service-subdomain-input").value.trim(),n=document.getElementById("service-ip-input").value.trim()||"localhost",b=document.getElementById("service-port-input").value.trim()||"80",p=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${n}:${b}`,config:r})}),s=await p.json();if(!p.ok||!s.success)throw new Error(s.error||`Caddy API Error: ${p.status}`);return s}window.loadExistingCAs=o,window.generateCaddyConfig=m,window.createDnsRecord=l,window.addServiceToConfig=w,window.addToCaddyfile=v})(),(function(){let o=null;function m(n){o=n;const b=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${n.name}`,document.getElementById("edit-service-name").value=n.name,document.getElementById("edit-service-url-display").textContent=n.url||buildServiceUrl(n.id),document.getElementById("edit-service-logo-preview").src=n.logo||`/assets/${n.id}.png`,document.getElementById("edit-subdomain").value=n.id,document.getElementById("edit-port").value=n.port||"",document.getElementById("edit-ip").value=n.ip||"localhost",document.getElementById("edit-tailscale-only").checked=n.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=n.logo||"",b.classList.add("show")}function l(){closeModal("service-edit-modal"),o=null}async function w(){if(!o)return;const n=document.getElementById("edit-subdomain").value.trim().toLowerCase(),b=document.getElementById("edit-service-name").value.trim(),p=document.getElementById("edit-port").value.trim(),s=document.getElementById("edit-ip").value.trim()||"localhost",c=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim();if(!n){showNotification("Subdomain is required","warning");return}const e=o.id,a=[];if(n!==e&&a.push("subdomain"),b&&b!==o.name&&a.push("name"),p&&p!==String(o.port)&&a.push("port"),s!==o.ip&&a.push("ip"),c!==(o.tailscaleOnly||!1)&&a.push("tailscale"),g&&g!==o.logo&&a.push("logo"),a.length===0){l();return}const u=document.getElementById("service-edit-save");u.textContent="Saving...",u.disabled=!0;try{const d=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:n,name:b||o.name,port:p||o.port,ip:s,tailscaleOnly:c,logo:g||void 0})})).json();if(!d.success)throw new Error(d.error||"Failed to update service");const f=window.APPS.findIndex(i=>i.id===e);f!==-1&&(window.APPS[f]={...window.APPS[f],id:n,name:b||window.APPS[f].name,port:p||window.APPS[f].port,ip:s,tailscaleOnly:c,logo:g||window.APPS[f].logo}),l(),window.buildGrid(),window.refreshAll()}catch(t){console.error("Error saving service changes:",t),showNotification(`Error saving changes: ${t.message}`,"error")}finally{u.textContent="Save Changes",u.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async n=>{const b=n.target.files[0];if(!b)return;if(!b.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const p=new FileReader;p.onload=async s=>{const c=s.target.result;if(document.getElementById("edit-service-logo-preview").src=c,document.getElementById("edit-logo-url").value=c,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:c})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},p.readAsDataURL(b)}),document.getElementById("service-edit-cancel")?.addEventListener("click",l),document.getElementById("service-edit-save")?.addEventListener("click",w),document.getElementById("service-edit-modal")?.addEventListener("click",n=>{n.target.id==="service-edit-modal"&&l()});function v(n,b,p){return new Promise(s=>{const c=document.getElementById("delete-ser Line truncated
This will: This will:
1. Pull the latest image 1. Pull the latest image
2. Stop the container 2. Stop the container
3. Recreate with same settings 3. Recreate with same settings
The service will be briefly unavailable.`))try{d&&(d.textContent="\u{1F504}",d.disabled=!0,d.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${a}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(p=>p.id===t);s&&e.newContainerId&&(s.containerId=e.newContainerId),d&&(d.textContent="\u2705",d.title="Updated successfully!",setTimeout(()=>{d.textContent=u,d.disabled=!1,d.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${k} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(h){console.error("Update error:",h),d&&(d.textContent="\u274C",d.title="Update failed",setTimeout(()=>{d.textContent=u,d.disabled=!1,d.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${k}: ${h.message}`,"error")}}async function b(a,k){const t=window.APPS.find(r=>r.id===a),d=t?buildDomain(t.id):null,u=t?.containerId,h=await m(k||a,u,t?.containerId);if(h===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(h&&u)try{const r=new URLSearchParams({containerId:t.containerId,subdomain:t.id,ip:t.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(t.id)}?${r.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(r){console.error("App removal error:",r)}else if(h&&d){try{const r=t?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(d)}&type=A&ipAddress=${encodeURIComponent(r)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(r){e.dns=r.message}try{const I=await(await secureFetch(`/api/v1/site/${encodeURIComponent(d)}`,{method:"DELETE"})).json();e.caddy=I.success||I.error&&I.error.includes("not found")?"removed":I.error||"failed"}catch(r){e.caddy=r.message}}const s=window.APPS.findIndex(r=>r.id===a);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const r=safeGetJSON("custom-apps",[]),I=r.findIndex(B=>B.id===a);I>-1&&(r.splice(I,1),safeSet("custom-apps",JSON.stringify(r)))}catch{}try{const I=await(await secureFetch(`/api/v1/services/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.service=I.success?"removed":I.error||"failed"}catch(r){e.service=r.message}window.buildGrid(),window.refreshAll();let p=!1,n=[];e.dashboard||(p=!0,n.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],y=r=>!r||c.some(I=>r.toLowerCase().includes(I.toLowerCase()));e.container&&!y(e.container)&&(p=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!y(e.dns)&&(p=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!y(e.caddy)&&(p=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!y(e.service)&&(p=!0,n.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${k||a}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=i,window.showDeleteModal=m,window.updateContainer=l,window.deleteService=b})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function i(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function v(){const e=document.getElementById("service-subdomain-input").value||"subdomain",s=document.getElementById("service-ip-input").value||f.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",y=document.getElementById("existing-ca-select").value,r=document.getElementById("enable-auth").checked,I=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,x=document.getElementById("timeout-input").value||30,T=document.getElementById("dns-preview");T&&(T.textContent=`${buildDomain(e)} \u2192 ${s}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const w={subdomain:e,port:p,ip:s,sslType:n,caName:c,existingCa:y,enableAuth:r,enableCors:I,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:x},g=window.generateCaddyConfig(w),C=document.getElementById("caddy-config-preview");C&&(C.value=g)}const f={localhost:"127.0.0.1",lan:"",tailscale:""};async function m(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const c=await n.json();c.lan&&(f.lan=c.lan),c.tailscale&&(f.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(f.lan?(e.dataset.ip=f.lan,e.textContent=`LAN (${f.lan})`,e.title=`LAN IP: ${f.lan}`):e Line truncated The service will be briefly unavailable.`))try{s&&(s.textContent="\u{1F504}",s.disabled=!0,s.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${n}/update`,{method:"POST"})).json();if(e.success){const a=window.APPS.find(u=>u.id===p);a&&e.newContainerId&&(a.containerId=e.newContainerId),s&&(s.textContent="\u2705",s.title="Updated successfully!",setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${b} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),s&&(s.textContent="\u274C",s.title="Update failed",setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${b}: ${g.message}`,"error")}}async function h(n,b){const p=window.APPS.find(i=>i.id===n),s=p?buildDomain(p.id):null,c=p?.containerId,g=await v(b||n,c,p?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&c)try{const i=new URLSearchParams({containerId:p.containerId,subdomain:p.id,ip:p.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(p.id)}?${i.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(i){console.error("App removal error:",i)}else if(g&&s){try{const i=p?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(s)}&type=A&ipAddress=${encodeURIComponent(i)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(i){e.dns=i.message}try{const k=await(await secureFetch(`/api/v1/site/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.caddy=k.success||k.error&&k.error.includes("not found")?"removed":k.error||"failed"}catch(i){e.caddy=i.message}}const a=window.APPS.findIndex(i=>i.id===n);a>-1&&(window.APPS.splice(a,1),e.dashboard=!0);try{const i=safeGetJSON("custom-apps",[]),k=i.findIndex(B=>B.id===n);k>-1&&(i.splice(k,1),safeSet("custom-apps",JSON.stringify(i)))}catch{}try{const k=await(await secureFetch(`/api/v1/services/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.service=k.success?"removed":k.error||"failed"}catch(i){e.service=i.message}window.buildGrid(),window.refreshAll();let u=!1,t=[];e.dashboard||(u=!0,t.push("\u2717 Failed to remove from dashboard"));const d=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],f=i=>!i||d.some(k=>i.toLowerCase().includes(k.toLowerCase()));e.container&&!f(e.container)&&(u=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!f(e.dns)&&(u=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!f(e.caddy)&&(u=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!f(e.service)&&(u=!0,t.push(`\u26A0 Service File: ${e.service}`)),u&&showNotification(`Error deleting "${b||n}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=m,window.showDeleteModal=v,window.updateContainer=r,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function m(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function l(){const e=document.getElementById("service-subdomain-input").value||"subdomain",a=document.getElementById("service-ip-input").value||w.lan||"localhost",u=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,d=document.getElementById("ca-name-input").value||"sami-ca",f=document.getElementById("existing-ca-select").value,i=document.getElementById("enable-auth").checked,k=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,T=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,L=document.getElementById("dns-preview");L&&(L.textContent=`${buildDomain(e)} \u2192 ${a}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:u,ip:a,sslType:t,caName:d,existingCa:f,enableAuth:i,enableCors:k,customHeaders:B,upstreamPath:T,healthCheck:A,timeout:E},y=window.generateCaddyConfig(x),I=document.getElementById("caddy-config-preview");I&&(I.value=y)}const w={localhost:"127.0.0.1",lan:"",tailscale:""};async function v(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const d=await t.json();d.lan&&(w.lan=d.lan),d.tailscale&&(w.tailscale=d.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),a=document.getElementById("quick-ip-tailscale");e&&(w.lan?(e.dataset.ip=w.lan,e.textContent=`LAN (${w.lan})`,e.title=`LAN IP: ${w.lan}`):e Line truncated
<span style="color: #4caf50;">\u2713 Connected</span> <span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${I.self?.hostname} (${I.self?.ip})</span> <span style="color: var(--muted); margin-left: 6px;">${k.self?.hostname} (${k.self?.ip})</span>
`,y.disabled=!1):I.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',y.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',y.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',y.disabled=!0}y.checked=!1,v()}function a(){const e=document.getElementById("service-type-local"),s=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),y=document.getElementById("tab-external");function r(){e.checked?(p.style.display="grid",n.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),y&&(y.style.background="transparent",y.style.color="var(--muted)")):(p.style.display="none",n.style.display="block",y&&(y.style.background="var(--accent)",y.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",r),s?.addEventListener("change",r)}function k(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const L=o(e.value);!n&&s&&(s.value=L),p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),v()}),s?.addEventListener("input",()=>{n=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),v()});const c=document.getElementById("external-service-name"),y=document.getElementById("external-service-subdomain"),r=document.getElementById("external-subdomain-preview"),I=document.getElementById("external-domain-preview");let B=!1;c?.addEventListener("input",()=>{const L=o(c.value);!B&&y&&(y.value=L);const A=y?.value||L;r&&(r.textContent=A?`\u2192 ${buildDomain(A)}`:""),I&&(I.textContent=A?buildDomain(A):"")}),y?.addEventListener("input",()=>{B=y.value!==o(c?.value||"");const L=y.value.trim()||o(c?.value||"");r&&(r.textContent=L?`\u2192 ${buildDomain(L)}`:""),I&&(I.textContent=L?buildDomain(L):"")})}async function t(){const e=document.getElementById("external-service-name").value.trim(),s=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),y=document.getElementById("external-create-dns").checked,r=document.getElementById("external-create-caddy").checked,I=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked;if(!e||!s){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!s.startsWith("http://")&&!s.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(p);try{const x={dns:null,caddy:null,dashboard:!1};if(y)if(window.getToken(getPrimaryDnsId(),"admin"))try{const S=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:I,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();x.dns=S.success?"created":S.error||"failed"}catch(E){x.dns=E.message}else x.dns="no admin token (configure in \u{1F511} Tokens)";if(r)try{const C={subdomain:p,externalUrl:s,preserveHost:B,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},S=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(C)})).json();x.caddy=S.success?"created":S.error||"failed"}catch(C){x.caddy=C.message}const T={id:p,name:e,url:`https://${A}`,externalUrl:s,logo:n||c||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),x.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(C=>!$.includes(C.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(C){console.warn("Failed to save to services.json:",C)}window.buildGrid(),window.refreshAll(),d();const g=[`External service "${e}" added!`];y&&g.push(`DNS: ${x.dns==="created"?"\u2713":"\u26A0 "+x.dns}`),r&&g.push(`Caddy: ${x.caddy==="created"?"\u2713":"\u26A0 "+x.caddy}`),g.push(`Access at: https://${A}`),showNotification(g.join(" | "),"success",6e3)}catch(x){console.error("Failed to create external service:",x),showNotification(`Failed to create external service: ${x.message}`,"error")}}function d(){closeModal("a Line truncated `,f.disabled=!1):k.installed?(d.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',f.disabled=!0):(d.innerHTML='<span style="color: var(--muted);">Not available</span>',f.disabled=!0)}catch{d.innerHTML='<span style="color: var(--muted);">Could not check</span>',f.disabled=!0}f.checked=!1,l()}function n(){const e=document.getElementById("service-type-local"),a=document.getElementById("service-type-external"),u=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),d=document.getElementById("tab-local"),f=document.getElementById("tab-external");function i(){e.checked?(u.style.display="grid",t.style.display="none",d&&(d.style.background="var(--accent)",d.style.color="var(--bg)"),f&&(f.style.background="transparent",f.style.color="var(--muted)")):(u.style.display="none",t.style.display="block",f&&(f.style.background="var(--accent)",f.style.color="var(--bg)"),d&&(d.style.background="transparent",d.style.color="var(--muted)"))}e?.addEventListener("change",i),a?.addEventListener("change",i)}function b(){const e=document.getElementById("service-name-input"),a=document.getElementById("service-subdomain-input"),u=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const T=o(e.value);!t&&a&&(a.value=T),u&&(u.textContent=T?`\u2192 ${buildDomain(T)}`:""),l()}),a?.addEventListener("input",()=>{t=a.value!==o(e?.value||"");const T=a.value.trim()||o(e?.value||"");u&&(u.textContent=T?`\u2192 ${buildDomain(T)}`:""),l()});const d=document.getElementById("external-service-name"),f=document.getElementById("external-service-subdomain"),i=document.getElementById("external-subdomain-preview"),k=document.getElementById("external-domain-preview");let B=!1;d?.addEventListener("input",()=>{const T=o(d.value);!B&&f&&(f.value=T);const A=f?.value||T;i&&(i.textContent=A?`\u2192 ${buildDomain(A)}`:""),k&&(k.textContent=A?buildDomain(A):"")}),f?.addEventListener("input",()=>{B=f.value!==o(d?.value||"");const T=f.value.trim()||o(d?.value||"");i&&(i.textContent=T?`\u2192 ${buildDomain(T)}`:""),k&&(k.textContent=T?buildDomain(T):"")})}async function p(){const e=document.getElementById("external-service-name").value.trim(),a=document.getElementById("external-service-url").value.trim(),u=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),d=document.getElementById("external-service-icon").value.trim(),f=document.getElementById("external-create-dns").checked,i=document.getElementById("external-create-caddy").checked,k=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,T=document.getElementById("external-follow-redirects").checked;if(!e||!a){showNotification("Please fill in Name and External URL","warning");return}if(!u){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!a.startsWith("http://")&&!a.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(u);try{const E={dns:null,caddy:null,dashboard:!1};if(f)if(window.getToken(getPrimaryDnsId(),"admin"))try{const C=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:k,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();E.dns=C.success?"created":C.error||"failed"}catch(S){E.dns=S.message}else E.dns="no admin token (configure in \u{1F511} Tokens)";if(i)try{const I={subdomain:u,externalUrl:a,preserveHost:B,followRedirects:T,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(I)})).json();E.caddy=C.success?"created":C.error||"failed"}catch(I){E.caddy=I.message}const L={id:u,name:e,url:`https://${A}`,externalUrl:a,logo:t||d||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(L),E.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(I=>!$.includes(I.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(I){console.warn("Failed to save to services.json:",I)}window.buildGrid(),window.refreshAll(),s();const y=[`External service "${e}" added!`];f&&y.push(`DNS: ${E.dns==="created"?"\u2713":"\u26A0 "+E.dns}`),i&&y.push(`Caddy: ${E.caddy==="created"?"\u2713":"\u26A0 "+E.caddy}`),y.push(`Access at: https://${A}`),showNotification(y.join(" | "),"success",6e3)}catch(E){console.error("Failed to create external service:",E),showNotification(`Failed to create external service: ${E.message}`,"error")}}function s(){closeModal("a Line truncated
+302 -191
View File
@@ -90,44 +90,44 @@
<button id="logo-save" class="btn-accent">Save</button> <button id="logo-save" class="btn-accent">Save</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),z=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),C=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),$=document.getElementById("logo-single-upload"),L=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),k=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),R=document.getElementById("dashboard-title"),O=DC.NAME;let D=null,N=null,M=null,H="left",S=O;C?.addEventListener("change",()=>{C.checked?(P.style.display="none",$.style.display="",D=null,N=null):(P.style.display="flex",$.style.display="none",M=null)});function T(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}L?.addEventListener("change",a=>{T(a.target.files[0],e=>{D=e,B.src=e,I.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{T(a.target.files[0],e=>{N=e,z.src=e,I.textContent="New light logo ready to save"})}),k?.addEventListener("change",a=>{T(a.target.files[0],e=>{M=e,B.src=e,z.src=e,I.textContent="New logo ready to save (both themes)"})});function b(a){E.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function u(a){S=a||O,document.title=S;const e=document.querySelector(".dashboard-title");e&&(e.textContent=S)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,z.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,x.src=e.customLogo,B.src=e.customLogo,z.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(H=e.position,b(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{H=a.dataset.pos,b(H)})}),document.getElementById("brand")?.addEventListener("click",()=>{D=null,N=null,M=null,L&&(L.value=""),g&&(g.value=""),k&&(k.value=""),C&&(C.checked=!1),P.style.display="flex",$.style.display="none",B.src=f.src,z.src=x.src;const a=f.src.includes("custom-logo")||x.src.includes("custom-logo");I.textContent=a?"Using custom logo":"Using default logos",b(H),R.value=S,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=R.value.trim()||O,e={position:H,dashboardTitle:a};C?.checked&&M?(e.dataDark=M,e.dataLight=M):(D&&(e.dataDark=D),N&&(e.dataLight=N));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),s="?t="+Date.now();t.pathDark&&(f.src=t.pathDark+s,B.src=t.pathDark+s),t.pathLight&&(x.src=t.pathLight+s,z.src=t.pathLight+s),b(H),u(a),w.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults? </div>`);const b=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),T=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),L=document.getElementById("logo-single-upload"),H=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),I=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),$=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),R=DC.NAME;let M=null,j=null,B=null,A="left",w=R;T?.addEventListener("change",()=>{T.checked?(P.style.display="none",L.style.display="",M=null,j=null):(P.style.display="flex",L.style.display="none",B=null)});function z(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}H?.addEventListener("change",a=>{z(a.target.files[0],e=>{M=e,E.src=e,S.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{z(a.target.files[0],e=>{j=e,N.src=e,S.textContent="New light logo ready to save"})}),I?.addEventListener("change",a=>{z(a.target.files[0],e=>{B=e,E.src=e,N.src=e,S.textContent="New logo ready to save (both themes)"})});function f(a){$.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){w=a||R,document.title=w;const e=document.querySelector(".dashboard-title");e&&(e.textContent=w)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(A=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{A=a.dataset.pos,f(A)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,j=null,B=null,H&&(H.value=""),g&&(g.value=""),I&&(I.value=""),T&&(T.checked=!1),P.style.display="flex",L.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");S.textContent=a?"Using custom logo":"Using default logos",f(A),C.value=w,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=C.value.trim()||R,e={position:A,dashboardTitle:a};T?.checked&&B?(e.dataDark=B,e.dataLight=B):(M&&(e.dataDark=M),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 t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(A),p(a),b.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.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&&(f.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",z.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",D=null,N=null,M=null,R.value=O,u(O),H="left",b("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),s=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),s&&(s.textContent="Using DashCaddy favicon"),r=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const c=document.getElementById("favicon-preview"),o=document.getElementById("favicon-status"),l=document.getElementById("favicon-upload"),v=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(v.rel="icon",v.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(v));async function p(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(v.href=e.customFavicon+"?t="+Date.now(),c.src=e.customFavicon+"?t="+Date.now(),o.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}l?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),l.value="";return}const n=new FileReader;n.onload=t=>{r=t.target.result,c.src=r,o.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(a.ok){const e=await a.json();v.href=e.path+"?t="+Date.now(),c.src=e.path+"?t="+Date.now(),o.textContent="Using custom favicon",r=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),p(),y();const m=document.getElementById("settings-timezone");m&&(new MutationObserver(()=>{w.classList.contains("show")&&m.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(m,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=m.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(w,B){const z=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const C of z){const P=document.createElement("option");P.value=C,P.textContent=C.replace(/_/g," "),C===I&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",B=null;async function z(){try{const T=await fetch("/api/v1/config");if(T.ok&&(B=await T.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(T){console.warn("Could not fetch server config, checking localStorage fallback:",T.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}z();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function C(S){document.querySelectorAll(".setup-step").forEach(b=>{b.style.display="none"});const T=document.getElementById(S);T&&(T.style.display="block")}function P(){const S=document.getElementById("setup-summary-content");if(!S)return;let T='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",c=document.getElementById("setup-dns-ip")?.value?.trim()||"",o=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;T+=` This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",M=null,j=null,B=null,C.value=R,p(R),A="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),m=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),m.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,m.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),m.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),y();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{b.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})();const errorHandler=new ErrorHandler;window.populateTimezoneSelect=function(b,E){const N=Intl.supportedValuesOf("timeZone"),S=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const T of N){const P=document.createElement("option");P.value=T,P.textContent=T.replace(/_/g," "),T===S&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",E=null;async function N(){try{const z=await fetch("/api/v1/config");if(z.ok&&(E=await z.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(z){console.warn("Could not fetch server config, checking localStorage fallback:",z.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function T(w){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const z=document.getElementById(w);z&&(z.style.display="block")}function P(){const w=document.getElementById("setup-summary-content");if(!w)return;let z='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",m=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${u}</div> <div><strong>TLD:</strong> ${p}</div>
<div><strong>Certificate Authority:</strong> ${y}</div> <div><strong>Certificate Authority:</strong> ${y}</div>
<div><strong>DNS Server:</strong> ${c}:${o}</div> <div><strong>DNS Server:</strong> ${v}:${m}</div>
<div><strong>Example URLs:</strong> https://uptime${u}, https://nextcloud${u}</div> <div><strong>Example URLs:</strong> https://uptime${p}, https://nextcloud${p}</div>
</div> </div>
</div> </div>
`}else if(w==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";T+=` `}else if(b==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Access Method:</strong> IP:Port only</div> <div><strong>Access Method:</strong> IP:Port only</div>
<div><strong>Default IP:</strong> ${u}</div> <div><strong>Default IP:</strong> ${p}</div>
<div><strong>SSL:</strong> None (HTTP only)</div> <div><strong>SSL:</strong> None (HTTP only)</div>
<div><strong>Example URLs:</strong> http://${u}:8080, http://${u}:3000</div> <div><strong>Example URLs:</strong> http://${p}:8080, http://${p}:3000</div>
</div> </div>
</div> </div>
`}else if(w==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",c=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",o=c==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;T+=` `}else if(b==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;z+=`
<div> <div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3> <h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;"> <div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${u}</div> <div><strong>Domain:</strong> ${p}</div>
<div><strong>SSL:</strong> Let's Encrypt</div> <div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${y}</div> <div><strong>Email:</strong> ${y}</div>
<div><strong>Routing:</strong> ${c==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div> <div><strong>Routing:</strong> ${v==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${o}</div> <div><strong>Example URLs:</strong> ${m}</div>
</div> </div>
</div> </div>
`}const b=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";T+=` `}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";z+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);"> <div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${b.replace(/_/g," ")}</div> <div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
</div> </div>
`,T+="</div>",S.innerHTML=T,C("setup-step-summary")}async function $(S){try{const T=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)});return T.ok?(await T.json(),!0):(console.error("Failed to save config to server:",T.status),!1)}catch(T){return console.error("Error saving config to server:",T),!1}}async function L(){const S={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};w==="homelab"?(S.tld=document.getElementById("setup-tld")?.value?.trim()||".home",S.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",S.dns={provider:"technitium",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()||""},S.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):w==="simple"?(S.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",S.defaults={dnsType:"none",sslType:"none",targetIP:S.defaultIP}):w==="public"&&(S.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",S.email=document.getElementById("setup-public-email")?.value?.trim()||"",S.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",S.defaults={dnsType:S.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const T=await $(S);safeSet("dashcaddy-config",JSON.stringify(S)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const b=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",u=T?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${b}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(S){S.preventDefault();const T=document.querySelector('input[name="config-type"]:checked');T&&(w=T.value),C(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const k=document.getElementById("setup-skip");k&&(k.onclick=async function(S){S.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await $({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const f=document.getElementById("setup-tld");f&&(f.oninput=function(S){const T=S.target.value||".home",b=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");b&&(b.textContent=T),u&&(u.textContent=T)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(S){S.preventDefault(),C("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(S){S.preventDefault();const T=document.getElementById("setup-tld")?.value?.trim()||"",b=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!T||!T.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(!u){showNotification("Please enter your DNS server IP address","warning");return}P()});const R=document.getElementById("setup-simple-back");R&&(R.onclick=function(S){S.preventDefault(),C("setup-step-1")});const O=document.getElementById("setup-simple-next");O&&(O.onclick=function(S){S.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(S){S.onchange=function(){var T=document.getElementById("dns-requirement-note");T&&(T.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const D=document.getElementById("setup-public-back");D&&(D.onclick=function(S){S.preventDefault(),C("setup-step-1")});const N=document.getElementById("setup-public-next");N&&(N.onclick=function(S){S.preventDefault();const T=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"";if(!T){showNotification("Please enter your domain name","warning");return}if(!b||!b.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(S){S.preventDefault(),w==="homelab"?C("setup-step-homelab"):w==="simple"?C("setup-step-simple"):w==="public"&&C("setup-step-public")});const H=document.getElementById("setup-finish");H&&(H.onclick=function(S){S.preventDefault() Line truncated `,z+="</div>",w.innerHTML=z,T("setup-step-summary")}async function L(w){try{const z=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(w)});return z.ok?(await z.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${z.status}`),{function:"saveConfigToServer"}),!1)}catch(z){return errorHandler.logError("[SetupWizard] Save Config",z,{function:"saveConfigToServer"}),!1}}async function H(){const w={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};b==="homelab"?(w.tld=document.getElementById("setup-tld")?.value?.trim()||".home",w.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",w.dns={provider:"technitium",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()||""},w.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):b==="simple"?(w.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",w.defaults={dnsType:"none",sslType:"none",targetIP:w.defaultIP}):b==="public"&&(w.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",w.email=document.getElementById("setup-public-email")?.value?.trim()||"",w.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",w.defaults={dnsType:w.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const z=await L(w);safeSet("dashcaddy-config",JSON.stringify(w)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",p=z?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(w){w.preventDefault();const z=document.querySelector('input[name="config-type"]:checked');z&&(b=z.value),T(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const I=document.getElementById("setup-skip");I&&(I.onclick=async function(w){w.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await L({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(w){const z=w.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=z),p&&(p.textContent=z)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(w){w.preventDefault(),T("setup-step-1")});const $=document.getElementById("setup-homelab-next");$&&($.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!z||!z.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}P()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(w){w.preventDefault(),T("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(w){w.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(w){w.onchange=function(){var z=document.getElementById("dns-requirement-note");z&&(z.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(w){w.preventDefault(),T("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!z){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const B=document.getElementById("setup-summary-back");B&&(B.onclick=function(w){w.preventDefault(),b==="homelab"?T("setup-step-homelab"):b==="simple"?T("setup-step-simple"):b==="public"&&T("setup-step-pub Line truncated
<div class="app-selector-content"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <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> <div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button> </button>
</div> </div>
</div> </div>
</div>`);const w="custom-apps";let B=null,z=null;const I=document.getElementById("app-selector-modal"),C=document.getElementById("app-selector-grid");async function P(){try{const l=await(await fetch("/api/v1/apps/templates")).json();if(l.success)return B=l.templates,z=l.categories,!0}catch(o){console.error("Failed to fetch app templates:",o)}return!1}async function $(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(l){return console.error("Failed to check port:",l),{available:!0}}}async function L(o){try{const v=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(v.success)return v.suggestedPort}catch(l){console.error("Failed to get suggested port:",l)}return o}async function g(){if(C.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!B&&!await P()){C.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}C.innerHTML="";const o={};for(const[v,r]of Object.entries(B)){const p=r.category||"Other";o[p]||(o[p]=[]),o[p].push({id:v,...r})}const l=z?Object.keys(z):Object.keys(o).sort();for(const v of l){const r=o[v];if(!r||r.length===0)continue;r.sort((a,e)=>(e.popularity||0)-(a.popularity||0));const p=document.createElement("div");p.className="app-category-header";const m=z?.[v]||{};p.innerHTML=`${escapeHtml(m.icon||"")} ${escapeHtml(v)}`,m.color&&(p.style.borderBottomColor=m.color),C.appendChild(p),r.forEach(a=>{const e=document.createElement("div");e.className="app-option";const n=a.isDashboardWidget,t=n&&safeGet("widget-"+a.id+"-enabled")!=="false",s=n?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${t?"#2ecc7130":"#e74c3c30"}; color: ${t?"#2ecc71":"#e74c3c"}; font-weight: 600;">${t?"ON":"OFF"}</div>`:"",i=!n&&a.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${a.difficulty==="Easy"?"#2ecc71":a.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${a.difficulty==="Easy"?"#2ecc71":a.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(a.difficulty)}</div>`:"";e.innerHTML=` </div>`);const E="custom-apps";let N=null,S=null;const T=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function L(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,S=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function H(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function g(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function I(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await L()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const u=h.category||"Other";r[u]||(r[u]=[]),r[u].push({id:s,...h})}const c=S?Object.keys(S):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const a=S?.[s]||{};u.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(u.style.borderBottomColor=a.color),P.appendChild(u),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&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 class="app-option-icon">${escapeHtml(a.icon||"\u{1F4E6}")}</div> <div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(a.name)}</div> <div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(a.description||"")}</div> <div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${s}${i} ${o}${d}
`,n?e.onclick=()=>k(a,e):e.onclick=()=>f(a),C.appendChild(e)})}window.renderRecipeCards&&await window.renderRecipeCards(C)}function k(o,l){const v="widget-"+o.id+"-enabled",p=!(safeGet(v)!=="false");safeSet(v,String(p));const m=o.widgetSelector;if(m){const e=document.querySelector(m);e&&(e.style.display=p?"":"none")}const a=l.querySelector('div[style*="border-radius: 4px"]');a&&(a.textContent=p?"ON":"OFF",a.style.background=p?"#2ecc7130":"#e74c3c30",a.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function f(o){const l=document.getElementById("app-deploy-modal"),v=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),m=document.getElementById("deploy-ip"),a=document.getElementById("deploy-port"),e=document.getElementById("deploy-tailscale-only"),n=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${o.name} container: `,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),P.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function k(r,c){const s="widget-"+r.id+"-enabled",u=!(safeGet(s)!=="false");safeSet(s,String(u));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=u?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.name} container:
Container: ${V.name} Container: ${V.name}
Status: ${V.status} Status: ${V.status}
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container? Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container. Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(o._useExisting=!0,o._existingContainer=V)}}catch{}v.textContent=`Deploy ${o.name}`;const t=o.subdomain||o.id.replace(/-/g,"");r.value=t;const s=document.getElementById("subpath-compat-warning");if(s)if(SITE.routingMode==="subdirectory"){const q=o.subpathSupport||"strip";q==="none"?(s.style.display="block",s.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+o.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):q==="strip"?(s.style.display="block",s.innerHTML='<span style="color: var(--muted);">&#9432; '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):s.style.display="none"}else s.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),h=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),d=document.querySelector(`input[name="dns-type"][value="${i}"]`),A=document.querySelector(`input[name="ssl-type"][value="${h}"]`);d?d.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,m.value=SITE.defaults.targetIP||"localhost",e.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),j=document.querySelector("#app-deploy-modal details"),_=j?.querySelector("div");if(j&&_&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const q=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;q&&!q.dataset.moved&&(_.appendChild(q),q.dataset.moved="1"),J&&!J.dataset.moved&&(_.appendChild(J),J.dataset.moved="1")}const U=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),Z=document.getElementById("media-path-description");if(o.mediaMount){U.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const q=document.getElementById("detected-mounts-container"),J=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",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Y=document.createElement("button");Y.type="button";const le=te.includes(ee.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.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>`,Y.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Y.onclick=()=>{const re=W.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(ee.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(ee.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=re.join(", ")},J.appendChild(Y)})}else q.style.display="none"}catch{q.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else U.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const G=document.getElementById("plex-claim-section");G&&(o.id==="plex"||o.claimToken?(G.style.display="block",document.getElementById("deploy-plex-claim").value=""):G.style.display="none");const Q=document.getElementById("volume-mounts-section"),ae=document.getElementById("volume-mounts-list");if(ae.innerHTML="",o.docker?.volumes?.length){const q=o.mediaMount?.containerPath,J=o.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(q&&V.endsWith(":"+q)));J.length>0?(Q.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=` Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+r.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; '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),D=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);D?D.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),J=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",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.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=W.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))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.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="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.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}" <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;" /> 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> <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> <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>
`,ae.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=o.defaultPort||8080;a.value="",a.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",a.parentNode.appendChild(X));async function se(){const q=a.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await $(q);if(J.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(q))} is available</span>`;else{const V=await L(ne);X.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=r.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 J=await H(_);if(J.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(q)} in use by ${escapeHtml(J.conflict?.usedBy||"unknown")}</span> <span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(J.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,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},X.appendChild(K)}}let ie;a.oninput=function(){clearTimeout(ie),ie=setTimeout(se,500)},se();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?n.innerHTML=` `;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 J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?t.innerHTML=`
<span style="color: #4caf50;">Connected</span> <span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span> <span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span> <span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span>
`:J.installed?n.innerHTML='<span style="color: #ff9800;">Not connected</span>':(n.innerHTML='<span style="color: var(--muted);">Not available</span>',e.disabled=!0)}catch{n.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function oe(){const q=r.value||"subdomain",J=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(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(q)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||q;K=SITE.domain?`${te}://${q}.${SITE.domain}`:`${te}://${q}`}else{const te=a.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${m.value}:${te}`}p.textContent=K}r.oninput=oe,m.oninput=oe,a.oninput=oe,document.querySelectorAll('input[name="dns-type"]').forEach(q=>{q.onchange=oe}),document.querySelectorAll('input[name="ssl-type"]').forEach(q=>{q.onchange=oe}),oe(),I.classList.remove("show"),l.classList.add("show"),l.dataset.appTemplate=JSON.stringify(o)}async function x(o){const l=o.appTemplate,v=safeGetJSON(w,[]),r=l._useExisting&&l._existingContainer,p=v.find(m=>m.id===o.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const m=v.indexOf(p);v.splice(m,1),safeSet(w,JSON.stringify(v))}if(r)o.port=l._existingContainer.primaryPort;else{const m=o.port||l.defaultPort||8080;showNotification(`Checking port ${m} availability...`,"info",0);const a=await $(m);if(!a.available){const e=await L(l.defaultPort||8080);if(confirm(`Port ${m} is already in use by ${a.conflict?.usedBy||"another container"}. `:J.installed?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.value||"subdomain",J=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(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}u.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),T.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function $(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,u=s.find(a=>a.id===r.subdomain);if(!(u&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(u){const a=s.indexOf(u);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await H(a);if(!e.available){const n=await g(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${e} instead?`))o.port=e;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${l.name} with existing container...`:`Deploying ${l.name}...`,"info",0);try{const m={appId:l.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||l.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};r&&(m.config.useExisting=!0,m.config.existingContainerId=l._existingContainer.id,m.config.existingPort=l._existingContainer.primaryPort,!o.port&&l._existingContainer.primaryPort&&(m.config.port=l._existingContainer.primaryPort));const e=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();if(e.success){const n={id:o.subdomain,name:l.name,logo:`/assets/${l.id}.png`,containerId:e.containerId,url:e.url,ip:o.ip,appTemplate:l.id,tailscaleOnly:o.tailscaleOnly||!1};v.push(n),safeSet(w,JSON.stringify(v)),window.APPS&&!window.APPS.some(s=>s.id===l.id)&&(window.APPS.push(n),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let t=e.usedExisting?`${l.name} configured with existing container! Would you like to use port ${n} instead?`))r.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(h?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const a={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};h&&(a.config.useExisting=!0,a.config.existingContainerId=c._existingContainer.id,a.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(a.config.port=c._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();if(n.success){const t={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:n.containerId,url:n.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};s.push(t),safeSet(E,JSON.stringify(s)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(t),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=n.usedExisting?`${c.name} configured with existing container!
URL: ${e.url}`:`${l.name} deployed successfully! URL: ${n.url}`:`${c.name} deployed successfully!
URL: ${e.url}`;e.warning&&(t+=` URL: ${n.url}`;n.warning&&(i+=`
\u26A0 Warning: ${e.warning}`),showNotification(t,"success",8e3),delete l._useExisting,delete l._existingContainer,e.url&&e.url.startsWith("https://")&&E(e.url,l.name),e.setupInstructions&&e.setupInstructions.length>0&&setTimeout(()=>{const s=e.setupInstructions.join(` \u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&C(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${l.name}: ${s}`,"info",1e4)},1e3)}else throw new Error(e.error||"Deployment failed")}catch(m){console.error("Deployment error:",m),showNotification(`Failed to deploy ${l.name}: ${m.message}`,"error",8e3)}}}async function E(o,l){showNotification(`\u23F3 Generating SSL certificate for ${l}...`,"warning",6e4);let v=0;const r=12,p=async()=>{v++;try{const m=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${l} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return v<r?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${l} deployed but SSL certificate may still be generating. `);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){b.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function C(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,u=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(u,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(p,3e3)}function R(){safeGetJSON(w,[]).forEach(l=>{window.APPS.some(v=>v.id===l.id)||window.APPS.push(l)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{g(),I.classList.add("show")}),wireModal(I,document.getElementById("app-selector-cancel"));const O=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{O.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(O.dataset.appTemplate),l=document.getElementById("deploy-media-path").value.trim(),v=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{v.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:o,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:l||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:v.length>0?v:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!l){showNotification("Please enter a media library path for this application","warning");return}O.classList.remove("show"),x(r)}),wireModal(O);const D=document.getElementById("folder-browser-modal"),N=document.getElementById("folder-browser-path"),M=document.getElementById("folder-browser-list"),H=document.getElementById("folder-browser-selected"),S=document.getElementById("folder-browser-selected-list");let T="",b=[],u=null;window.openFolderBrowser=function(o){u=o,b=o.value.split(",").map(l=>l.trim()).filter(l=>l),T="",c(),y(""),D.classList.add("show")};async function y(o){N.textContent=o||"Select a drive...",M.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const v=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(o)}`)).json();if(!v.success){M.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(v.error)}</div>`;return}T=v.path||"",N.textContent=T||"Select a drive...";let r="";v.parent&&v.parent!==v.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(v.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;"> Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{I(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(M.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{s.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const h={appTemplate:r,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:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),$(h)}),wireModal(M);const j=document.getElementById("folder-browser-modal"),B=document.getElementById("folder-browser-path"),A=document.getElementById("folder-browser-list"),w=document.getElementById("folder-browser-selected"),z=document.getElementById("folder-browser-selected-list");let f="",p=[],y=null;window.openFolderBrowser=function(r){y=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",m(),v(""),j.classList.add("show")};async function v(r){B.textContent=r||"Select a drive...",A.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!s.success){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",B.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<div class="folder-item" data-path="${escapeHtml(s.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="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span> <span style="color: var(--muted);">.. Parent Directory</span>
</div>`),v.items.length===0&&!v.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':v.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':v.items.forEach(p=>{const m=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",a=b.includes(p.path),e=a?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${e}"> </div>`),s.items.length===0&&!s.parent?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(u=>{const a=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(u.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(u.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;">${m}</span> <span style="font-size: 1.2rem;">${a}</span>
<span style="flex: 1;">${escapeHtml(p.name)}</span> <span style="flex: 1;">${escapeHtml(u.name)}</span>
${a?'<span style="color: var(--success);">\u2713</span>':""} ${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),M.innerHTML=r,M.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{y(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const m=b.includes(p.dataset.path);p.style.background=m?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(l){M.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(l.message)}</div>`}}function c(){if(b.length===0){H.style.display="none";return}H.style.display="block",S.innerHTML=b.map(o=>` </div>`}),A.innerHTML=h,A.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{v(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const a=p.includes(u.dataset.path);u.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function m(){if(p.length===0){w.style.display="none";return}w.style.display="block",z.innerHTML=p.map(r=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;"> <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(o)} ${escapeHtml(r)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(o)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button> <button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span> </span>
`).join("")}window.removeSelectedFolder=function(o){b=b.filter(l=>l!==o),c(),y(T)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{T&&!b.includes(T)&&(b.push(T),c(),y(T))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{u&&(u.value=b.join(", ")),D.classList.remove("show")}),R()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal"> `).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),m(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),m(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{y&&(y.value=p.join(", ")),j.classList.remove("show")}),R()})(),(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;"> <div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3> <h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,70 +445,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> <button id="recipe-next" class="btn-accent-solid">Next</button>
</div> </div>
</div> </div>
</div>`);let w=null,B=null,z=null,I=1,C=!1;const P=document.getElementById("recipe-deploy-modal"),$=document.getElementById("recipe-cancel"),L=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,$);async function k(){try{const b=await fetch("/api/v1/recipes/templates"),u=await b.json();if(u.success)return w=u.templates,B=u.categories,!0;if(b.status===403)return C=!1,!1}catch(b){console.warn("Failed to fetch recipe templates:",b.message)}return!1}async function f(){try{C=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{C=!1}return C}window.renderRecipeCards=async function(b){await f();let u;if(C&&w?u=w:u=x(),!u||u.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",b.appendChild(y);const c=Array.isArray(u)?u:Object.values(u);c.sort((o,l)=>(l.popularity||0)-(o.popularity||0));for(const o of c){const l=document.createElement("div");l.className="app-option",l.style.position="relative";const v=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${o.componentCount||o.components?.length||"?"} apps</div>`,r=C?"":'<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>';l.innerHTML=` </div>`);let b=null,E=null,N=null,S=1,T=!1;const P=document.getElementById("recipe-deploy-modal"),L=document.getElementById("recipe-cancel"),H=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,L);async function I(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return b=p.templates,E=p.categories,!0;if(f.status===403)return T=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{T=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{T=!1}return T}window.renderRecipeCards=async function(f){await k();let p;if(T&&b?p=b:p=x(),!p||p.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",f.appendChild(y);const v=Array.isArray(p)?p:Object.values(p);v.sort((m,r)=>(r.popularity||0)-(m.popularity||0));for(const m of v){const r=document.createElement("div");r.className="app-option",r.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;">${m.componentCount||m.components?.length||"?"} apps</div>`,s=T?"":'<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>';r.innerHTML=`
${r} ${s}
<div class="app-option-icon">${escapeHtml(o.icon||"\u{1F9EA}")}</div> <div class="app-option-icon">${escapeHtml(m.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(o.name)}</div> <div class="app-option-name">${escapeHtml(m.name)}</div>
<div class="app-option-desc">${escapeHtml(o.description||"")}</div> <div class="app-option-desc">${escapeHtml(m.description||"")}</div>
${v} ${c}
`,l.onclick=()=>{if(!C){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(o)},b.appendChild(l)}};function x(){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 E(b){z=b,I=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${b.name}`,R(),O(),P.classList.add("show")}function R(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(b=>{const u=parseInt(b.dataset.step);b.classList.toggle("active",u===I),b.classList.toggle("completed",u<I)});for(let b=1;b<=4;b++){const u=document.getElementById(`recipe-step-${b}`);u&&(u.style.display=b===I?"":"none")}L.style.display=I>1&&I<4?"":"none",I===4?(g.style.display="none",$.textContent="Close"):I===3?(g.textContent="\u{1F680} Deploy",g.style.display="",$.textContent="Cancel"):(g.textContent="Next",g.style.display="",$.textContent="Cancel")}function O(){const b=document.getElementById("recipe-component-list");b.innerHTML="";const u=z.components||[];for(const y of u){const c=document.createElement("div");c.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const o=y.required,l=y.internal;c.innerHTML=` `,r.onclick=()=>{if(!T){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}$(m)},f.appendChild(r)}};function x(){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 $(f){N=f,S=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,C(),R(),P.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===S),f.classList.toggle("completed",p<S)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===S?"":"none")}H.style.display=S>1&&S<4?"":"none",S===4?(g.style.display="none",L.textContent="Close"):S===3?(g.textContent="\u{1F680} Deploy",g.style.display="",L.textContent="Cancel"):(g.textContent="Next",g.style.display="",L.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const y of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const m=y.required,r=y.internal;v.innerHTML=`
<input type="checkbox" ${o?"checked disabled":"checked"} data-component-id="${y.id}" <input type="checkbox" ${m?"checked disabled":"checked"} data-component-id="${y.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" /> style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div> <div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);"> <div style="font-size: 0.78rem; color: var(--muted);">
${y.templateRef?escapeHtml(y.templateRef):"Built-in"} ${y.templateRef?escapeHtml(y.templateRef):"Built-in"}
${o?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'} ${m?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${l?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""} ${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div> </div>
${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""} ${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""}
</div> </div>
`,b.appendChild(c)}}function D(){const b=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),y=z.sharedVolumes;if(y&&Object.keys(y).length>0){b.style.display="",u.innerHTML="";for(const[c,o]of Object.entries(y)){const l=document.createElement("div");l.style.cssText="display: grid; gap: 4px;",l.innerHTML=` `,f.appendChild(v)}}function M(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),y=N.sharedVolumes;if(y&&Object.keys(y).length>0){f.style.display="",p.innerHTML="";for(const[v,m]of Object.entries(y)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(o.label||c)}</label> <label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(m.label||v)}</label>
<input type="text" data-volume-key="${c}" value="${escapeHtml(o.defaultPath||"")}" <input type="text" data-volume-key="${v}" value="${escapeHtml(m.defaultPath||"")}"
placeholder="${escapeHtml(o.defaultPath||"/path")}" placeholder="${escapeHtml(m.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;" /> 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(o.description||"")}</div> <div class="form-hint-sm">${escapeHtml(m.description||"")}</div>
`,u.appendChild(l)}}else b.style.display="none"}function N(){const b=document.getElementById("recipe-review-content"),u=M(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),c={};y.forEach(r=>{c[r.dataset.volumeKey]=r.value});const o=document.getElementById("recipe-timezone").value||"UTC",l=document.getElementById("recipe-ip").value||"host.docker.internal",v=document.getElementById("recipe-tailscale").checked;b.innerHTML=` `,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=B(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};y.forEach(s=>{v[s.dataset.volumeKey]=s.value});const m=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(z.name)}</div> <div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(N.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(z.description||"")}</div> <div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(N.description||"")}</div>
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<strong>Components (${u.length}):</strong> <strong>Components (${p.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;"> <div style="display: grid; gap: 4px; margin-top: 6px;">
${u.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;"> ${p.map(s=>`<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>':""} \u2022 <strong>${escapeHtml(s.role||s.id)}</strong> ${s.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")} </div>`).join("")}
</div> </div>
</div> </div>
${Object.keys(c).length>0?`<div style="margin-bottom: 12px;"> ${Object.keys(v).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong> <strong>Volumes:</strong>
${Object.entries(c).map(([r,p])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(p)}</div>`).join("")} ${Object.entries(v).map(([s,h])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${s}: ${escapeHtml(h)}</div>`).join("")}
</div>`:""} </div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);"> <div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(o)} &bull; IP: ${escapeHtml(l)} ${v?"&bull; Tailscale only":""} Timezone: ${escapeHtml(m)} &bull; IP: ${escapeHtml(r)} ${c?"&bull; Tailscale only":""}
</div> </div>
${z.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(z.network.name)}</code></div>`:""} ${N.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(N.network.name)}</code></div>`:""}
`}function M(){const b=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;b.forEach(c=>{c.checked&&u.add(c.dataset.componentId)});const y=z.components||[];return y.filter(c=>c.required).forEach(c=>u.add(c.id)),y.filter(c=>u.has(c.id))}async function H(){const b=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",b.innerHTML="";const y=M();for(const v of y){const r=document.createElement("div");r.id=`recipe-progress-${v.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 B(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const y=N.components||[];return y.filter(v=>v.required).forEach(v=>p.add(v.id)),y.filter(v=>p.has(v.id))}async function A(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const y=B();for(const c of y){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.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;",s.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span> <span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(v.role||v.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> <span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,b.appendChild(r)}const c=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),o={};c.forEach(v=>{o[v.dataset.volumeKey]=v.value});const l={selectedComponents:y.map(v=>v.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:o},componentOverrides:{}};for(const v of y)S(v.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:z.id,config:l})})).json();if(r.success){for(const p of r.deployed||[])S(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])S(p.componentId,"error",p.error);u.style.display="",u.innerHTML=` `,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};v.forEach(c=>{m[c.dataset.volumeKey]=c.value});const r={selectedComponents:y.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:m},componentOverrides:{}};for(const c of y)w(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])w(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])w(h.componentId,"error",h.error);p.style.display="",p.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="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> <div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(s.message||"Deployed!")}</div>
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;"> ${s.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong> <strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(p=>`<li>${escapeHtml(p)}</li>`).join("")}</ul> <ul style="margin: 4px 0 0 16px; padding: 0;">${s.setupInstructions.map(h=>`<li>${escapeHtml(h)}</li>`).join("")}</ul>
</div>`:""} </div>`:""}
</div> </div>
`,showNotification(`${z.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.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);"> `,showNotification(`${N.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else p.style.display="",p.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")} <strong>Deployment failed:</strong> ${escapeHtml(s.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(v){u.style.display="",u.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);"> </div>`,showNotification(`Recipe deployment failed: ${s.error}`,"error",5e3)}catch(c){p.style.display="",p.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(v.message)} <strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function S(b,u,y){const c=document.getElementById(`recipe-progress-${b}`);if(!c)return;const o=c.querySelector(".recipe-progress-icon"),l=c.querySelector(".recipe-progress-status");u==="deploying"?(o.textContent="\u23F3",l.style.color="var(--accent)"):u==="success"?(o.textContent="\u2705",l.style.color="var(--ok-fg)"):u==="error"&&(o.textContent="\u274C",l.style.color="var(--bad-fg)"),l.textContent=y}g.addEventListener("click",()=>{if(I===3){I=4,R(),H();return}I<3&&(I++,R(),I===2&&D(),I===3&&N())}),L.addEventListener("click",()=>{I>1&&I<4&&(I--,R())}),window.groupRecipeCards=function(){const b=document.querySelectorAll(".service-card[data-recipe-id]");if(b.length===0)return;const u={};b.forEach(y=>{const c=y.dataset.recipeId;u[c]||(u[c]=[]),u[c].push(y)});for(const[y,c]of Object.entries(u))c.length<2||c.forEach((o,l)=>{if(o.style.borderLeft="3px solid rgba(142,68,173,0.5)",l===0){let v=o.querySelector(".recipe-group-label");v||(v=document.createElement("div"),v.className="recipe-group-label",v.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;",v.textContent=y.replace(/-/g," "),o.style.position="relative",o.appendChild(v))}})},window.manageRecipe=async function(b,u){const y=`/api/v1/recipes/${b}/${u}`,c=u==="remove"?"DELETE":"POST",o=u==="remove"?`/api/v1/recipes/${b}`:y;if(!(u==="remove"&&!confirm(`Remove the entire ${b} recipe? This will delete all containers and configuration.`)))try{const v=await(await secureFetch(o,{method:c})).json();v.success?(showNotification(`Recipe ${u}: ${v.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${v.error}`,"error",5e3)}catch(l){showNotification(`Network error: ${l.message}`,"error",5e3)}};const T=document.createElement("style");T.textContent=` </div>`}}function w(f,p,y){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const m=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(m.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(m.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(m.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=y}g.addEventListener("click",()=>{if(S===3){S=4,C(),A();return}S<3&&(S++,C(),S===2&&M(),S===3&&j())}),H.addEventListener("click",()=>{S>1&&S<4&&(S--,C())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(y=>{const v=y.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(y)});for(const[y,v]of Object.entries(p))v.length<2||v.forEach((m,r)=>{if(m.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=m.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=y.replace(/-/g," "),m.style.position="relative",m.appendChild(c))}})},window.manageRecipe=async function(f,p){const y=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",m=p==="remove"?`/api/v1/recipes/${f}`:y;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(m,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const z=document.createElement("style");z.textContent=`
.recipe-step { .recipe-step {
flex: 1; flex: 1;
text-align: center; text-align: center;
@@ -550,16 +550,127 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel { .recipe-step-panel {
min-height: 180px; min-height: 180px;
} }
`,document.head.appendChild(T),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),B=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const z=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await z.json();if(z.ok&&I.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(z){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${z.message}`,"error"),setTimeout(()=>{w.textContent=B,w.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 w=document.getElementById("error-log-modal"),B=document.getElementById("error-log-content"),z=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),C=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function $(){B.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const k=await(await fetch("/api/v1/error-logs")).json();k.success&&k.logs?k.logs.length===0?B.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':B.innerHTML=k.logs.map(f=>` `,document.head.appendChild(z),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),E=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await N.json();if(N.ok&&S.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(N){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{b.textContent=E,b.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 b=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),S=document.getElementById("error-log-refresh"),T=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function L(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const I=await(await fetch("/api/v1/error-logs")).json();I.success&&I.logs?I.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=I.logs.map(k=>`
<div class="log-entry error"> <div class="log-entry error">
<span class="log-timestamp">${new Date(f.timestamp).toLocaleString()}</span> <span class="log-timestamp">${new Date(k.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span> <span class="log-level">ERROR</span>
<div class="log-message"> <div class="log-message">
<strong>${escapeHtml(f.context)}</strong>: ${escapeHtml(f.error)} <strong>${escapeHtml(k.context)}</strong>: ${escapeHtml(k.error)}
${f.details?`<br><small style="opacity: 0.7;">${escapeHtml(f.details)}</small>`:""} ${k.details?`<br><small style="opacity: 0.7;">${escapeHtml(k.details)}</small>`:""}
</div> </div>
</div> </div>
`).join(""):B.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){B.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function L(){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),$()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}z?.addEventListener("click",()=>{w.classList.add("show"),$()}),I?.addEventListener("click",$),C?.addEventListener("click",L),wireModal(w,P)})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal"> `).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function H(){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),L()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}N?.addEventListener("click",()=>{b.classList.add("show"),L()}),S?.addEventListener("click",L),T?.addEventListener("click",H),wireModal(b,P)})(),(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>
<h3 style="margin: 0;">\u{1F4DC} Container Logs</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
<option value="">Select a container...</option>
</select>
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="50">Last 50 lines</option>
<option value="100" selected>Last 100 lines</option>
<option value="500">Last 500 lines</option>
<option value="1000">Last 1000 lines</option>
<option value="all">All logs</option>
</select>
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F504} Refresh</button>
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">\u25B6 Stream</button>
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">\u{1F4BE} Download</button>
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">\u2715</button>
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">\u2715</button>
</div>
</div>
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
</div>
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
</div>
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
<span id="cl-stream-indicator">\u{1F534}</span> <span id="cl-stream-text">Disconnected</span>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="cl-line-count">0 lines</span>
<span>|</span>
<span id="cl-filter-count">0 filtered</span>
</div>
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),T=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),L=document.getElementById("cl-stream"),H=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),I=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),$=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),B=document.getElementById("cl-status"),A=document.getElementById("cl-created");let w=null,z=[],f=[],p=null,y=!1,v=null;function m(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const D=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${D}" data-index="${l}" 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;">${l+1}</span>
<span class="log-stream-icon" style="color: ${d.stream==="stderr"?"#f59e0b":"#22c55e"};">${O}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(d.text)}</span>
</div>
`}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",M.textContent="0 filtered";return}if(z=d,f=l?d.filter(D=>D.text&&D.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,M.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((D,O)=>c(D,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],D=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),D&&E.querySelector(`option[value="${D}"]`)&&(E.value=D,u(D))}catch(d){console.error("Failed to load containers:",d)}}function u(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",B.textContent=l.dataset.status||"-",B.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",A.textContent=m(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),w=d,u(d);const l=T.value,D=S.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,D):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",M.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),y=!0,L.textContent="\u23F9 Stop",x.style.display="flex",$.textContent="\u{1F7E2}",C.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{$.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},p.onmessage=D=>{try{const O=JSON.parse(D.data);if(O.error){$.textContent="\u{1F534}",C.textContent=`Error: ${O.error}`;return}z.push(O),f.push(O),R.textContent=`${z.length} lines`,M.textContent=`${f.length} shown`;const F=S.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{$.textContent="\u{1F534}",C.textContent="Disconnected",y=!1,L.textContent="\u25B6 Stream"},b._eventSource=p}function n(){p&&(p.close(),p=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),y=!1,L.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!z||z.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${w}"]`)?.dataset.name||w,l=new Date().toISOString().replace(/[:.]/g,"-"),D=`${d}-logs-${l}.txt`,O=z.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
`),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=D,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${z.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),T?.addEventListener("change",()=>{a()}),P?.addEventListener("click",()=>{a()}),L?.addEventListener("click",()=>{y?n():e()}),H?.addEventListener("click",()=>{t()}),g?.addEventListener("click",()=>{S.value="",s(z,"")}),S?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(z,S.value.trim())},300)}),S?.addEventListener("keydown",d=>{d.key==="Escape"&&(S.value="",s(z,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),h()});function o(){n(),b.classList.remove("show")}I?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&b.classList.contains("show")&&o()}),wireModal(b,null,o),window.openContainerLogsModal=function(d,l){b.classList.add("show"),h().then(()=>{const D=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);D?(E.value=D.value,u(D.value),a()):d?(w=d,j.textContent=l||d,B.textContent="-",A.textContent="-",a()):N.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">
Create and manage Docker container checkpoints for instant state recovery.
</p>
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="">-- Select a container --</option>
</select>
</div>
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
</div>
</div>
<div class="panel-tabs" style="margin-bottom: 12px;">
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
</div>
<div id="snapshot-create" class="panel-section active">
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 12px;">
<label class="checkbox-label" style="font-size: 0.85rem;">
<input type="checkbox" id="snapshot-leave-running" checked />
Leave container running after checkpoint (resume without restart)
</label>
</div>
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">\u{1F4BE} Create Snapshot</button>
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
</div>
<div id="snapshot-list" class="panel-section" style="display: none;">
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span>Select a container to view its snapshots</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),N=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),T=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),L=document.getElementById("snapshot-create-status");let H=null;async function g(){try{const R=await(await fetch("/api/v1/containers")).json();if(!R.success||!R.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const M of R.containers){const j=document.createElement("option");j.value=M.id,j.textContent=`${M.name||M.id} (${M.image||"unknown"})`,j.dataset.name=M.name,j.dataset.image=M.image,j.dataset.status=M.status,j.dataset.created=M.created,S.appendChild(j)}}catch(C){console.error("Failed to load containers:",C)}}function I(C){if(!C||!C.value){T.style.display="none",H=null;return}H=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),T.style.display=""}async function k(){if(!H){L.textContent="Please select a container first",L.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){L.textContent="Please enter a snapshot name",L.style.color="var(--bad-fg)";return}const R=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",L.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(H)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:R})})).json();j.success?(L.textContent=`\u2713 Snapshot "${C}" created successfully`,L.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(L.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,L.style.color="var(--bad-fg)")}catch(M){L.textContent=`\u2717 Error: ${M.message}`,L.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function x(){b.classList.add("show"),g()}function $(){b.classList.remove("show"),T.style.display="none",H=null,S.selectedIndex=0}E?.addEventListener("click",x),N?.addEventListener("click",$),wireModal(b,N),S?.addEventListener("change",C=>{const R=S.options[S.selectedIndex];I(R)}),P?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(R=>R.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(R=>R.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.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;"> <div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3> <h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;"> <p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -638,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button> <button id="arr-setup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),z=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),C=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),$=document.getElementById("smart-phase-progress"),L=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),k=document.getElementById("smart-credential-inputs"),f=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),R=document.getElementById("smart-retry-btn");let O=null;const D={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},N={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(c){C.style.display=c==="detect"?"block":"none",P.style.display=c==="credentials"?"block":"none",$.style.display=c==="progress"?"block":"none",L.style.display=c==="results"?"block":"none"}function H(c){const o={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"}},l=o[c]||o.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, ${l.bg} 20%, transparent); color: ${l.bg};">${l.icon} ${l.text}</span>`}async function S(){M("detect"),g.style.display="none";try{if(O=await(await fetch("/api/v1/arr/smart-detect")).json(),!O.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(O.error)}</div>`,g.style.display="block";return}let o='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[v,r]of Object.entries(O.services)){const p=D[v]||"\u{1F4E6}",m=N[v]||v,a=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>':"";o+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`);const b=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),T=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),L=document.getElementById("smart-phase-progress"),H=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),I=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),$=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let R=null;const M={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 B(v){T.style.display=v==="detect"?"block":"none",P.style.display=v==="credentials"?"block":"none",L.style.display=v==="progress"?"block":"none",H.style.display=v==="results"?"block":"none"}function A(v){const m={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"}},r=m[v]||m.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, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function w(){B("detect"),g.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,g.style.display="block";return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=M[c]||"\u{1F4E6}",u=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";m+=`<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;">${p}</span> <span style="font-size: 1.1rem;">${h}</span>
<div style="flex: 1;"> <div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${m}</div> <div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;"> <div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${a} ${e} ${n} ${a} ${e} ${n}
</div> </div>
</div> </div>
${H(r.status)} ${A(s.status)}
</div>`}o+="</div>";const l=O.summary;o+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;"> </div>`}m+="</div>";const r=R.summary;m+=`<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(l.fullyConnected))}/${escapeHtml(String(l.totalDetected+(5-l.totalDetected)))} services detected &middot; ${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected &middot;
${escapeHtml(String(l.fullyConnected))} connected${l.needsApiKey>0?` &middot; <strong>${escapeHtml(String(l.needsApiKey))} needs API key</strong>`:""} ${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` &middot; <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
</div>`,g.innerHTML=o,g.style.display="block",T(O),setTimeout(()=>{M("credentials")},800)}catch(c){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(c.message)}</div>`,g.style.display="block"}}function T(c){let o="";const l=c.services,v=["radarr","sonarr","prowlarr"];for(const m of v){const a=l[m];if(!a||a.status==="not_found"&&!a.url)continue;const e=D[m],n=N[m],t=a.status==="connected";o+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};"> </div>`,g.innerHTML=m,g.style.display="block",z(R),setTimeout(()=>{B("credentials")},800)}catch(v){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,g.style.display="block"}}function z(v){let m="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const u of c){const a=r[u];if(!a||a.status==="not_found"&&!a.url)continue;const e=M[u],n=j[u],t=a.status==="connected";m+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span> <span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${n}</span> <span style="font-weight: 500;">${n}</span>
<span id="smart-${m}-status" style="margin-left: auto; font-size: 0.75rem;"> <span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
${t?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""} ${t?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span> </span>
</div> </div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;"> <div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label> <label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${m}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${m}/" <input type="text" id="smart-${u}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${u}/"
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;" /> 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>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label> <label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${m}-key" placeholder="${t?"(saved)":"Paste API key"}" <input type="password" id="smart-${u}-key" placeholder="${t?"(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;" /> 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>
</div> </div>
<button onclick="smartTestConnection('${m}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button> <button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const r=l.plex;if(r){const m=r.status==="connected";o+=`<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>`}const s=r.plex;if(s){const u=s.status==="connected";m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span> <span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span> <span style="font-weight: 500;">Plex</span>
${H(r.status)} ${A(s.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(s.source||"")}</span>
</div> </div>
</div>`}const p=l.seerr;if(p){const m=p.status==="connected";let a="";if(p.configuredServices){const e=p.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;"> </div>`}const h=r.seerr;if(h){const u=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot; Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot; ${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"} ${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}o+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"var(--ok-fg)":"var(--border)"};"> </div>`}m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;"> <div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span> <span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span> <span style="font-weight: 500;">Seerr</span>
${H(p.status)} ${A(h.status)}
</div> </div>
${a} ${a}
</div>`}k.innerHTML=o}window.smartTestConnection=async function(c){const o=document.getElementById(`smart-${c}-url`),l=document.getElementById(`smart-${c}-key`),v=document.getElementById(`smart-${c}-status`),r=o?.value.trim(),p=l?.value.trim();if(!r||!p){v.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}v.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:c,url:r,apiKey:p})})).json();a.success?v.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:v.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(a.error)}</span>`}catch(m){v.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(m.message)}</span>`}};async function b(){M("progress"),f.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 c={};for(const l of["radarr","sonarr","prowlarr"]){const v=document.getElementById(`smart-${l}-url`)?.value.trim(),r=document.getElementById(`smart-${l}-key`)?.value.trim();r&&v?c[l]={apiKey:r,url:v}:r&&(c[l]={apiKey:r})}const o={services:Object.keys(c).length>0?c: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 v=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();let r="";for(const p of v.steps||[]){const m=p.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',a=p.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;"> </div>`}I.innerHTML=m}window.smartTestConnection=async function(v){const m=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=m?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(a.error)}</span>`}catch(u){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(u.message)}</span>`}};async function f(){B("progress"),k.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 v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const m={services:Object.keys(v).length>0?v: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(m)})).json();let s="";for(const h of c.steps||[]){const u=h.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${m} ${u}
<span>${escapeHtml(p.step)}</span> <span>${escapeHtml(h.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(p.details||"")}</span> <span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(h.details||"")}</span>
</div>`}f.innerHTML=r,setTimeout(()=>u(v),500)}catch(l){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(l.message)}</div>`}}function u(c){M("results");const o=c.summary||{},l=o.failed===0&&o.succeeded>0,v=l?"var(--ok-fg)":"#f39c12",r=l?"&#10003;":"&#9888;",p=l?"All Connected!":`${escapeHtml(String(o.succeeded))}/${escapeHtml(String(o.totalSteps))} Steps Succeeded`;let m=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${v} 12%, transparent); border-radius: 10px; border: 1px solid ${v}; margin-bottom: 12px;"> </div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){B("results");const m=v.summary||{},r=m.failed===0&&m.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"&#10003;":"&#9888;",h=r?"All Connected!":`${escapeHtml(String(m.succeeded))}/${escapeHtml(String(m.totalSteps))} Steps Succeeded`;let u=`<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: ${v};">${r}</div> <div style="font-size: 1.5rem; color: ${c};">${s}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${v};">${p}</div> <div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${h}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(o.succeeded))} succeeded, ${escapeHtml(String(o.failed))} failed</div> <div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(m.succeeded))} succeeded, ${escapeHtml(String(m.failed))} failed</div>
</div>`;m+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of c.steps||[]){const e=a.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;"> </div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span> ${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span>
</div>`}m+="</div>",x.innerHTML=m,R.style.display=o.failed>0?"block":"none",c.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const o=await(await fetch("/api/v1/plex/libraries")).json();if(o.success&&o.libraries?.length>0){let l=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}u+="</div>",x.innerHTML=u,C.style.display=m.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const m=await(await fetch("/api/v1/plex/libraries")).json();if(m.success&&m.libraries?.length>0){let r=`<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(o.serverName)} Libraries</h4> <h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(m.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const v of o.libraries){const r=v.type==="movie"?"\u{1F3AC}":v.type==="show"?"\u{1F4FA}":"\u{1F3B5}";l+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;"> <div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of m.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${r} <strong>${escapeHtml(v.title)}</strong> ${s} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(v.count))} items</span> <span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}l+="</div></div>",E.innerHTML=l,E.style.display="block"}}catch{}}B?.addEventListener("click",()=>{w.classList.add("show"),E.style.display="none",S()}),wireModal(w,z),I?.addEventListener("click",b),R?.addEventListener("click",b)})(),(function(){injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal"> </div>`}r+="</div></div>",$.innerHTML=r,$.style.display="block"}}catch{}}E?.addEventListener("click",()=>{b.classList.add("show"),$.style.display="none",w()}),wireModal(b,N),S?.addEventListener("click",f),C?.addEventListener("click",f)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3> <h3>\u{1F514} Notification Settings</h3>
@@ -884,15 +995,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> <button id="notifications-save" class="btn-accent">Save Settings</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("notifications-modal"),B=document.getElementById("manage-notifications"),z=document.getElementById("notifications-save"),I=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(f=>{const x=document.getElementById(`${f}-enabled`),E=document.getElementById(`${f}-config`);x?.addEventListener("change",()=>{E.style.display=x.checked?"block":"none"})});const C=document.getElementById("health-check-enabled"),P=document.getElementById("health-check-config");C?.addEventListener("change",()=>{P.style.opacity=C.checked?"1":"0.5"});async function $(){try{const x=await(await fetch("/api/v1/notifications/config")).json();if(x.success){const E=x.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.containerDown!==!1,document.getElementById("event-container-up").checked=E.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=E.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=E.events?.deploymentFailed!==!1}}catch(f){console.error("Failed to load notification config:",f)}}async function L(){try{const x=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");x.success&&x.history?.length>0?E.innerHTML=x.history.map(R=>{const O=new Date(R.timestamp).toLocaleString();return` </div>`);const E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const $=document.getElementById(`${x}-enabled`),C=document.getElementById(`${x}-config`);$?.addEventListener("change",()=>{C.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),L=document.getElementById("health-check-config");P?.addEventListener("change",()=>{L.style.opacity=P.checked?"1":"0.5"});async function H(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const C=$.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");$.success&&$.history?.length>0?C.innerHTML=$.history.map(R=>{const M=new Date(R.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;"> <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)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span> <span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;"> <div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div> <div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div> <div style="font-size: 0.7rem; color: var(--muted);">${M}</div>
</div> </div>
</div> </div>
`}).join(""):E.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(f){console.error("Failed to load notification history:",f)}}async function g(){try{const f={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},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)})).json();E.success?(showNotification("Notification settings saved","success",3e3),w.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}async function k(f){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:f})})).json();E.success?showNotification(`Test ${f} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const x=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();x.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.lastCheck).toLocaleString()} (${x.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(f){showNotification(`Error: ${f.message}`,"error",3e3)}}),B?.addEventListener("click",()=>{w.classList.add("show"),$(),L()}),z?.addEventListener("click",g),wireModal(w,I)})(),(function(){document.addEventListener("click",w=>{const B=w.target.closest(".panel-tab");if(!B)return;const z=B.dataset.panel;if(!z)return;const I=B.closest(".panel-tabs"),C=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach($=>$.classList.remove("active")),B.classList.add("active"),C.querySelectorAll(".panel-section").forEach($=>$.classList.remove("active"));const P=C.querySelector("#"+z);P&&P.classList.add("active")})})(),(function(){var w=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function B(){for(var e={},n=0;n<w.length;n++){var t=w[n],s=safeGet(t);s!=null&&(e[t]=s)}try{for(var i=0;i<localStorage.length;i++){var h=localStorage.key(i);/^widget-.+-enabled$/.test(h)&&(e[h]=localStorage.getItem(h))}}catch{}return e}function z(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function I(e){return e.version&&!e.files&&e.services}function C(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),z(n),e.services&&Array.isArray(e.services)&&secureF Line truncated `}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function I(){try{const x={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},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();C.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),H(),g()}),S?.addEventListener("click",I),wireModal(E,T)})(),(function(){document.addEventListener("click",b=>{const E=b.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const S=E.closest(".panel-tabs"),T=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),E.classList.add("active"),T.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active"));const P=T.querySelector("#"+N);P&&P.classList.add("active")})})(),(function(){var b=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var e={},n=0;n<b.length;n++){var t=b[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function S(e){return e.version&&!e.files&&e.services}function T(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),e.services&&Array.isArray(e.serv Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3> <h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -976,7 +1087,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button> <button id="backup-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);var P=document.getElementById("backup-modal"),$=document.getElementById("backup-restore-btn"),L=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),k=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),R=document.getElementById("backup-preview-content"),O=document.getElementById("backup-do-restore-btn"),D=document.getElementById("backup-result"),N=document.getElementById("backup-schedule-container"),M=document.getElementById("backup-history-container"),H=null;$?.addEventListener("click",function(){P.classList.add("show"),D&&(D.style.display="none"),E&&(E.style.display="none"),x&&(x.style.display="none"),H=null}),wireModal(P,L),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=B();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),s=URL.createObjectURL(t),i=document.createElement("a");i.href=s,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(s);var h=Object.keys(n.browserState).length,d=n.themes?Object.keys(n.themes).length:0;D.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+h+" browser settings"+(d?" + "+d+" themes":""),D.style.display="block",D.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",D.style.border="1px solid var(--ok-fg)"}catch(A){D.innerHTML="\u274C Export failed: "+escapeHtml(A.message),D.style.display="block",D.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",D.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),k?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",D.style.display="none";try{var t=await n.text(),s=JSON.parse(t);if(I(s)){H=s;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(s.version)+")</div>";i+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',s.services?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+s.services.length+" services</span>"),s.customApps?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+s.customApps.length+" custom apps</span>"),s.theme&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(s.theme)+"</span>"),s.userThemes&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(s.userThemes).length+" custom themes</span>"),i+="</div>",R.innerHTML=i,E.style.display="block";return}var h=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)}),d=await h.json();if(d.success){H=s;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(s.exportedAt).toLocaleString()+" (v"+escapeHtml(s.version)+")</div>";i+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',i+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var A in d.preview.files){var F=d.preview.files[A],j=F.action==="create"?"\u{1F195}":"\u{1F4DD}";i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+j+" "+escapeHtml(F.description)+"</span>"}i+="</div>",d.preview.serviceCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(i+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',i+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),R.innerHTML=i,E.style.display="block"}else D.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),D.style.display="block",D.style.background="color-mix(in srgb, #f39c12 15%, transparent)",D.style.border="1px solid #f39c12",E.style.display="none"}catch(_){D.innerHTML="\u274C Could not read file: "+escapeHtml(_.message),D.style.display="block",D.style.background="color-mix(in Line truncated </div>`);var P=document.getElementById("backup-modal"),L=document.getElementById("backup-restore-btn"),H=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),I=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),$=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),B=document.getElementById("backup-history-container"),A=null;L?.addEventListener("click",function(){P.classList.add("show"),M&&(M.style.display="none"),$&&($.style.display="none"),x&&(x.style.display="none"),A=null}),wireModal(P,H),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=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(D){M.innerHTML="\u274C Export failed: "+escapeHtml(D.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),I?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",M.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(S(i)){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",C.innerHTML=o,$.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.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 D in l.preview.files){var O=l.preview.files[D],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.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 '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=o,$.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",$.style.display="none"}catch(q){M.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),M.style.display="block",M.style.background="color-mix(in Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F4CA} Resource Monitor</h3> <h3>\u{1F4CA} Resource Monitor</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1056,11 +1167,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="stats-cancel">Close</button> <button id="stats-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),z=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),C=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),$=document.getElementById("stats-aggregated-container"),L=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let k=null,f=null;function x(r){if(r===0||!r)return"0 B";const p=1024,m=["B","KB","MB","GB"],a=Math.floor(Math.log(r)/Math.log(p));return parseFloat((r/Math.pow(p,a)).toFixed(1))+" "+m[a]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function R(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function O(){try{let r=null,p=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,p=!0,f=e.stats)}catch{}if(!p){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};f=r}}if(!r||Object.keys(r).length===0){P.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[a,e]of Object.entries(r)){const n=e.current||e,t=n.cpu?.percent||0,s=n.memory?.percent||0,i=E(t),h=R(s),d=n.memory?.usage||n.memory?.used||0,A=n.memory?.limit||0,F=n.network?.rxBytes||n.network?.rx||0,j=n.network?.txBytes||n.network?.tx||0,_=e.aggregated;m+=` </div>`);const b=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),T=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),L=document.getElementById("stats-aggregated-container"),H=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let I=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,u=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+u[a]}function $(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function C(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[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};k=s}}if(!s||Object.keys(s).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=$(t),d=C(i),l=n.memory?.usage||n.memory?.used||0,D=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;u+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> <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;"> <div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||a}</span> <span style="font-weight: 600; flex: 1;">${e.name||a}</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>`:""} ${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-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</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>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;"> <div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
@@ -1068,32 +1179,32 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div> <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="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;"> <div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(t,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div> <div style="height: 100%; width: ${Math.min(t,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
</div> </div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</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="display: flex; align-items: center; gap: 8px;">
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;"> <div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
<div style="height: 100%; width: ${Math.min(s,100)}%; background: ${h}; border-radius: 3px; transition: width 0.3s;"></div> <div style="height: 100%; width: ${Math.min(i,100)}%; background: ${d}; border-radius: 3px; transition: width 0.3s;"></div>
</div> </div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${h}; min-width: 45px; text-align: right;">${s.toFixed(1)}%</span> <span style="font-size: 0.8rem; font-weight: 500; color: ${d}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
</div> </div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(d)} / ${x(A)}</div> <div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(D)}</div>
</div> </div>
<div> <div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div> <div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;"> <div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${x(F)}</span> <span style="color: #3498db;">\u2193 ${x(O)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span> <span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${x(j)}</span> <span style="color: #e74c3c;">\u2191 ${x(F)}</span>
</div> </div>
</div> </div>
</div> </div>
</div>`}m+="</div>",P.innerHTML=m,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function D(){if(!$)return;const r=f;if(!r||Object.keys(r).length===0){$.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 p='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,a]of Object.entries(r)){const e=a.aggregated;e&&(p+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`}u+="</div>",P.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function M(){if(!L)return;const s=k;if(!s||Object.keys(s).length===0){L.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 h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${a.name||m}</div> <div style="font-weight: 600; margin-bottom: 10px;">${a.name||u}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;"> <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?.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> <div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1101,49 +1212,49 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<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 class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</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>`:""} ${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>`)}p+="</div>",$.innerHTML=p}async function N(){if(!L)return;L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=f;if(!r||Object.keys(r).length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,a]of Object.entries(r)){const e=a.alertConfig||{};p+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);"> </div>`)}h+="</div>",L.innerHTML=h}async function j(){if(!H)return;H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;"> <div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${a.name||m}</span> <span style="font-weight: 600; flex: 1;">${a.name||u}</span>
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<input type="checkbox" class="alert-enabled" data-container="${m}" ${e.enabled?"checked":""} /> Enabled <input type="checkbox" class="alert-enabled" data-container="${u}" ${e.enabled?"checked":""} /> Enabled
</label> </label>
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label> <label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
<input type="number" class="alert-cpu" data-container="${m}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" /> <input type="number" class="alert-cpu" data-container="${u}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
</div> </div>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label> <label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
<input type="number" class="alert-mem" data-container="${m}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" /> <input type="number" class="alert-mem" data-container="${u}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
</div> </div>
<div> <div>
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label> <label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
<input type="number" class="alert-cooldown" data-container="${m}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" /> <input type="number" class="alert-cooldown" data-container="${u}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
</div> </div>
</div> </div>
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;"> <div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;"> <label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
<input type="checkbox" class="alert-autorestart" data-container="${m}" ${e.autoRestart?"checked":""} /> Auto-restart on breach <input type="checkbox" class="alert-autorestart" data-container="${u}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
</label> </label>
<span style="flex: 1;"></span> <span style="flex: 1;"></span>
<button class="alert-save-btn" data-container="${m}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button> <button class="alert-save-btn" data-container="${u}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
</div> </div>
</div>`}p+="</div>",L.innerHTML=p,L.querySelectorAll(".alert-save-btn").forEach(m=>{m.addEventListener("click",async()=>{const a=m.dataset.container,e=L.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(L.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(L.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,s=parseInt(L.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,i=L.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const d=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:s,autoRestart:i})})).json();m.textContent=d.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{m.textContent="Save"},2e3)}catch{m.textContent="\u274C Error",setTimeout(()=>{m.textContent="Save"},2e3)}})})}function M(){k&&clearInterval(k),C?.checked&&(k=setInterval(O,DC.POLL.STATS))}function H(){k&&(clearInterval(k),k=null)}B?.addEventListener("click",()=>{w.classList.add("show"),O(),M()}),z?.addEventListener("click",()=>{w.classList.remove("show"),H()}),w?.addEventListener("click",r=>{r.target===w&&(w.classList.remove("show"),H())}),I?.addEventListener("click",O),C?.addEventListener("change",()=>{C.checked?M():H()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",D),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",N);const S=document.getElementById("stats-history-container"),T=document.getElementById("stats-history-container-area"),b=document.querySelectorAll(".stats-range-btn");let u="1h";function y(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 c(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function o(r,p,m,a,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=r.map(p).filter(W=>W!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),s=Math.min(...n,0),i=t-s||1,h=600,d=80,A=4,F=(h-A*2)/Math.max(n.length-1,1),j=n.map((W,Z)=>{const G=A+Z*F,Q=d-A-(W-s)/i*(d-A*2);return`${G.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=n[n.length-1],U=n.reduce((W,Z)=>W+Z,0)/n.length;return` </div>`}h+="</div>",H.innerHTML=h,H.querySelectorAll(".alert-save-btn").forEach(u=>{u.addEventListener("click",async()=>{const a=u.dataset.container,e=H.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(H.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(H.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt(H.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=H.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();u.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{u.textContent="Save"},2e3)}catch{u.textContent="\u274C Error",setTimeout(()=>{u.textContent="Save"},2e3)}})})}function B(){I&&clearInterval(I),T?.checked&&(I=setInterval(R,DC.POLL.STATS))}function A(){I&&(clearInterval(I),I=null)}E?.addEventListener("click",()=>{b.classList.add("show"),R(),B()}),N?.addEventListener("click",()=>{b.classList.remove("show"),A()}),b?.addEventListener("click",s=>{s.target===b&&(b.classList.remove("show"),A())}),S?.addEventListener("click",R),T?.addEventListener("change",()=>{T.checked?B():A()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const w=document.getElementById("stats-history-container"),z=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function y(s){switch(s){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 v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function m(s,h,u,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,D=4,O=(d-D*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=D+W*O,Q=l-D-(G-i)/o*(l-D*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.length;return`
<div style="margin-bottom: 14px;"> <div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;"> <div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(a)}</span> <span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(a)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span> <span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
</div> </div>
<svg viewBox="0 0 ${h} ${d}" preserveAspectRatio="none" style="width: 100%; height: ${d}px; background: var(--base); border-radius: 4px;"> <svg viewBox="0 0 ${d} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${m}" stroke-width="1.5" points="${j}" /> <polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
</svg> </svg>
</div> </div>
`}function l(){if(!S)return;const r=f||{},p=S.value,m=Object.entries(r);if(m.length===0){S.innerHTML='<option value="">No containers</option>';return}S.innerHTML=m.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),p&&r[p]&&(S.value=p)}async function v(){if(!T||!S)return;const r=S.value;if(!r){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const p=Date.now(),m=p-y(u);T.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=${m}&endTime=${p}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${c(t)}.</div>`;return}const s=t==="raw",i=s?j=>j.cpu?.percent:j=>j.cpu?.avg,h=s?j=>j.memory?.percent:j=>j.memory?.avgPercent,d=s?j=>j.network?.rxMB||0:j=>j.network?.rxMB||0,A=s?j=>j.network?.txMB||0:j=>j.network?.txMB||0;let F=` `}function r(){if(!w)return;const s=k||{},h=w.value,u=Object.entries(s);if(u.length===0){w.innerHTML='<option value="">No containers</option>';return}w.innerHTML=u.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(w.value=h)}async function c(){if(!z||!w)return;const s=w.value;if(!s){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),u=h-y(p);z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${u}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,D=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;"> <div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${n.length} samples \xB7 ${escapeHtml(c(t))} \xB7 ${new Date(m).toLocaleString()} \u2192 ${new Date(p).toLocaleString()} ${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
</div> </div>
`;F+=o(n,i,"#2ecc71","CPU","%"),F+=o(n,h,"#3498db","Memory","%"),F+=o(n,d,"#9b59b6","Network RX"," MB"),F+=o(n,A,"#e67e22","Network TX"," MB"),T.innerHTML=F}catch(a){T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}b.forEach(r=>{r.addEventListener("click",()=>{b.forEach(p=>p.classList.remove("active")),r.classList.add("active"),u=r.dataset.range,v()})}),S?.addEventListener("change",v),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{l(),v()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal"> `;O+=m(n,o,"#2ecc71","CPU","%"),O+=m(n,d,"#3498db","Memory","%"),O+=m(n,l,"#9b59b6","Network RX"," MB"),O+=m(n,D,"#e67e22","Network TX"," MB"),z.innerHTML=O}catch(a){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),w?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;"> <div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3> <h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1229,24 +1340,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="health-cancel">Close</button> <button id="health-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),z=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),C=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),$=document.getElementById("health-config-container"),L=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),k=document.getElementById("health-config-form"),f=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save");let R=null;function O(b){return b>=99.9?"var(--ok-fg)":b>=95?"#f39c12":"var(--bad-fg)"}function D(b){const u={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: ${u[b]||"var(--muted)"}20; color: ${u[b]||"var(--muted)"};">${b}</span>`}async function N(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){C.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 y=Object.values(u.status);let c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',c+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',c+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',c+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const o of y){const l=o.status==="up",v=l?"var(--dot-ok)":"var(--dot-bad)",r=o.uptime?.["24h"]??"-",p=o.uptime?.["7d"]??"-",m=o.avgResponseTime!=null?Math.round(o.avgResponseTime)+"ms":"-",a=o.timestamp?timeAgo(o.timestamp):"-";c+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(o.serviceId)}">`,c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.name||o.serviceId)}</td>`,c+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${v}; margin-right: 6px;"></span>${l?"Up":"Down"}</td>`,c+=`<td style="padding: 8px; color: ${typeof r=="number"?O(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,c+=`<td style="padding: 8px; color: ${typeof p=="number"?O(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,c+=`<td style="padding: 8px;">${m}</td>`,c+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,c+="</tr>",c+=`<tr id="health-detail-${escapeHtml(o.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>`}c+="</table>",C.innerHTML=c,L.textContent="Updated "+new Date().toLocaleTimeString(),C.querySelectorAll("tr[data-health-id]").forEach(o=>{o.addEventListener("click",async()=>{const l=o.dataset.healthId,v=document.getElementById("health-detail-"+l);if(v){if(v.style.display!=="none"){v.style.display="none";return}v.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${l}/stats?hours=24`)).json();if(p.success&&p.stats){const m=p.stats,a=m.responseTime||{};v.querySelector("td").innerHTML=` </div>`);const b=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),T=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),L=document.getElementById("health-config-container"),H=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),I=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),$=document.getElementById("health-form-save");let C=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function M(f){const p={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: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){T.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 y=Object.values(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of y){const r=m.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=m.uptime?.["24h"]??"-",h=m.uptime?.["7d"]??"-",u=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",a=m.timestamp?timeAgo(m.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${u}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(m.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>`}v+="</table>",T.innerHTML=v,H.textContent="Updated "+new Date().toLocaleTimeString(),T.querySelectorAll("tr[data-health-id]").forEach(m=>{m.addEventListener("click",async()=>{const r=m.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const u=h.stats,a=u.responseTime||{};c.querySelector("td").innerHTML=`
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;"> <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>${m.totalChecks||0}</strong></div> <div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${O(m.uptime||0)};">${(m.uptime||0).toFixed(2)}%</strong></div> <div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div> <div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div>
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div> <div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div>
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div> <div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div>
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div> <div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${m.upChecks||0}</strong></div> <div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${m.downChecks||0}</strong></div> <div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
</div>`}else v.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){v.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(b){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(b.message)}</div>`}}async function M(){try{const[b,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await b.json(),c=await u.json();let o="";const l=y.success&&y.incidents?y.incidents:[];if(l.length>0){o+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+l.length+")</h4>";for(const r of l)o+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);"> </div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function B(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await f.json(),v=await p.json();let m="";const r=y.success&&y.incidents?y.incidents:[];if(r.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)m+=`<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;"> <div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span> <span style="font-weight: 500;">${escapeHtml(s.serviceId)}</span>
<span>${D(r.severity)}</span> <span>${M(s.severity)}</span>
</div> </div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div> <div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(s.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 style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(s.createdAt)} \xB7 ${s.occurrences||1} occurrence(s)</div>
</div>`;o+="</div>"}else o+='<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 v=c.success&&c.history?c.history:[];if(v.length>0){o+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',o+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',o+='<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 v){const p=r.status==="resolved",m=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";o+='<tr style="border-bottom: 1px solid var(--border);">',o+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,o+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,o+=`<td style="padding: 6px;">${D(r.severity)}</td>`,o+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,o+=`<td style="padding: 6px;">${m}</td>`,o+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,o+="</tr>"}o+="</table>"}P.innerHTML=o||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(b){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}async function H(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),y=u.success&&u.status?Object.values(u.status):[];if(y.length===0){$.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 c='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';c+='<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 o of y){const l=o.status==="up";c+='<tr style="border-bottom: 1px solid var(--border);">',c+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(o.name||o.serviceId)}</td>`,c+=`<td style="padding: 8px; color: ${l?"var(--ok-fg)":"var(--bad-fg)"};">${l?"Up":"Down"}</td>`,c+=`<td style="padding: 8px;">${o.sla?.target?o.sla.target+"%":"-"}</td>`,c+='<td style="padding: 8px; text-align: right;">',c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(o.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,c+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(o.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,c+="</td></tr>"}c+="</table>",$.innerHTML=c}catch(b){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(b.message)}</div>`}}function S(b,u,y,c,o,l,v){R=b||null,f.textContent=b?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=b||"",document.getElementById("health-form-id").disabled=!!b,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=c||1e4,document.getElementById("health-form-codes").value=o||"200",document.getElementById("health-form-sla").value=l||99.9,document.getElementById("health-form-slow").value=v||5e3,k.style.display="",g.style.display="none"}function T(){k.style.display="none",g.style.display="",R=null}g?.addEventListener("click",()=>S("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",T),E?.addEventListener("click",async()=>{const b=R||document.getElementById("health-form-id").value.trim();if(!b)return showNotification("Service ID is required","warning");const u=document.getElementById("health-form-url").value.trim();if(!u)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(o=>parseInt(o.trim())).filter(Boolean),c={name:document.getElementById("health-form-name").value.trim()||b,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:y.length?y:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated </div>`;m+="</div>"}else m+='<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 c=v.success&&v.history?v.history:[];if(c.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',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;">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 s of c){const h=s.status==="resolved",u=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,m+=`<td style="padding: 6px;">${M(s.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,m+=`<td style="padding: 6px;">${u}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function A(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),y=p.success&&p.status?Object.values(p.status):[];if(y.length===0){L.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 v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<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 m of y){const r=m.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",L.innerHTML=v}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function w(f,p,y,v,m,r,c){C=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,I.style.display="",g.style.display="none"}function z(){I.style.display="none",g.style.display="",C=null}g?.addEventListener("click",()=>w("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",z),$?.addEventListener("click",async()=>{const f=C||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:y.length?y:[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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3> <h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1318,17 +1429,17 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="updates-cancel">Close</button> <button id="updates-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),z=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),C=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),$=document.getElementById("updates-auto-container"),L=document.getElementById("updates-last-check");async function g(){try{const m=await(await fetch("/api/v1/updates/available")).json();if(!m.success)throw new Error(m.error);const a=m.updates||[];if(a.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',L.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<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 n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<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(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",C.innerHTML=e,L.textContent=a.length+" update(s) available",C.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,s=n.dataset.name;if(confirm(`Update "${s}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(h.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(h.error||"Update failed")}catch(i){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+i.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),C.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,s=n.dataset.name;if(confirm(`Rollback "${s}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const h=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(h.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(h.error||"Rollback failed")}catch(i){n.textContent="Failed",showNotification("Rollback error: "+i.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(p){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function k(){I.textContent="\u{1F50D} Checking...",I.disabled=!0;try{const m=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!m.success)throw new Error(m.error);I.textContent="\u2705 Done!",await g()}catch(p){I.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{I.textContent="\u{1F50D} Check for Updates",I.disabled=!1},3e3)}async function f(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const m=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=m.success&&m.history?m.history:[];if(a.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<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;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated </div>`);const b=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),T=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),L=document.getElementById("updates-auto-container"),H=document.getElementById("updates-last-check");async function g(){try{const u=await(await fetch("/api/v1/updates/available")).json();if(!u.success)throw new Error(u.error);const a=u.updates||[];if(a.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',H.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<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 n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<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(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",T.innerHTML=e,H.textContent=a.length+" update(s) available",T.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),T.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function I(){S.textContent="\u{1F50D} Checking...",S.disabled=!0;try{const u=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!u.success)throw new Error(u.error);S.textContent="\u2705 Done!",await g()}catch(h){S.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{S.textContent="\u{1F50D} Check for Updates",S.disabled=!1},3e3)}async function k(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const u=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<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;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
<select class="auto-schedule" data-id="${escapeHtml(d)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;"> <select class="auto-schedule" data-id="${escapeHtml(l)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${F?"":" selected"}>Disabled</option> <option value=""${O?"":" selected"}>Disabled</option>
<option value="daily"${F==="daily"?" selected":""}>Daily</option> <option value="daily"${O==="daily"?" selected":""}>Daily</option>
<option value="weekly"${F==="weekly"?" selected":""}>Weekly</option> <option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${F==="monthly"?" selected":""}>Monthly</option> <option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
</select></td>`,s+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(d)}" value="${escapeHtml(_)}" 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>`,s+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(d)}"${j?" checked":""} /></td>`,s+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,s+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(d)}" data-name="${escapeHtml(h)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,s+="</tr>"}s+="</table>",$.innerHTML=s,$.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const h=i.dataset.id,d=i.closest("tr"),A=d.querySelector(".auto-schedule").value,F=d.querySelector(".auto-rollback").checked,j=d.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(h)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:F,maintenanceWindow:j||void 0})})).json();if(U.success)i.textContent="\u2713 Saved";else throw new Error(U.error)}catch(_){i.textContent="\u2717 Error",showNotification("Save error: "+_.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(p){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const E=document.getElementById("dashcaddy-current-version"),R=document.getElementById("dashcaddy-update-badge"),O=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),N=document.getElementById("dashcaddy-changelog"),M=document.getElementById("dashcaddy-apply-btn"),H=document.getElementById("dashcaddy-check-btn"),S=document.getElementById("dashcaddy-rollback-btn"),T=document.getElementById("dashcaddy-status-bar"),b=document.getElementById("dashcaddy-history-container");let u=null;function y(p,m){T&&(T.style.display="block",T.style.background=m==="error"?"var(--bad-bg)":m==="success"?"var(--ok-bg)":"var(--bg)",T.style.color=m==="error"?"var(--bad-fg)":m==="success"?"var(--ok-fg)":"var(--fg)",T.textContent=p)}async function c(){try{const m=await(await fetch("/api/v1/system/version")).json();if(m.success){const a=m.commit&&m.commit!=="unknown"?m.commit:null;E.textContent="v"+m.version+(a?" ("+a.substring(0,7)+")":"")}}catch{E.textContent="Unable to fetch version"}}async function o(p){p||(H.textContent="Checking...",H.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(u=a,a.success&&a.available&&a.remote){R.style.display="",O.style.display="",D.textContent="v"+a.remote.version,N.textContent=a.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",n.appendChild(t)}}else R.style.display="none",O.style.display="none",await c(),p||y("You are running the latest version.","success");p||(H.textContent="Check for Updates",H.disabled=!1)}catch(m){p||(y("Failed to check: "+m.message,"error"),H.textContent="Check for Updates",H.disabled=!1)}}async function l(){if(confirm("Apply DashCaddy update? The API container will restart.")){M.textContent="Updating...",M.disabled=!0,y("Downloading and applying update...","info");try{const m=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(m.success)y("Update initiated: v"+(m.fromVersion||"?")+" \u2192 v"+(m.toVersion||"?")+". The container will restart shortly.","success"),M.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove());else throw new Error(m.error||"Update failed")}catch(p){y("Update failed: "+p.message,"error"),M.textContent="Update Now",M.disabled=!1}}}async function v(){try{const m=await(await fetch("/api/v1/system/update-history")).json(),a=m.success&&m.history?m.history:[];if(a.length===0){b.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="paddLine truncated </select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" 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>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",L.innerHTML=i,L.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),D=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!D,schedule:D||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),M=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),w=document.getElementById("dashcaddy-rollback-btn"),z=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function y(h,u){z&&(z.style.display="block",z.style.background=u==="error"?"var(--bad-bg)":u==="success"?"var(--ok-bg)":"var(--bg)",z.style.color=u==="error"?"var(--bad-fg)":u==="success"?"var(--ok-fg)":"var(--fg)",z.textContent=h)}async function v(){try{const u=await(await fetch("/api/v1/system/version")).json();if(u.success){const a=u.commit&&u.commit!=="unknown"?u.commit:null;$.textContent="v"+u.version+(a?" ("+a.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function m(h){h||(A.textContent="Checking...",A.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){C.style.display="",R.style.display="",M.textContent="v"+a.remote.version,j.textContent=a.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",n.appendChild(t)}}else C.style.display="none",R.style.display="none",await v(),h||y("You are running the latest version.","success");h||(A.textContent="Check for Updates",A.disabled=!1)}catch(u){h||(y("Failed to check: "+u.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,y("Downloading and applying update...","info");try{const u=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(u.success)return y("Update initiated: v"+(u.fromVersion||"?")+" \u2192 v"+(u.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(u.error||"Update failed")}catch(h){throw y("Update failed: "+h.message,"error"),B.textContent="Update Now",B.disabled=!1,h}}async function c(){try{const u=await(await fetch("/api/v1/system/update-history")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+a.join(` `+a.join(`
`)+` `)+`
Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;y("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)y("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(p){y("Rollback failed: "+p.message,"error")}}H?.addEventListener("click",()=>o(!1)),M?.addEventListener("click",l),S?.addEventListener("click",r),I?.addEventListener("click",k),B?.addEventListener("click",()=>{w?.classList.add("show"),g()}),wireModal(w,z),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",f),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{c(),v(),u||o(!0)}),setTimeout(()=>o(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal"> Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;y("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)y("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(h){y("Rollback failed: "+h.message,"error")}}A?.addEventListener("click",()=>m(!1)),B?.addEventListener("click",()=>r().catch(()=>{})),w?.addEventListener("click",s),S?.addEventListener("click",I),E?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||m(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=m,setTimeout(()=>m(!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;"> <div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3> <h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p> <p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1377,7 +1488,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<button id="dr-close">Close</button> <button id="dr-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),z=document.getElementById("dr-close");function I(L){if(!L||L===0)return"0 B";const g=["B","KB","MB","GB","TB"],k=Math.floor(Math.log(Math.abs(L))/Math.log(1024));return(L/Math.pow(1024,k)).toFixed(1)+" "+g[k]}async function C(){const L=document.getElementById("dr-vol-list");try{const k=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(k.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 x of k){const E=x.name==="buildkit"||x.name.length===64;f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,f+='<td style="padding: 6px; text-align: right;">',E||(f+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",L.innerHTML=f,L.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),C()}catch(E){showNotification("Delete failed: "+E.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const L=document.getElementById("dr-vol-name"),g=L.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),L.value="",showNotification(`Volume "${g}" created`,"success"),C()}catch(k){showNotification("Create failed: "+k.message,"error")}});async function P(){const L=document.getElementById("dr-net-list");try{const k=(await getJSON("/api/v1/docker/networks")).networks||[];if(k.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 x of k){const E=["bridge","host","none"].includes(x.name);f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,f+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,f+='<td style="padding: 6px; text-align: right;">',E||(f+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",L.innerHTML=f,L.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),P()}catch(E){showNotification("Delete failed: "+E.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const L=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),k=L.value.trim();if(!k){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:k,driver:g.value}),L.value="",showNotification(`Network "${k}" created`,"success"),P()}catch(f){showNotification("Create failed: "+f.message,"error")}});async fuLine truncated </div>`);const b=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function S(H){if(!H||H===0)return"0 B";const g=["B","KB","MB","GB","TB"],I=Math.floor(Math.log(Math.abs(H))/Math.log(1024));return(H/Math.pow(1024,I)).toFixed(1)+" "+g[I]}async function T(){const H=document.getElementById("dr-vol-list");try{const I=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<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 x of I){const $=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),T()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-vol-name"),g=H.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),H.value="",showNotification(`Volume "${g}" created`,"success"),T()}catch(I){showNotification("Create failed: "+I.message,"error")}});async function P(){const H=document.getElementById("dr-net-list");try{const I=(await getJSON("/api/v1/docker/networks")).networks||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<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 x of I){const $=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),P()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),I=H.value.trim();if(!I){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:I,driver:g.value}),H.value="",showNotification(`Network "${I}" created`,"success"),P()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;"> <div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3> <h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p> <p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1418,8 +1529,8 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<div id="compose-progress-content"></div> <div id="compose-progress-content"></div>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),z=document.getElementById("compose-cancel");wireModal(w,z);let I=null;function C($){document.getElementById("compose-step-paste").style.display=$==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=$==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=$==="progress"?"":"none"}B?.addEventListener("click",()=>{C("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",w?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",$=>{const L=$.target.files[0];if(!L)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(L)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const $=document.getElementById("compose-yaml").value.trim(),L=document.getElementById("compose-stack-name").value.trim()||"stack";if(!$){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),k=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const f=await postJSON("/api/v1/apps/import-compose",{yaml:$,stackName:L});I=f,I.stackName=L,P(f),C("preview")}catch(f){showNotification("Parse failed: "+f.message,"error")}finally{g.textContent=k,g.disabled=!1}});function P($){const L=document.getElementById("compose-preview-content");let g="";$.networks&&$.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${$.networks.map(k=>`<code>${escapeHtml(k)}</code>`).join(", ")}</div>`),$.volumes&&$.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${$.volumes.map(k=>`<code>${escapeHtml(k)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${$.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const k of $.services){const f=k.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${f}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(k.name)}`,k.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(k.reason)}</span>`),g+="</div>",!k.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(k.image)}</code></div>`,k.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${k.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),k.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${k.volumes.length}</div>`),Object.keys(k.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(k.environment).length}</div>`),k.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(k.envFileWarning)}</div>`),k.resources?.cpus||k.resources?.memory)){const x=[];k.resources.cpus&&x.push(`CPU: ${k.resources.cpus}`),k.resources.memory&&x.push(`Mem: ${k.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",L.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>C("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const $=document.getElementById("compose-deploy-btn");$.textContent="Deploying...",$.disabled=!0,C("progress");const L=document.getElementById("compose-progress-content");L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let k=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;k+='<div class="scroll-container" style="max-height: 350px;">';for(const f of g.results){const x=f.status==="deployed"||f.status==="created"?"\u2705":f.status==="exists"?"\u26A1":f.status==="skipped"?"\u23ED":"\u274C";k+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',k+=`${x} <strong>${escapeHtml(f.name)}</strong> (${f.type}) \u2014 ${escapeHtml(f.status)}`,f.error&&(k+=` <span style="color: var(--bad-fg);">${escapeHtml(f.error)}</span>`),f.subdomain&&(k+=` \u2192 <code>${escapeHtml(f.subdomain)}</code>`),f.reason&&(k+=` <span style="color: var(--muted);">(${escapeHtml(f.reason)})</span>`),k+="</div>"}k+="</div>",k+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated </div>`);const b=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(b,N);let S=null;function T(L){document.getElementById("compose-step-paste").style.display=L==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=L==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=L==="progress"?"":"none"}E?.addEventListener("click",()=>{T("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",L=>{const H=L.target.files[0];if(!H)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(H)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const L=document.getElementById("compose-yaml").value.trim(),H=document.getElementById("compose-stack-name").value.trim()||"stack";if(!L){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),I=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:L,stackName:H});S=k,S.stackName=H,P(k),T("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=I,g.disabled=!1}});function P(L){const H=document.getElementById("compose-preview-content");let g="";L.networks&&L.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${L.networks.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),L.volumes&&L.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${L.volumes.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${L.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const I of L.services){const k=I.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.name)}`,I.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(I.reason)}</span>`),g+="</div>",!I.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(I.image)}</code></div>`,I.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${I.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),I.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${I.volumes.length}</div>`),Object.keys(I.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(I.environment).length}</div>`),I.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(I.envFileWarning)}</div>`),I.resources?.cpus||I.resources?.memory)){const x=[];I.resources.cpus&&x.push(`CPU: ${I.resources.cpus}`),I.resources.memory&&x.push(`Mem: ${I.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",H.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>T("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const L=document.getElementById("compose-deploy-btn");L.textContent="Deploying...",L.disabled=!0,T("progress");const H=document.getElementById("compose-progress-content");H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let I=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;I+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";I+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',I+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(I+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(I+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(I+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),I+="</div>"}I+="</div>",I+='<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",()=>C("paste"))}finally{$.textContent="Deploy All",$.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal"> <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",()=>T("paste"))}finally{L.textContent="Deploy All",L.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;"> <div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3> <h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div> <div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1427,11 +1538,11 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<button id="exec-close">Close</button> <button id="exec-close">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),z=document.getElementById("exec-close");let I=null,C=null,P=null;function $(){if(C){try{C.close()}catch{}C=null}if(I){try{I.dispose()}catch{}I=null}P=null,B.innerHTML=""}function L(g,k){if($(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${k||g}`,w?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=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"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";C=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(g)}`),C.binaryType="arraybuffer",C.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const E=P.proposeDimensions();E&&C.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},C.onmessage=E=>{if(typeof E.data=="string"){try{const R=JSON.parse(E.data);if(R.type==="connected"){I.writeln(`\x1B[32mConnected (${R.shell})\x1B[0m\r </div>`);const b=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let S=null,T=null,P=null;function L(){if(T){try{T.close()}catch{}T=null}if(S){try{S.dispose()}catch{}S=null}P=null,E.innerHTML=""}function H(g,I){if(L(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${I||g}`,b?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=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"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(E),P&&setTimeout(()=>P.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";T=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),T.binaryType="arraybuffer",T.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const $=P.proposeDimensions();$&&T.send(JSON.stringify({type:"resize",cols:$.cols,rows:$.rows}))}},T.onmessage=$=>{if(typeof $.data=="string"){try{const C=JSON.parse($.data);if(C.type==="connected"){S.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
`);return}if(R.type==="error"){I.writeln(`\x1B[31mError: ${R.message}\x1B[0m`);return}if(R.type==="exit"){I.writeln(`\r `);return}if(C.type==="error"){S.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){S.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(E.data)}else I.write(new Uint8Array(E.data))},C.onclose=()=>{I&&I.writeln(`\r \x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write($.data)}else S.write(new Uint8Array($.data))},T.onclose=()=>{S&&S.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},C.onerror=()=>{I&&I.writeln(`\r \x1B[33mDisconnected.\x1B[0m`)},T.onerror=()=>{S&&S.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},I.onData(E=>{C&&C.readyState===WebSocket.OPEN&&C.send(E)}),I.onResize(({cols:E,rows:R})=>{C&&C.readyState===WebSocket.OPEN&&C.send(JSON.stringify({type:"resize",cols:E,rows:R}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),w._resizeHandler=x}z?.addEventListener("click",()=>{$(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",g=>{g.target===w&&($(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=L})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal"> \x1B[31mConnection error.\x1B[0m`)},S.onData($=>{T&&T.readyState===WebSocket.OPEN&&T.send($)}),S.onResize(({cols:$,rows:C})=>{T&&T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:"resize",cols:$,rows:C}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),b._resizeHandler=x}N?.addEventListener("click",()=>{L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=H})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;"> <div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3> <h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle"> <p class="modal-subtitle">
@@ -1467,7 +1578,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<button id="audit-cancel">Close</button> <button id="audit-cancel">Close</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),z=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),C=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),$=document.getElementById("audit-log-container"),L=document.getElementById("audit-load-more");let g=0;const k=50;async function f(x){try{x||(g=0,$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const E=P.value;let R=`/api/v1/audit-logs?limit=${k}&offset=${g}`;E&&(R+=`&action=${encodeURIComponent(E)}`);const D=await(await fetch(R)).json(),N=D.success&&D.entries?D.entries:[];if(N.length===0&&!x){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',L.style.display="none";return}let M="";x||(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 H of N){const S=H.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(H.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(H.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(H.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${S?"var(--ok-fg)":"var(--bad-fg)"};">${S?"\u2713":"\u2717"}</span></td>`,M+="</tr>",H.details&&Object.keys(H.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(H.details,null,2))}</pre></td></tr>`)}if(!x)M+="</table>",$.innerHTML=M;else{const H=$.querySelector("table");H&&H.insertAdjacentHTML("beforeend",M)}g+=N.length,L.style.display=N.length>=k?"":"none",$.querySelectorAll(".audit-row").forEach(H=>{H.dataset.wired||(H.dataset.wired="true",H.addEventListener("click",()=>{const S=H.nextElementSibling;S&&S.classList.contains("audit-detail")&&(S.style.display=S.style.display==="none"?"":"none")}))})}catch(E){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(E.message)}</div>`}}B?.addEventListener("click",()=>{w?.classList.add("show"),f(!1)}),wireModal(w,z),I?.addEventListener("click",()=>f(!1)),P?.addEventListener("change",()=>f(!1)),L?.addEventListener("click",()=>f(!0)),C?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const E=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();E.success?f(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3> </div>`);const b=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),T=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),L=document.getElementById("audit-log-container"),H=document.getElementById("audit-load-more");let g=0;const I=50;async function k(x){try{x||(g=0,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const $=P.value;let C=`/api/v1/audit-logs?limit=${I}&offset=${g}`;$&&(C+=`&action=${encodeURIComponent($)}`);const M=await(await fetch(C)).json(),j=M.success&&M.entries?M.entries:[];if(j.length===0&&!x){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',H.style.display="none";return}let B="";x||(B='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',B+='<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 A of j){const w=A.outcome==="success";B+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',B+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(A.timestamp)}</td>`,B+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(A.ip||"-")}</td>`,B+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(A.action||"-")}</td>`,B+=`<td style="padding: 6px;">${escapeHtml(A.resource||"-")}</td>`,B+=`<td style="padding: 6px;"><span style="color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"\u2713":"\u2717"}</span></td>`,B+="</tr>",A.details&&Object.keys(A.details).length>0&&(B+=`<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(A.details,null,2))}</pre></td></tr>`)}if(!x)B+="</table>",L.innerHTML=B;else{const A=L.querySelector("table");A&&A.insertAdjacentHTML("beforeend",B)}g+=j.length,H.style.display=j.length>=I?"":"none",L.querySelectorAll(".audit-row").forEach(A=>{A.dataset.wired||(A.dataset.wired="true",A.addEventListener("click",()=>{const w=A.nextElementSibling;w&&w.classList.contains("audit-detail")&&(w.style.display=w.style.display==="none"?"":"none")}))})}catch($){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml($.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),k(!1)}),wireModal(b,N),S?.addEventListener("click",()=>k(!1)),P?.addEventListener("change",()=>k(!1)),H?.addEventListener("click",()=>k(!0)),T?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const $=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();$.success?k(!1):showNotification("Error: "+($.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const b=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> <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"> <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> <div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1478,23 +1589,23 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<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> <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> </div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const w="weather-location",B="weather-zip",z="weather-geo",I="weather-unit";!safeGet(w)&&safeGet(B)&&safeSet(w,safeGet(B));function C(){return safeGet(I)||"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 $={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"},L={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"},g=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(N){return g[Math.round(N/22.5)%16]}async function f(N){const M=safeGet(z);if(M)try{const u=JSON.parse(M);if(u.query===N)return u}catch{}const H=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(N)}&count=1&language=en&format=json`);if(!H.ok)throw new Error("Geocoding failed");const S=await H.json();if(!S.results||!S.results.length)throw new Error("Location not found");const T=S.results[0],b={query:N,lat:T.latitude,lon:T.longitude,city:T.name,state:T.admin1||"",country:T.country||"",countryCode:T.country_code||""};return safeSet(z,JSON.stringify(b)),b}function x(N){return N.countryCode==="US"&&N.state?`${N.city}, ${N.state}`:N.country?`${N.city}, ${N.country}`:N.city}async function E(N){try{const M=await f(N),H=C(),S=H==="metric"?"celsius":"fahrenheit",T=H==="metric"?"kmh":"mph",b=`https://api.open-meteo.com/v1/forecast?latitude=${M.lat}&longitude=${M.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${S}&wind_speed_unit=${T}`,u=await fetch(b);if(!u.ok)throw new Error("Weather fetch failed");const c=(await u.json()).current,o=c.weather_code;return{temp:Math.round(c.temperature_2m),condition:$[o]||"Unknown",icon:L[o]||"\u{1F324}\uFE0F",locationStr:x(M),windSpeed:Math.round(c.wind_speed_10m),windDir:k(c.wind_direction_10m),unit:H}}catch(M){return console.warn("Weather fetch failed:",M),null}}async function R(){const N=P();if(!N.icon||!N.temp||!N.condition||!N.location||!N.wind){console.warn("Weather widget elements not found");return}const M=safeGet(w);if(!M){N.location.textContent="Set Location",N.temp.textContent="--\xB0",N.condition.textContent="Click \u2699\uFE0F to configure",N.wind.textContent="--",N.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const H=await E(M);if(H){const S=H.unit==="metric"?"\xB0C":"\xB0F",T=H.unit==="metric"?"km/h":"mph";N.location.textContent=H.locationStr,N.temp.textContent=`${H.temp}${S}`,N.condition.textContent=H.condition,N.wind.textContent=`Wind: ${H.windSpeed} ${T} ${H.windDir}`,N.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(H.icon)}</span>`}}catch(H){console.error("Weather update error:",H),N.location.textContent="Weather Error",N.temp.textContent="Error",N.condition.textContent="Failed to load",N.wind.textContent="--"}}const O=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(w)||"";const N=C(),M=O.querySelector(`input[name="weather-unit-radio"][value="${N}"]`);M&&(M.checked=!0),O.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{O.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const N=D.value.trim();if(N){safeGet(w)!==N&&safeSet(z,""),safeSet(w,N);const H=O.querySelector('input[name="weather-unit-radio"]:checked'),S=H?H.value:"imperial",T=C();safeSet(I,S),T!==S&&safeSet(z,""),O.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(O),document.addEventListener("keydown",N=>{N.key==="Escape"&&O.classList.Line truncated <div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",N="weather-zip",S="weather-geo",T="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function P(){return safeGet(T)||"imperial"}function L(){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 H={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"},I=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(B){return I[Math.round(B/22.5)%16]}async function x(B){const A=safeGet(S);if(A)try{const y=JSON.parse(A);if(y.query===B)return y}catch{}const w=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(B)}&count=1&language=en&format=json`);if(!w.ok)throw new Error("Geocoding failed");const z=await w.json();if(!z.results||!z.results.length)throw new Error("Location not found");const f=z.results[0],p={query:B,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(S,JSON.stringify(p)),p}function $(B){return B.countryCode==="US"&&B.state?`${B.city}, ${B.state}`:B.country?`${B.city}, ${B.country}`:B.city}async function C(B){try{const A=await x(B),w=P(),z=w==="metric"?"celsius":"fahrenheit",f=w==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${A.lat}&longitude=${A.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${z}&wind_speed_unit=${f}`,y=await fetch(p);if(!y.ok)throw new Error("Weather fetch failed");const m=(await y.json()).current,r=m.weather_code;return{temp:Math.round(m.temperature_2m),condition:H[r]||"Unknown",icon:g[r]||"\u{1F324}\uFE0F",locationStr:$(A),windSpeed:Math.round(m.wind_speed_10m),windDir:k(m.wind_direction_10m),unit:w}}catch(A){return console.warn("Weather fetch failed:",A),null}}async function R(){const B=L();if(!B.icon||!B.temp||!B.condition||!B.location||!B.wind){console.warn("Weather widget elements not found");return}const A=safeGet(E);if(!A){B.location.textContent="Set Location",B.temp.textContent="--\xB0",B.condition.textContent="Click \u2699\uFE0F to configure",B.wind.textContent="--",B.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const w=await C(A);if(w){const z=w.unit==="metric"?"\xB0C":"\xB0F",f=w.unit==="metric"?"km/h":"mph";B.location.textContent=w.locationStr,B.temp.textContent=`${w.temp}${z}`,B.condition.textContent=w.condition,B.wind.textContent=`Wind: ${w.windSpeed} ${f} ${w.windDir}`,B.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(w.icon)}</span>`}}catch(w){b.logError("[Weather] Update Error",w,{function:"updateWeather"}),B.location.textContent="Weather Error",B.temp.textContent="Error",B.condition.textContent="Failed to load",B.wind.textContent="--"}}const M=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const B=P(),A=M.querySelector(`input[name="weather-unit-radio"][value="${B}"]`);A&&(A.checked=!0),M.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const B=j.value.trim();if(B){safeGet(E)!==B&&safeSet(S,""),safeSet(E,B);const w=M.querySelector('input[name="weather-unit-radio"]:checked'),z=w?w.value:"imperial",f=P();safeSet(T,z),f!==z&&safeSet(S,""),M.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",B=>{B.keyLine truncated
<circle cx="${h}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/> <circle cx="${d}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${U} ${U}
${_} ${q}
<line x1="${h}" y1="${d}" x2="${h+28*Math.cos(j*Math.PI/180)}" y2="${d+28*Math.sin(j*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${h}" y1="${d}" x2="${h+38*Math.cos(F*Math.PI/180)}" y2="${d+38*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+38*Math.cos(O*Math.PI/180)}" y2="${l+38*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${h}" y1="${d}" x2="${h+42*Math.cos(A*Math.PI/180)}" y2="${d+42*Math.sin(A*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/> <line x1="${d}" y1="${l}" x2="${d+42*Math.cos(D*Math.PI/180)}" y2="${l+42*Math.sin(D*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${h}" cy="${d}" r="3" fill="var(--fg)"/> <circle cx="${d}" cy="${l}" r="3" fill="var(--fg)"/>
</svg>`,Z=a.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${W}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${Z}</span><span class="analog-date-sm">${R(a)}</span></div></div>`,k="analog"}function u(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),s="clock-widget"+(P!=="default"?" "+P:"");switch(w.className!==s&&(w.className=s),P){case"lcd":M(a);break;case"lcd-blue":M(a);break;case"lcd-amber":M(a);break;case"lcd-retro":M(a);break;case"lcd-taxi":M(a);break;case"flip":H(a);break;case"binary":T(a);break;case"analog":b(a,!1);break;case"roman":b(a,!0);break;default:N(a)}n===0&&t===0&&e!==$&&($=e,E(e)),n!==0&&($=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{u(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",O(),u(),y()}),u(),y();const c=[{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 o='<div class="clock-style-grid">';c.forEach(a=>{o+=`<label class="clock-style-option"> </svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${C(a)}</span></div></div>`,I="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":B(a);break;case"lcd-blue":B(a);break;case"lcd-amber":B(a);break;case"lcd-retro":B(a);break;case"lcd-taxi":B(a);break;case"flip":A(a);break;case"binary":z(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==L&&(L=e,$(e)),n!==0&&(L=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",R(),p(),y()}),p(),y();const v=[{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 m='<div class="clock-style-grid">';v.forEach(a=>{m+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${a.id}"> <input type="radio" name="clock-style-radio" value="${a.id}">
<span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span> <span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span>
</label>`}),o+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal"> </label>`}),m+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;"> <div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3> <h3>Clock Settings</h3>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label> <label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${o} ${m}
</div> </div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);"> <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);"> <label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1518,8 +1629,8 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
<button id="clock-settings-save">Save</button> <button id="clock-settings-save">Save</button>
</div> </div>
</div> </div>
</div>`);const l=document.getElementById("clock-settings-modal"),v=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function m(){const a=safeGet("clock-style")||"default",e=l.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),v.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=v.checked?"1":"0.4"}v?.addEventListener("change",()=>{p.style.opacity=v.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{m(),l.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=l.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(v.checked)),safeSet("clock-chime-volume",r.value),P=e,g="",O(),u(),y(),l.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{l.classList.remove("show")}),wireModal(l),l?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",O(),u()})})})(),(function(){async function w(){try{const $=await(await fetch("/api/v1/health-checks/status")).json();if(!$.success||!$.status)return;for(const[L,g]of Object.entries($.status)){const k=document.getElementById("uptime-"+L),f=document.getElementById("uptime-bar-"+L);if(!k)continue;const x=g.uptime?.["24h"];if(x!=null){const E=x.toFixed(1);k.textContent=`${E}% uptime`,k.className="uptime-chip",x>=99.9?k.classList.add("excellent"):x>=99?k.classList.add("good"):x>=95?k.classList.add("degraded"):k.classList.add("poor"),f&&(f.style.width=E+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}async function z(){try{const $=await(await fetch("/api/v1/updates/available")).json();if(!$.success||(document.querySelectorAll(".update-available-badge").forEach(L=>L.classList.remove("visible")),!$.updates?.length))return;for(const L of $.updates){const g=window.APPS||[];for(const k of g)if(k.containerId===L.containerId||k.id===L.containerName||k.name===L.containerName){if(B.has(k.id))break;const f=document.getElementById("update-badge-"+k.id);f&&(f.classList.add("visible"),f.title=`Image digest changed. Click to dismiss if already up to date. </div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function u(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),P=e,g="",R(),p(),y(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",R(),p()})})})(),(function(){async function b(){try{const L=await(await fetch("/api/v1/health-checks/status")).json();if(!L.success||!L.status)return;for(const[H,g]of Object.entries(L.status)){const I=document.getElementById("uptime-"+H),k=document.getElementById("uptime-bar-"+H);if(!I)continue;const x=g.uptime?.["24h"];if(x!=null){const $=x.toFixed(1);I.textContent=`${$}% uptime`,I.className="uptime-chip",x>=99.9?I.classList.add("excellent"):x>=99?I.classList.add("good"):x>=95?I.classList.add("degraded"):I.classList.add("poor"),k&&(k.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function N(){try{const L=await(await fetch("/api/v1/updates/available")).json();if(!L.success||(document.querySelectorAll(".update-available-badge").forEach(H=>H.classList.remove("visible")),!L.updates?.length))return;for(const H of L.updates){const g=window.APPS||[];for(const I of g)if(I.containerId===H.containerId||I.id===H.containerName||I.name===H.containerName){if(E.has(I.id))break;const k=document.getElementById("update-badge-"+I.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
${L.imageName||""}`,f.style.cursor="pointer",f.onclick=x=>{x.stopPropagation(),f.classList.remove("visible"),B.add(k.id),safeSessionSet("dismissed-updates",JSON.stringify([...B]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function I(){setTimeout(()=>{w(),z()},5e3),setInterval(()=>{w(),z()},6e4)}const C=window.refreshAll;C&&(window.refreshAll=async function(){try{await C(),setTimeout(w,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),I()})(),(function(){var w=null,B=null,z={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},C=[["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"]],P=document.getElementById("theme");if(!P)return;var $=document.getElementById("theme-label");function L(t){if(I[t])return I[t];var s=safeGetJSON(window.USER_THEMES_KEY,{});return s[t]&&s[t].name||t}function g(){$&&($.textContent=L(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),s=window.getActiveTheme(),i=t.indexOf(s),h=t[(i+1)%t.length];window.applyTheme(h),g()}),g();function k(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},s={};C.forEach(function(h){s[h[2]]||(s[h[2]]=[]),s[h[2]].push(h)});var i="";return Object.keys(t).forEach(function(h){h==="advanced"?(i+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>',i+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):i+='<div class="theme-builder-section">',i+='<div class="theme-builder-section-title">'+t[h]+"</div>",(s[h]||[]).forEach(function(d){i+='<div class="theme-builder-row"><span class="theme-builder-label">'+d[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+d[0]+'"'+(h==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+d[0]+'">#000000</span></div>'}),i+="</div>"}),i}function f(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+L(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+f()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</ Line truncated ${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(I.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function S(){setTimeout(()=>{b(),N()},5e3),setInterval(()=>{b(),N()},6e4)}const T=window.refreshAll;T&&(window.refreshAll=async function(){try{await T(),setTimeout(b,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),S()})(),(function(){var b=null,E=null,N={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},T=[["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"]],P=document.getElementById("theme");if(!P)return;var L=document.getElementById("theme-label");function H(t){if(S[t])return S[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function g(){L&&(L.textContent=H(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),g()}),g();function I(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};T.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors &#9660;</div>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+H(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+k()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</ Line truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3> <h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;"> <p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -1556,13 +1667,13 @@ ${L.imageName||""}`,f.style.cursor="pointer",f.onclick=x=>{x.stopPropagation(),f
<button id="license-activate" class="btn-accent">Activate</button> <button id="license-activate" class="btn-accent">Activate</button>
</div> </div>
</div> </div>
</div>`);const w=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),z=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),C=document.getElementById("license-error"),P=document.getElementById("license-success"),$=document.getElementById("license-badge-icon"),L=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),k=document.getElementById("license-details"),f=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let E=null;function R(){C.style.display="none",P.style.display="none"}function O(y){R(),C.textContent=y,C.style.display="block"}function D(y){R(),P.textContent=y,P.style.display="block"}function N(y){if(E=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",$.textContent="\u2605",L.textContent="Premium Active";const l=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;k.innerHTML=` </div>`);const b=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),T=document.getElementById("license-error"),P=document.getElementById("license-success"),L=document.getElementById("license-badge-icon"),H=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),I=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let $=null;function C(){T.style.display="none",P.style.display="none"}function R(y){C(),T.textContent=y,T.style.display="block"}function M(y){C(),P.textContent=y,P.style.display="block"}function j(y){if($=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",L.textContent="\u2605",H.textContent="Premium Active";const r=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;I.innerHTML=`
<div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div> <div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
${l} ${r}
`,x.style.display="none",z.style.display="none",I.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",$.textContent="\u2606",L.textContent=y.expired?"License Expired":"Free Tier",k.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",z.style.display="",I.style.display="none";const c=y.premiumFeatures||{},o=new Set(y.features||[]);f.innerHTML=Object.entries(c).map(([l,v])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);"> `,x.style.display="none",N.style.display="none",S.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",L.textContent="\u2606",H.textContent=y.expired?"License Expired":"Free Tier",I.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",N.style.display="",S.style.display="none";const v=y.premiumFeatures||{},m=new Set(y.features||[]);k.innerHTML=Object.entries(v).map(([r,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;">${o.has(l)?"\u2705":"\u{1F512}"}</span> <span style="font-size: 1.1rem;">${m.has(r)?"\u2705":"\u{1F512}"}</span>
<div> <div>
<div style="font-weight: 600; font-size: 0.9rem;">${v.name}</div> <div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${v.description}</div> <div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div> </div>
</div>`).join("")}async function M(){try{const c=await(await fetch("/api/v1/license/status")).json();c.success&&(N(c.license),T(c.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function H(){const y=B.value.trim();if(!y){O("Please enter a license code.");return}R(),z.disabled=!0,z.textContent="Activating...";try{const o=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();o.success?(D(o.message),B.value="",N(o.license),showNotification("License activated! Premium features unlocked.","success",5e3),T(o.license)):O(o.error||"Activation failed")}catch(c){O("Network error: "+c.message)}finally{z.disabled=!1,z.textContent="Activate"}}async function S(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const c=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();c.success?(D(c.message),await M(),showNotification("License deactivated.","info",3e3),T({active:!1})):O(c.error||"Deactivation failed")}catch(y){O("Network error: "+y.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function T(y){const c=document.getElementById("license-status-topbar"),o=document.getElementById("license-topbar-icon"),l=document.getElementById("license-topbar-text"),v=document.getElementById("license-topbar-time");if(c)if(c.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(o.textContent="\u2605",l.textContent="PREMIUM",y.lifetime)v.textContent="\xB7 LIFETIME";else{const r=y.daysRemaining;v.textContent=r!=null?"\xB7 "+r+"d remaining":""}else o.textContent="\u2606",l.textContent=y.expired?"EXPIRED":"FREE TIER",v.textContent=""}function b(){R(),M(),w.classList.add("show")}B.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const c=["DC"],o=y.substring(2);for(let l=0;l<o.length;l+=5)c.push(o.substring(l,l+5));y=c.join("-")}this._prevLength=y.length,this.value=y}),z.addEventListener("click",H),I.addEventListener("click",S),B.addEventListener("keydown",y=>{y.key==="Enter"&&H()}),wireModal(w,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=b,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},M().then(y=>{E&&T(E)})})(); </div>`).join("")}async function B(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),z(v.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function A(){const y=E.value.trim();if(!y){R("Please enter a license code.");return}C(),N.disabled=!0,N.textContent="Activating...";try{const m=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();m.success?(M(m.message),E.value="",j(m.license),showNotification("License activated! Premium features unlocked.","success",5e3),z(m.license)):R(m.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function w(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(M(v.message),await B(),showNotification("License deactivated.","info",3e3),z({active:!1})):R(v.error||"Deactivation failed")}catch(y){R("Network error: "+y.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function z(y){const v=document.getElementById("license-status-topbar"),m=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(m.textContent="\u2605",r.textContent="PREMIUM",y.lifetime)c.textContent="\xB7 LIFETIME";else{const s=y.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else m.textContent="\u2606",r.textContent=y.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){C(),B(),b.classList.add("show")}E.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const v=["DC"],m=y.substring(2);for(let r=0;r<m.length;r+=5)v.push(m.substring(r,r+5));y=v.join("-")}this._prevLength=y.length,this.value=y}),N.addEventListener("click",A),S.addEventListener("click",w),E.addEventListener("keydown",y=>{y.key==="Enter"&&A()}),wireModal(b,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},B().then(y=>{$&&z($)})})();
+7 -7
View File
@@ -1,4 +1,4 @@
(function(){function k(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}k();function n(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let l=!1;async function m(){if(l){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(l=!0,await window.loadServices(),window.buildGrid(),n(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}p(),q()&&v()}function v(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function q(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function g(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",S=>{S.preventDefault();const y=i.classList.toggle("open");h.setAttribute("aria-expanded",y?"true":"false");const w={};document.querySelectorAll(".tools-section").forEach(f=>{w[f.dataset.section]=f.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(w))}))})}g();function p(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{v();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=m,window.loadCustomServices=k,u(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}m()})()})(),(function(){"use strict";const k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{v(),document.addEventListener("keydown",q),console.log("[Keyboard Shortcuts] Initialized"),console.log("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),console.log("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function v(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=` (function(){function p(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}p();function y(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function n(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let u=!1;async function l(){if(u){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(u=!0,await window.loadServices(),window.buildGrid(),y(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}k(),w()&&m()}function m(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function w(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function b(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",q=>{q.preventDefault();const S=i.classList.toggle("open");h.setAttribute("aria-expanded",S?"true":"false");const f={};document.querySelectorAll(".tools-section").forEach(v=>{f[v.dataset.section]=v.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(f))}))})}b();function k(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{m();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=l,window.loadCustomServices=p,n(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}l()})()})(),(function(){"use strict";const p=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},y=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{w(),document.addEventListener("keydown",b),p("[Keyboard Shortcuts] Initialized"),p("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),p("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function w(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
<div class="quick-search-content"> <div class="quick-search-content">
<div class="quick-search-input-wrapper"> <div class="quick-search-input-wrapper">
<span class="quick-search-icon">\u{1F50D}</span> <span class="quick-search-icon">\u{1F50D}</span>
@@ -160,7 +160,7 @@
font-family: monospace; font-family: monospace;
margin-right: 4px; margin-right: 4px;
} }
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",r),u.addEventListener("keydown",w),n.addEventListener("click",e=>{e.target===n&&p()})}function q(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),g();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){p();return}a()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function g(){try{n.classList.add("show"),u.value="",u.focus(),c()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function p(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function a(){for(const t of k){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function c(){const t=` `,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",h),u.addEventListener("keydown",v),n.addEventListener("click",e=>{e.target===n&&a()})}function b(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),k();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){a();return}c()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function k(){try{n.classList.add("show"),u.value="",u.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function a(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function c(){for(const t of y){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function i(){const t=`
<div class="quick-search-category">Quick Actions</div> <div class="quick-search-category">Quick Actions</div>
<div class="quick-search-item" data-action="refresh"> <div class="quick-search-item" data-action="refresh">
<span class="quick-search-item-icon">\u{1F504}</span> <span class="quick-search-item-icon">\u{1F504}</span>
@@ -192,9 +192,9 @@
</div> </div>
<div class="quick-search-category">Services</div> <div class="quick-search-category">Services</div>
${i()} ${r()}
`;l.innerHTML=t,y()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",b=s.dataset.app||"";d&&d!=="--"&&(e+=` `;l.innerHTML=t,f()}function r(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",g=s.dataset.app||"";d&&d!=="--"&&(e+=`
<div class="quick-search-item" data-action="open-service" data-service="${b}"> <div class="quick-search-item" data-action="open-service" data-service="${g}">
<span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span> <span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
<div class="quick-search-item-title">${d}</div> <div class="quick-search-item-title">${d}</div>
@@ -202,7 +202,7 @@
</div> </div>
<span class="quick-search-item-badge">${o.toUpperCase()}</span> <span class="quick-search-item-badge">${o.toUpperCase()}</span>
</div> </div>
`)}),e||'<div class="quick-search-empty">No services found</div>'}function r(t){try{const e=t.target.value.toLowerCase().trim();if(!e){c();return}const s=h(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function h(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const b=o.querySelector(".name")?.textContent||"",x=o.dataset.app||"",E=o.dataset.status||"unknown";(b.toLowerCase().includes(t)||x.toLowerCase().includes(t))&&e.services.push({id:x,title:b,status:E,icon:E==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=` `)}),e||'<div class="quick-search-empty">No services found</div>'}function h(t){try{const e=t.target.value.toLowerCase().trim();if(!e){i();return}const s=q(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function q(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const g=o.querySelector(".name")?.textContent||"",E=o.dataset.app||"",C=o.dataset.status||"unknown";(g.toLowerCase().includes(t)||E.toLowerCase().includes(t))&&e.services.push({id:E,title:g,status:C,icon:C==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
<div class="quick-search-item" data-action="${s.id}"> <div class="quick-search-item" data-action="${s.id}">
<span class="quick-search-item-icon">${s.icon}</span> <span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content"> <div class="quick-search-item-content">
@@ -217,4 +217,4 @@
</div> </div>
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span> <span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
</div> </div>
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,y()}function y(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>f(e)),s===0&&e.classList.add("selected")})}function w(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&f(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function f(t){try{const e=t.dataset.action,s=t.dataset.service;switch(p(),e){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(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:console.log("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:g,closeQuickSearch:p}})(); `})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,f()}function f(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>x(e)),s===0&&e.classList.add("selected")})}function v(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&x(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function x(t){try{const e=t.dataset.action,s=t.dataset.service;switch(a(),e){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(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:p("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:k,closeQuickSearch:a}})();
+36 -36
View File
@@ -1,5 +1,5 @@
this.driver=this.driver||{},this.driver.js=(function(v){"use strict";let T={};function P(o={}){T={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...o}}function e(o){return o?T[o]:T}function t(o,r,n,s){return(o/=s/2)<1?n/2*o*o+r:-n/2*(--o*(o-2)-1)+r}function a(o){const r='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return o.flatMap(n=>{const s=n.matches(r),i=Array.from(n.querySelectorAll(r));return[...s?[n]:[],...i]}).filter(n=>getComputedStyle(n).pointerEvents!=="none"&&A(n))}function p(o){if(!o||m(o))return;const r=e("smoothScroll");o.scrollIntoView({behavior:!r||y(o)?"auto":"smooth",inline:"center",block:"center"})}function y(o){if(!o||!o.parentElement)return;const r=o.parentElement;return r.scrollHeight>r.clientHeight}function m(o){const r=o.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}function A(o){return!!(o.offsetWidth||o.offsetHeight||o.getClientRects().length)}let L={};function E(o,r){L[o]=r}function d(o){return o?L[o]:L}function K(){L={}}let F={};function R(o,r){F[o]=r}function O(o){var r;(r=F[o])==null||r.call(F)}function se(){F={}}function ae(o,r,n,s){let i=d("__activeStagePosition");const u=i||n.getBoundingClientRect(),b=s.getBoundingClientRect(),S=t(o,u.x,b.x-u.x,r),c=t(o,u.y,b.y-u.y,r),w=t(o,u.width,b.width-u.width,r),l=t(o,u.height,b.height-u.height,r);i={x:S,y:c,width:w,height:l},J(i),E("__activeStagePosition",i)}function Y(o){if(!o)return;const r=o.getBoundingClientRect(),n={x:r.x,y:r.y,width:r.width,height:r.height};E("__activeStagePosition",n),J(n)}function le(){const o=d("__activeStagePosition"),r=d("__overlaySvg");if(!o)return;if(!r){console.warn("No stage svg found.");return}const n=window.innerWidth,s=window.innerHeight;r.setAttribute("viewBox",`0 0 ${n} ${s}`)}function de(o){const r=ce(o);document.body.appendChild(r),ee(r,n=>{n.target.tagName==="path"&&O("overlayClick")}),E("__overlaySvg",r)}function J(o){const r=d("__overlaySvg");if(!r){de(o);return}const n=r.firstElementChild;if(n?.tagName!=="path")throw new Error("no path element found in stage svg");n.setAttribute("d",Q(o))}function ce(o){const r=window.innerWidth,n=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${r} ${n}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const i=document.createElementNS("http://www.w3.org/2000/svg","path");return i.setAttribute("d",Q(o)),i.style.fill=e("overlayColor")||"rgb(0,0,0)",i.style.opacity=`${e("overlayOpacity")}`,i.style.pointerEvents="auto",i.style.cursor="auto",s.appendChild(i),s}function Q(o){const r=window.innerWidth,n=window.innerHeight,s=e("stagePadding")||0,i=e("stageRadius")||0,u=o.width+s*2,b=o.height+s*2,S=Math.min(i,u/2,b/2),c=Math.floor(Math.max(S,0)),w=o.x-s+c,l=o.y-s,h=u-c*2,g=b-c*2;return`M${r},0L0,0L0,${n}L${r},${n}L${r},0Z this.driver=this.driver||{},this.driver.js=(function(v){"use strict";let b={};function k(e={}){b={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...e}}function o(e){return e?b[e]:b}function s(e,r,n,l){return(e/=l/2)<1?n/2*e*e+r:-n/2*(--e*(e-2)-1)+r}function t(e){const r='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return e.flatMap(n=>{const l=n.matches(r),i=Array.from(n.querySelectorAll(r));return[...l?[n]:[],...i]}).filter(n=>getComputedStyle(n).pointerEvents!=="none"&&T(n))}function a(e){if(!e||h(e))return;const r=o("smoothScroll");e.scrollIntoView({behavior:!r||d(e)?"auto":"smooth",inline:"center",block:"center"})}function d(e){if(!e||!e.parentElement)return;const r=e.parentElement;return r.scrollHeight>r.clientHeight}function h(e){const r=e.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}function T(e){return!!(e.offsetWidth||e.offsetHeight||e.getClientRects().length)}let c={};function x(e,r){c[e]=r}function p(e){return e?c[e]:c}function B(){c={}}let R={};function U(e,r){R[e]=r}function H(e){var r;(r=R[e])==null||r.call(R)}function se(){R={}}function ae(e,r,n,l){let i=p("__activeStagePosition");const g=i||n.getBoundingClientRect(),C=l.getBoundingClientRect(),E=s(e,g.x,C.x-g.x,r),m=s(e,g.y,C.y-g.y,r),S=s(e,g.width,C.width-g.width,r),u=s(e,g.height,C.height-g.height,r);i={x:E,y:m,width:S,height:u},J(i),x("__activeStagePosition",i)}function K(e){if(!e)return;const r=e.getBoundingClientRect(),n={x:r.x,y:r.y,width:r.width,height:r.height};x("__activeStagePosition",n),J(n)}function le(){const e=p("__activeStagePosition"),r=p("__overlaySvg");if(!e)return;if(!r){console.warn("No stage svg found.");return}const n=window.innerWidth,l=window.innerHeight;r.setAttribute("viewBox",`0 0 ${n} ${l}`)}function de(e){const r=pe(e);document.body.appendChild(r),ee(r,n=>{n.target.tagName==="path"&&H("overlayClick")}),x("__overlaySvg",r)}function J(e){const r=p("__overlaySvg");if(!r){de(e);return}const n=r.firstElementChild;if(n?.tagName!=="path")throw new Error("no path element found in stage svg");n.setAttribute("d",Q(e))}function pe(e){const r=window.innerWidth,n=window.innerHeight,l=document.createElementNS("http://www.w3.org/2000/svg","svg");l.classList.add("driver-overlay","driver-overlay-animated"),l.setAttribute("viewBox",`0 0 ${r} ${n}`),l.setAttribute("xmlSpace","preserve"),l.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),l.setAttribute("version","1.1"),l.setAttribute("preserveAspectRatio","xMinYMin slice"),l.style.fillRule="evenodd",l.style.clipRule="evenodd",l.style.strokeLinejoin="round",l.style.strokeMiterlimit="2",l.style.zIndex="10000",l.style.position="fixed",l.style.top="0",l.style.left="0",l.style.width="100%",l.style.height="100%";const i=document.createElementNS("http://www.w3.org/2000/svg","path");return i.setAttribute("d",Q(e)),i.style.fill=o("overlayColor")||"rgb(0,0,0)",i.style.opacity=`${o("overlayOpacity")}`,i.style.pointerEvents="auto",i.style.cursor="auto",l.appendChild(i),l}function Q(e){const r=window.innerWidth,n=window.innerHeight,l=o("stagePadding")||0,i=o("stageRadius")||0,g=e.width+l*2,C=e.height+l*2,E=Math.min(i,g/2,C/2),m=Math.floor(Math.max(E,0)),S=e.x-l+m,u=e.y-l,f=g-m*2,y=C-m*2;return`M${r},0L0,0L0,${n}L${r},${n}L${r},0Z
M${w},${l} h${h} a${c},${c} 0 0 1 ${c},${c} v${g} a${c},${c} 0 0 1 -${c},${c} h-${h} a${c},${c} 0 0 1 -${c},-${c} v-${g} a${c},${c} 0 0 1 ${c},-${c} z`}function pe(){const o=d("__overlaySvg");o&&o.remove()}function ue(){const o=document.getElementById("driver-dummy-element");if(o)return o;let r=document.createElement("div");return r.id="driver-dummy-element",r.style.width="0",r.style.height="0",r.style.pointerEvents="none",r.style.opacity="0",r.style.position="fixed",r.style.top="50%",r.style.left="50%",document.body.appendChild(r),r}function X(o){const{element:r}=o;let n=typeof r=="string"?document.querySelector(r):r;n||(n=ue()),he(n,o)}function me(){const o=d("__activeElement"),r=d("__activeStep");o&&(Y(o),le(),ne(o,r))}function he(o,r){const n=Date.now(),s=d("__activeStep"),i=d("__activeElement")||o,u=!i||i===o,b=o.id==="driver-dummy-element",S=i.id==="driver-dummy-element",c=e("animate"),w=r.onHighlightStarted||e("onHighlightStarted"),l=r?.onHighlighted||e("onHighlighted"),h=s?.onDeselected||e("onDeselected"),g=e(),C=d();!u&&h&&h(S?void 0:i,s,{config:g,state:C}),w&&w(b?void 0:o,r,{config:g,state:C});const k=!u&&c;let x=!1;we(),E("previousStep",s),E("previousElement",i),E("activeStep",r),E("activeElement",o);const f=()=>{if(d("__transitionCallback")!==f)return;const D=Date.now()-n,B=400-D<=400/2;r.popover&&B&&!x&&k&&(te(o,r),x=!0),e("animate")&&D<400?ae(D,400,i,o):(Y(o),l&&l(b?void 0:o,r,{config:e(),state:d()}),E("__transitionCallback",void 0),E("__previousStep",s),E("__previousElement",i),E("__activeStep",r),E("__activeElement",o)),window.requestAnimationFrame(f)};E("__transitionCallback",f),window.requestAnimationFrame(f),p(o),!k&&r.popover&&te(o,r),i.classList.remove("driver-active-element","driver-no-interaction"),i.removeAttribute("aria-haspopup"),i.removeAttribute("aria-expanded"),i.removeAttribute("aria-controls"),e("disableActiveInteraction")&&o.classList.add("driver-no-interaction"),o.classList.add("driver-active-element"),o.setAttribute("aria-haspopup","dialog"),o.setAttribute("aria-expanded","true"),o.setAttribute("aria-controls","driver-popover-content")}function ge(){var o;(o=document.getElementById("driver-dummy-element"))==null||o.remove(),document.querySelectorAll(".driver-active-element").forEach(r=>{r.classList.remove("driver-active-element","driver-no-interaction"),r.removeAttribute("aria-haspopup"),r.removeAttribute("aria-expanded"),r.removeAttribute("aria-controls")})}function z(){const o=d("__resizeTimeout");o&&window.cancelAnimationFrame(o),E("__resizeTimeout",window.requestAnimationFrame(me))}function ve(o){var r;if(!d("isInitialized")||!(o.key==="Tab"||o.keyCode===9))return;const n=d("__activeElement"),s=(r=d("popover"))==null?void 0:r.wrapper,i=a([...s?[s]:[],...n?[n]:[]]),u=i[0],b=i[i.length-1];if(o.preventDefault(),o.shiftKey){const S=i[i.indexOf(document.activeElement)-1]||b;S?.focus()}else{const S=i[i.indexOf(document.activeElement)+1]||u;S?.focus()}}function Z(o){var r;((r=e("allowKeyboardControl"))==null||r)&&(o.key==="Escape"?O("escapePress"):o.key==="ArrowRight"?O("arrowRightPress"):o.key==="ArrowLeft"&&O("arrowLeftPress"))}function ee(o,r,n){const s=(i,u)=>{const b=i.target;o.contains(b)&&((!n||n(b))&&(i.preventDefault(),i.stopPropagation(),i.stopImmediatePropagation()),u?.(i))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",i=>{s(i,r)},!0)}function fe(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",z),window.addEventListener("scroll",z)}function ye(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",z),window.removeEventListener("scroll",z)}function we(){const o=d("popover");o&&(o.wrapper.style.display="none")}function te(o,r){var n,s;let i=d("popover");i&&document.body.removeChild(i.wrapper),i=Te(),document.body.appendChild(i.wrapper);const{title:u,description:b,showButtons:S,disableButtons:c,showProgress:w,nextBtnText:l=e("nextBtnText")||"Next &rarr;",prevBtnText:h=e("prevBtnText")||"&larr; Previous",progressText:g=e("progressText")||"{current} of {total}"}=r.popover||{};i.nextButton.innerHTML=l,i.previousButton.innerHTML=h,i.progress.innerHTML=g,u?(i.title.innerHTML=u,i.title.style.display="block"):i.title.style.display="none",b?(i.description.innerHTML=b,i.description.style.display="block"):i.description.style.display="none";const C=S||e("showButtons"),k=w||e("showProgress")||!1,x=C?.includes("next")||C?.includes("previous")||k;i.closeButton.style.display=C.includes("close")?"block":"none",x?(i.footer.style.display="flex",i.progress.style.display=k?"block":"none",i.nextButton.style.display=C.includes("next")?"block":"none",i.previousButton.style.display=C.includes("previous")?"block":"none"):i.footer.style.display="none";const f=c||e("disableButtons")||[];f!=null&&f.includes("next")&&(i.nextButton.disabled=!0,i. Line truncated M${S},${u} h${f} a${m},${m} 0 0 1 ${m},${m} v${y} a${m},${m} 0 0 1 -${m},${m} h-${f} a${m},${m} 0 0 1 -${m},-${m} v-${y} a${m},${m} 0 0 1 ${m},-${m} z`}function ce(){const e=p("__overlaySvg");e&&e.remove()}function ue(){const e=document.getElementById("driver-dummy-element");if(e)return e;let r=document.createElement("div");return r.id="driver-dummy-element",r.style.width="0",r.style.height="0",r.style.pointerEvents="none",r.style.opacity="0",r.style.position="fixed",r.style.top="50%",r.style.left="50%",document.body.appendChild(r),r}function X(e){const{element:r}=e;let n=typeof r=="string"?document.querySelector(r):r;n||(n=ue()),he(n,e)}function me(){const e=p("__activeElement"),r=p("__activeStep");e&&(K(e),le(),ne(e,r))}function he(e,r){const n=Date.now(),l=p("__activeStep"),i=p("__activeElement")||e,g=!i||i===e,C=e.id==="driver-dummy-element",E=i.id==="driver-dummy-element",m=o("animate"),S=r.onHighlightStarted||o("onHighlightStarted"),u=r?.onHighlighted||o("onHighlighted"),f=l?.onDeselected||o("onDeselected"),y=o(),D=p();!g&&f&&f(E?void 0:i,l,{config:y,state:D}),S&&S(C?void 0:e,r,{config:y,state:D});const _=!g&&m;let A=!1;we(),x("previousStep",l),x("previousElement",i),x("activeStep",r),x("activeElement",e);const w=()=>{if(p("__transitionCallback")!==w)return;const P=Date.now()-n,$=400-P<=400/2;r.popover&&$&&!A&&_&&(te(e,r),A=!0),o("animate")&&P<400?ae(P,400,i,e):(K(e),u&&u(C?void 0:e,r,{config:o(),state:p()}),x("__transitionCallback",void 0),x("__previousStep",l),x("__previousElement",i),x("__activeStep",r),x("__activeElement",e)),window.requestAnimationFrame(w)};x("__transitionCallback",w),window.requestAnimationFrame(w),a(e),!_&&r.popover&&te(e,r),i.classList.remove("driver-active-element","driver-no-interaction"),i.removeAttribute("aria-haspopup"),i.removeAttribute("aria-expanded"),i.removeAttribute("aria-controls"),o("disableActiveInteraction")&&e.classList.add("driver-no-interaction"),e.classList.add("driver-active-element"),e.setAttribute("aria-haspopup","dialog"),e.setAttribute("aria-expanded","true"),e.setAttribute("aria-controls","driver-popover-content")}function ge(){var e;(e=document.getElementById("driver-dummy-element"))==null||e.remove(),document.querySelectorAll(".driver-active-element").forEach(r=>{r.classList.remove("driver-active-element","driver-no-interaction"),r.removeAttribute("aria-haspopup"),r.removeAttribute("aria-expanded"),r.removeAttribute("aria-controls")})}function F(){const e=p("__resizeTimeout");e&&window.cancelAnimationFrame(e),x("__resizeTimeout",window.requestAnimationFrame(me))}function ve(e){var r;if(!p("isInitialized")||!(e.key==="Tab"||e.keyCode===9))return;const n=p("__activeElement"),l=(r=p("popover"))==null?void 0:r.wrapper,i=t([...l?[l]:[],...n?[n]:[]]),g=i[0],C=i[i.length-1];if(e.preventDefault(),e.shiftKey){const E=i[i.indexOf(document.activeElement)-1]||C;E?.focus()}else{const E=i[i.indexOf(document.activeElement)+1]||g;E?.focus()}}function Z(e){var r;((r=o("allowKeyboardControl"))==null||r)&&(e.key==="Escape"?H("escapePress"):e.key==="ArrowRight"?H("arrowRightPress"):e.key==="ArrowLeft"&&H("arrowLeftPress"))}function ee(e,r,n){const l=(i,g)=>{const C=i.target;e.contains(C)&&((!n||n(C))&&(i.preventDefault(),i.stopPropagation(),i.stopImmediatePropagation()),g?.(i))};document.addEventListener("pointerdown",l,!0),document.addEventListener("mousedown",l,!0),document.addEventListener("pointerup",l,!0),document.addEventListener("mouseup",l,!0),document.addEventListener("click",i=>{l(i,r)},!0)}function fe(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",F),window.addEventListener("scroll",F)}function ye(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",F),window.removeEventListener("scroll",F)}function we(){const e=p("popover");e&&(e.wrapper.style.display="none")}function te(e,r){var n,l;let i=p("popover");i&&document.body.removeChild(i.wrapper),i=Te(),document.body.appendChild(i.wrapper);const{title:g,description:C,showButtons:E,disableButtons:m,showProgress:S,nextBtnText:u=o("nextBtnText")||"Next &rarr;",prevBtnText:f=o("prevBtnText")||"&larr; Previous",progressText:y=o("progressText")||"{current} of {total}"}=r.popover||{};i.nextButton.innerHTML=u,i.previousButton.innerHTML=f,i.progress.innerHTML=y,g?(i.title.innerHTML=g,i.title.style.display="block"):i.title.style.display="none",C?(i.description.innerHTML=C,i.description.style.display="block"):i.description.style.display="none";const D=E||o("showButtons"),_=S||o("showProgress")||!1,A=D?.includes("next")||D?.includes("previous")||_;i.closeButton.style.display=D.includes("close")?"block":"none",A?(i.footer.style.display="flex",i.progress.style.display=_?"block":"none",i.nextButton.style.display=D.includes("next")?"block":"none",i.previousButton.style.display=D.includes("previous")?"block":"none"):i.footer.style.display="none";const w=m||o("disableButtons")||[];w!=null&&w.includes("next")&&(i.nextButton.disabled=!0,i. Line truncated
position: fixed; position: fixed;
bottom: 20px; bottom: 20px;
right: 20px; right: 20px;
@@ -11,38 +11,38 @@ this.driver=this.driver||{},this.driver.js=(function(v){"use strict";let T={};fu
z-index: 9999; z-index: 9999;
max-width: 300px; max-width: 300px;
font-size: 14px; font-size: 14px;
`,e.innerHTML=` `,o.innerHTML=`
<strong>Welcome to DashCaddy!</strong><br> <strong>Welcome to DashCaddy!</strong><br>
<p style="margin: 10px 0 0 0; font-size: 12px;"> <p style="margin: 10px 0 0 0; font-size: 12px;">
The interactive tour is unavailable, but you can explore the dashboard freely. The interactive tour is unavailable, but you can explore the dashboard freely.
Check the documentation for help getting started. Check the documentation for help getting started.
</p> </p>
`,document.body.appendChild(e),setTimeout(()=>{e.parentNode&&e.parentNode.removeChild(e)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const e={data:{},getItem(t){return this.data[t]||null},setItem(t,a){this.data[t]=a},removeItem(t){delete this.data[t]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),e}sendToErrorTracking(e){}}v.ErrorHandler=T,console.log("[ErrorHandler] Module loaded")})(window),(function(v){"use strict";class T{constructor(e="dashcaddy_onboarding"){this.storageKey=e,this.storageVersion="1.0",this.installOnboardingCompleted=typeof SITE<"u"&&SITE.onboardingCompleted===!0,this._initializeStorage(),this._updateLastVisit()}_initializeStorage(){const e=this._getStorage();if(!e||e.version!==this.storageVersion){const t={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(t)}}_getStorage(){try{const e=localStorage.getItem(this.storageKey);return e?JSON.parse(e):null}catch(e){return console.error("[ProgressTracker] Error reading from storage:",e),null}}_setStorage(e){try{localStorage.setItem(this.storageKey,JSON.stringify(e))}catch(t){console.error("[ProgressTracker] Error writing to storage:",t),this._handleStorageError(t)}}_handleStorageError(e){try{sessionStorage.setItem(this.storageKey,JSON.stringify(this._getStorage())),console.warn("[ProgressTracker] Falling back to session storage")}catch(t){console.error("[ProgressTracker] Session storage also unavailable:",t)}}_updateLastVisit(){const e=this._getStorage();e&&(e.lastVisit=new Date().toISOString(),this._setStorage(e))}isTooltipCompleted(e){const t=this._getStorage();return t?t.completedTooltips.includes(e):!1}markTooltipCompleted(e){const t=this._getStorage();t&&(t.completedTooltips.includes(e)||(t.completedTooltips.push(e),t.tooltipTimestamps||(t.tooltipTimestamps={}),t.tooltipTimestamps[e]=new Date().toISOString(),this._setStorage(t)))}isTourCompleted(){const e=this._getStorage();return e?e.tourCompleted===!0:!1}isInstallOnboardingCompleted(){return this.installOnboardingCompleted===!0}async markInstallOnboardingCompleted(){if(!this.installOnboardingCompleted){this.installOnboardingCompleted=!0,typeof SITE<"u"&&(SITE.onboardingCompleted=!0);try{await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({onboardingCompleted:!0})})}catch(e){console.error("[ProgressTracker] Failed to persist install onboarding state:",e)}}}markTourCompleted(){const e=this._getStorage();e&&(e.tourCompleted=!0,e.completionTimestamp=new Date().toISOString(),this._setStorage(e))}getCurrentStep(){const e=this._getStorage();return e&&e.currentStep||0}setCurrentStep(e){const t=this._getStorage();t&&(t.currentStep=e,this._setStorage(t))}resetProgress(){const e={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(e)}getCompletionTimestamp(){const e=this._getStorage();return!e||!e.completionTimestamp?null:new Date(e.completionTimestamp)}isDnsSetupDeferred(){const e=this._getStorage();return e?e.dnsSetupDeferred===!0:!1}markDnsSetupDeferred(){const e=this._getStorage();e&&(e.dnsSetupDeferred=!0,this._setStorage(e))}getTooltipTimestamp(e){const t=this._getStorage();return!t||!t.tooltipTimestamps||!t.tooltipTimestamps[e]?null:new Date(t.tooltipTimestamps[e])}getCompletedTooltips(){const e=this._getStorage();return e?e.completedTooltips||[]:[]}getLastVisit(){const e=this._getStorage();return!e||!e.lastVisit?null:new Date(e.lastVisit)}}v.ProgressTracker=T,console.log("[ProgressTracker] Module loaded")})(window),(function(v){"use strict";const T={dark:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(0, 0, 0, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},light:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent-strong)",overlayColor:"rgba(0, 0, 0, 0.5)",borderColor:"var(--border)",highlightColor:"var(--accent-strong)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},blue:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(25, 8, 172, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},nord:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(46, 52, 64, 0.7)",borderColor:"var(--border)",highlightC Line truncated `,document.body.appendChild(o),setTimeout(()=>{o.parentNode&&o.parentNode.removeChild(o)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const o={data:{},getItem(s){return this.data[s]||null},setItem(s,t){this.data[s]=t},removeItem(s){delete this.data[s]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),o}sendToErrorTracking(o){}}v.ErrorHandler=b,console.log("[ErrorHandler] Module loaded")})(window),(function(v){"use strict";const b=new ErrorHandler,k=(...s)=>{v.DASHCADDY_DEBUG&&console.log(...s)};class o{constructor(t="dashcaddy_onboarding"){this.storageKey=t,this.storageVersion="1.0",this.installOnboardingCompleted=typeof SITE<"u"&&SITE.onboardingCompleted===!0,this._initializeStorage(),this._updateLastVisit()}_initializeStorage(){const t=this._getStorage();if(!t||t.version!==this.storageVersion){const a={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(a)}}_getStorage(){try{const t=localStorage.getItem(this.storageKey);return t?JSON.parse(t):null}catch(t){return b.logError("[ProgressTracker] Read Storage",t,{function:"_getStorage"}),null}}_setStorage(t){try{localStorage.setItem(this.storageKey,JSON.stringify(t))}catch(a){b.logError("[ProgressTracker] Write Storage",a,{function:"_setStorage"}),this._handleStorageError(a)}}_handleStorageError(t){try{sessionStorage.setItem(this.storageKey,JSON.stringify(this._getStorage())),console.warn("[ProgressTracker] Falling back to session storage")}catch(a){b.logError("[ProgressTracker] Session Storage Unavailable",a,{function:"_handleStorageError"})}}_updateLastVisit(){const t=this._getStorage();t&&(t.lastVisit=new Date().toISOString(),this._setStorage(t))}isTooltipCompleted(t){const a=this._getStorage();return a?a.completedTooltips.includes(t):!1}markTooltipCompleted(t){const a=this._getStorage();a&&(a.completedTooltips.includes(t)||(a.completedTooltips.push(t),a.tooltipTimestamps||(a.tooltipTimestamps={}),a.tooltipTimestamps[t]=new Date().toISOString(),this._setStorage(a)))}isTourCompleted(){const t=this._getStorage();return t?t.tourCompleted===!0:!1}isInstallOnboardingCompleted(){return this.installOnboardingCompleted===!0}async markInstallOnboardingCompleted(){if(!this.installOnboardingCompleted){this.installOnboardingCompleted=!0,typeof SITE<"u"&&(SITE.onboardingCompleted=!0);try{await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({onboardingCompleted:!0})})}catch(t){b.logError("[ProgressTracker] Persist Install Onboarding",t,{function:"markInstallOnboardingCompleted"})}}}markTourCompleted(){const t=this._getStorage();t&&(t.tourCompleted=!0,t.completionTimestamp=new Date().toISOString(),this._setStorage(t))}getCurrentStep(){const t=this._getStorage();return t&&t.currentStep||0}setCurrentStep(t){const a=this._getStorage();a&&(a.currentStep=t,this._setStorage(a))}resetProgress(){const t={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(t)}getCompletionTimestamp(){const t=this._getStorage();return!t||!t.completionTimestamp?null:new Date(t.completionTimestamp)}isDnsSetupDeferred(){const t=this._getStorage();return t?t.dnsSetupDeferred===!0:!1}markDnsSetupDeferred(){const t=this._getStorage();t&&(t.dnsSetupDeferred=!0,this._setStorage(t))}getTooltipTimestamp(t){const a=this._getStorage();return!a||!a.tooltipTimestamps||!a.tooltipTimestamps[t]?null:new Date(a.tooltipTimestamps[t])}getCompletedTooltips(){const t=this._getStorage();return t?t.completedTooltips||[]:[]}getLastVisit(){const t=this._getStorage();return!t||!t.lastVisit?null:new Date(t.lastVisit)}}v.ProgressTracker=o,k("[ProgressTracker] Module loaded")})(window),(function(v){"use strict";const b=new ErrorHandler,k=(...t)=>{v.DASHCADDY_DEBUG&&console.log(...t)},o={dark:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(0, 0, 0, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},light:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent-strong)",overlayColor:"rgba(0, 0, 0, 0.5)",borderColor:"var(--border)",highlightColor:"var(--accent-strong)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},blue:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(25, 8, 172, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe Line truncated
.driver-popover { .driver-popover {
background: ${t.backgroundColor} !important; background: ${a.backgroundColor} !important;
color: ${t.textColor} !important; color: ${a.textColor} !important;
border: 1px solid ${t.borderColor} !important; border: 1px solid ${a.borderColor} !important;
border-radius: 12px !important; border-radius: 12px !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important;
font-family: ${t.fontFamily} !important; font-family: ${a.fontFamily} !important;
} }
.driver-popover-title { .driver-popover-title {
color: ${t.textColor} !important; color: ${a.textColor} !important;
font-weight: 600 !important; font-weight: 600 !important;
font-family: ${t.fontFamily} !important; font-family: ${a.fontFamily} !important;
} }
.driver-popover-description { .driver-popover-description {
color: ${t.textColor} !important; color: ${a.textColor} !important;
font-family: ${t.fontFamily} !important; font-family: ${a.fontFamily} !important;
} }
.driver-popover-footer button { .driver-popover-footer button {
background: ${t.primaryColor} !important; background: ${a.primaryColor} !important;
color: ${t.backgroundColor} !important; color: ${a.backgroundColor} !important;
border: none !important; border: none !important;
font-family: ${t.fontFamily} !important; font-family: ${a.fontFamily} !important;
font-weight: 500 !important; font-weight: 500 !important;
} }
@@ -51,26 +51,26 @@ this.driver=this.driver||{},this.driver.js=(function(v){"use strict";let T={};fu
} }
.driver-popover-close-btn { .driver-popover-close-btn {
color: ${t.textColor} !important; color: ${a.textColor} !important;
} }
.driver-overlay { .driver-overlay {
background: ${t.overlayColor} !important; background: ${a.overlayColor} !important;
} }
.driver-highlighted-element { .driver-highlighted-element {
outline: 2px solid ${t.highlightColor} !important; outline: 2px solid ${a.highlightColor} !important;
outline-offset: 4px !important; outline-offset: 4px !important;
} }
.driver-popover-progress-text { .driver-popover-progress-text {
color: ${t.textColor} !important; color: ${a.textColor} !important;
opacity: 0.7 !important; opacity: 0.7 !important;
font-family: ${t.fontFamily} !important; font-family: ${a.fontFamily} !important;
} }
`,document.head.appendChild(p)}getAvailableThemes(){return Object.keys(T)}isThemeAvailable(t){return T.hasOwnProperty(t)}}v.ThemeAdapter=P,console.log("[ThemeAdapter] Module loaded")})(window),(function(v){"use strict";function T(a){const p=[];if((!a.id||typeof a.id!="string")&&p.push("Tooltip must have a valid string id"),a.element||p.push("Tooltip must have an element selector or HTMLElement"),!a.popover||typeof a.popover!="object")p.push("Tooltip must have a popover object");else{if((!a.popover.title||typeof a.popover.title!="string")&&p.push("Tooltip popover must have a valid string title"),(!a.popover.description||typeof a.popover.description!="string")&&p.push("Tooltip popover must have a valid string description"),a.popover.position){const m=["top","bottom","left","right","center"];m.includes(a.popover.position)||p.push(`Invalid position: ${a.popover.position}. Must be one of: ${m.join(", ")}`)}if(a.popover.align){const m=["start","center","end"];m.includes(a.popover.align)||p.push(`Invalid align: ${a.popover.align}. Must be one of: ${m.join(", ")}`)}a.popover.showButtons&&!Array.isArray(a.popover.showButtons)&&p.push("showButtons must be an array"),["onNext","onPrevious","onClose","onSetupNow","onLater"].forEach(m=>{a.popover[m]&&typeof a.popover[m]!="function"&&p.push(`${m} must be a function`)})}return a.condition&&typeof a.condition!="function"&&p.push("condition must be a function"),a.priority!==void 0&&typeof a.priority!="number"&&p.push("priority must be a number"),{valid:p.length===0,errors:p}}function P(a){if(!Array.isArray(a))return{valid:!1,errors:[{tooltip:null,errors:["tooltips must be an array"]}]};const p=[],y=new Set;return a.forEach((m,A)=>{const L=T(m);L.valid||p.push({tooltip:m.id||`index ${A}`,errors:L.errors}),m.id&&(y.has(m.id)&&p.push({tooltip:m.id,errors:[`Duplicate tooltip ID: ${m.id}`]}),y.add(m.id))}),{valid:p.length===0,errors:p}}class e extends Error{constructor(p,y=null){super(p),this.name="TooltipError",this.tooltipId=y}}function t(a){if(!a.valid){const p=a.errors.map(y=>`${y.tooltip}: ${y.errors.join(", ")}`).join(` `,document.head.appendChild(h)}getAvailableThemes(){return Object.keys(o)}isThemeAvailable(a){return o.hasOwnProperty(a)}}v.ThemeAdapter=s,k("[ThemeAdapter] Module loaded")})(window),(function(v){"use strict";const b=new ErrorHandler,k=(...d)=>{v.DASHCADDY_DEBUG&&console.log(...d)};function o(d){const h=[];if((!d.id||typeof d.id!="string")&&h.push("Tooltip must have a valid string id"),d.element||h.push("Tooltip must have an element selector or HTMLElement"),!d.popover||typeof d.popover!="object")h.push("Tooltip must have a popover object");else{if((!d.popover.title||typeof d.popover.title!="string")&&h.push("Tooltip popover must have a valid string title"),(!d.popover.description||typeof d.popover.description!="string")&&h.push("Tooltip popover must have a valid string description"),d.popover.position){const c=["top","bottom","left","right","center"];c.includes(d.popover.position)||h.push(`Invalid position: ${d.popover.position}. Must be one of: ${c.join(", ")}`)}if(d.popover.align){const c=["start","center","end"];c.includes(d.popover.align)||h.push(`Invalid align: ${d.popover.align}. Must be one of: ${c.join(", ")}`)}d.popover.showButtons&&!Array.isArray(d.popover.showButtons)&&h.push("showButtons must be an array"),["onNext","onPrevious","onClose","onSetupNow","onLater"].forEach(c=>{d.popover[c]&&typeof d.popover[c]!="function"&&h.push(`${c} must be a function`)})}return d.condition&&typeof d.condition!="function"&&h.push("condition must be a function"),d.priority!==void 0&&typeof d.priority!="number"&&h.push("priority must be a number"),{valid:h.length===0,errors:h}}function s(d){if(!Array.isArray(d))return{valid:!1,errors:[{tooltip:null,errors:["tooltips must be an array"]}]};const h=[],T=new Set;return d.forEach((c,x)=>{const p=o(c);p.valid||h.push({tooltip:c.id||`index ${x}`,errors:p.errors}),c.id&&(T.has(c.id)&&h.push({tooltip:c.id,errors:[`Duplicate tooltip ID: ${c.id}`]}),T.add(c.id))}),{valid:h.length===0,errors:h}}class t extends Error{constructor(h,T=null){super(h),this.name="TooltipError",this.tooltipId=T}}function a(d){if(!d.valid){const h=d.errors.map(T=>`${T.tooltip}: ${T.errors.join(", ")}`).join(`
`);throw console.error("[TooltipDefinitions] Validation errors:",p),new e(`Tooltip validation failed: `);throw b.logError("[TooltipDefinitions] Validation",h,{function:"validateTooltip"}),new t(`Tooltip validation failed:
${p}`)}}v.TooltipValidation={validateTooltipDefinition:T,validateTooltipDefinitions:P,handleValidationErrors:t,TooltipError:e},console.log("[TooltipDefinitions] Validation module loaded")})(window);const TOOLTIP_DEFINITIONS=[{id:"welcome",element:"#brand",popover:{title:"Welcome to DashCaddy!",description:` ${h}`)}}v.TooltipValidation={validateTooltipDefinition:o,validateTooltipDefinitions:s,handleValidationErrors:a,TooltipError:t},k("[TooltipDefinitions] Validation module loaded")})(window);const TOOLTIP_DEFINITIONS=[{id:"welcome",element:"#brand",popover:{title:"Welcome to DashCaddy!",description:`
<p>Your unified control panel for <strong>Docker</strong>, <strong>Caddy</strong>, and <strong>DNS</strong> &mdash; all in one place.</p> <p>Your unified control panel for <strong>Docker</strong>, <strong>Caddy</strong>, and <strong>DNS</strong> &mdash; all in one place.</p>
<p>This tour will walk you through every section so you know exactly where everything is.</p> <p>This tour will walk you through every section so you know exactly where everything is.</p>
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.7;">You can click your logo anytime to customize it.</p> <p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.7;">You can click your logo anytime to customize it.</p>
@@ -172,7 +172,7 @@ ${p}`)}}v.TooltipValidation={validateTooltipDefinition:T,validateTooltipDefiniti
<p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin &rarr; License</strong></p> <p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin &rarr; License</strong></p>
</div> </div>
<p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin &rarr; Help Tour</strong>.</p> <p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin &rarr; Help Tour</strong>.</p>
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function getTooltipDefinitions(){return TOOLTIP_DEFINITIONS}function getTooltipById(v){return TOOLTIP_DEFINITIONS.find(T=>T.id===v)||null}function getActiveTooltips(){return TOOLTIP_DEFINITIONS.filter(v=>{if(v.condition&&typeof v.condition=="function")try{return v.condition()}catch(T){return console.error(`[TooltipDefinitions] Error evaluating condition for ${v.id}:`,T),!1}return!0})}function getSortedTooltips(){return getActiveTooltips().sort((T,P)=>{const e=T.priority||999,t=P.priority||999;return e-t})}function getNewFeatureTooltips(){return getActiveTooltips().filter(T=>T.isNewFeature===!0).sort((T,P)=>{const e=T.priority||999,t=P.priority||999;return e-t})}window.TooltipDefinitions={TOOLTIP_DEFINITIONS,getTooltipDefinitions,getTooltipById,getActiveTooltips,getSortedTooltips,getNewFeatureTooltips},console.log("[TooltipDefinitions] Definitions loaded:",TOOLTIP_DEFINITIONS.length,"tooltips"),(function(v){"use strict";class T{constructor(e){this.progressTracker=e,this.modal=null,this.onTemplateSelected=null,console.log("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const e=document.createElement("div");e.id="dns-template-modal",e.className="dns-template-modal",e.innerHTML=` `,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function getTooltipDefinitions(){return TOOLTIP_DEFINITIONS}function getTooltipById(v){return TOOLTIP_DEFINITIONS.find(b=>b.id===v)||null}function getActiveTooltips(){return TOOLTIP_DEFINITIONS.filter(v=>{if(v.condition&&typeof v.condition=="function")try{return v.condition()}catch(b){return errorHandler.logError("[TooltipDefinitions] Condition Eval",b,{function:"evaluateCondition",tooltipId:v.id}),!1}return!0})}function getSortedTooltips(){return getActiveTooltips().sort((b,k)=>{const o=b.priority||999,s=k.priority||999;return o-s})}function getNewFeatureTooltips(){return getActiveTooltips().filter(b=>b.isNewFeature===!0).sort((b,k)=>{const o=b.priority||999,s=k.priority||999;return o-s})}window.TooltipDefinitions={TOOLTIP_DEFINITIONS,getTooltipDefinitions,getTooltipById,getActiveTooltips,getSortedTooltips,getNewFeatureTooltips},debug("[TooltipDefinitions] Definitions loaded:",TOOLTIP_DEFINITIONS.length,"tooltips"),(function(v){"use strict";const b=(...o)=>{v.DASHCADDY_DEBUG&&console.log(...o)};class k{constructor(s){this.progressTracker=s,this.modal=null,this.onTemplateSelected=null,b("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const s=document.createElement("div");s.id="dns-template-modal",s.className="dns-template-modal",s.innerHTML=`
<div class="dns-template-modal-content"> <div class="dns-template-modal-content">
<div class="dns-template-header"> <div class="dns-template-header">
<h2>\u{1F310} Choose a DNS Server</h2> <h2>\u{1F310} Choose a DNS Server</h2>
@@ -186,21 +186,21 @@ ${p}`)}}v.TooltipValidation={validateTooltipDefinition:T,validateTooltipDefiniti
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button> <button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div> </div>
</div> </div>
`,document.body.appendChild(e),this.modal=e,e.querySelector(".dns-template-close").addEventListener("click",()=>this.close()),e.querySelector("#dns-setup-later").addEventListener("click",()=>this.handleSetupLater()),e.addEventListener("click",t=>{t.target===e&&this.close()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&e.style.display==="flex"&&this.close()})}populateTemplates(){const e=document.getElementById("dns-template-grid");if(!e)return;const t=this.getDnsTemplates();e.innerHTML="",t.forEach(a=>{const p=this.createTemplateCard(a);e.appendChild(p)})}createTemplateCard(e){const t=document.createElement("div");t.className="dns-template-card",e.recommended&&t.classList.add("recommended");const a=e.difficulty.toLowerCase();return t.innerHTML=` `,document.body.appendChild(s),this.modal=s,s.querySelector(".dns-template-close").addEventListener("click",()=>this.close()),s.querySelector("#dns-setup-later").addEventListener("click",()=>this.handleSetupLater()),s.addEventListener("click",t=>{t.target===s&&this.close()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&s.style.display==="flex"&&this.close()})}populateTemplates(){const s=document.getElementById("dns-template-grid");if(!s)return;const t=this.getDnsTemplates();s.innerHTML="",t.forEach(a=>{const d=this.createTemplateCard(a);s.appendChild(d)})}createTemplateCard(s){const t=document.createElement("div");t.className="dns-template-card",s.recommended&&t.classList.add("recommended");const a=s.difficulty.toLowerCase();return t.innerHTML=`
${e.recommended?'<div class="recommended-badge">Recommended</div>':""} ${s.recommended?'<div class="recommended-badge">Recommended</div>':""}
<div class="dns-template-icon">${e.icon}</div> <div class="dns-template-icon">${s.icon}</div>
<h3>${e.name}</h3> <h3>${s.name}</h3>
<p class="dns-template-description">${e.description}</p> <p class="dns-template-description">${s.description}</p>
<div class="dns-template-difficulty difficulty-${a}"> <div class="dns-template-difficulty difficulty-${a}">
${e.difficulty} ${s.difficulty}
</div> </div>
<ul class="dns-template-features"> <ul class="dns-template-features">
${e.features.slice(0,3).map(y=>`<li>${y}</li>`).join("")} ${s.features.slice(0,3).map(h=>`<li>${h}</li>`).join("")}
</ul> </ul>
<button class="dns-template-select-btn" data-template-id="${e.id}"> <button class="dns-template-select-btn" data-template-id="${s.id}">
Select ${e.name} Select ${s.name}
</button> </button>
`,t.querySelector(".dns-template-select-btn").addEventListener("click",()=>this.handleTemplateSelection(e)),t}handleTemplateSelection(e){console.log(`[DnsTemplateSelector] Template selected: ${e.id}`),this.close(),this.onTemplateSelected?this.onTemplateSelected(e):this.openAppSelector(e.id)}handleSetupLater(){console.log("[DnsTemplateSelector] DNS setup deferred"),this.progressTracker&&this.progressTracker.markDnsSetupDeferred(),this.close(),this.showNotification("DNS setup deferred. You can set it up later from the App Selector.")}openAppSelector(e){const t=document.querySelector('[onclick*="showAppSelector"]');t?(t.click(),setTimeout(()=>{const a=document.querySelector("#app-search");a&&(a.value=e,a.dispatchEvent(new Event("input",{bubbles:!0})))},300)):this.showNotification(`To deploy ${e}, use the App Selector and search for "${e}"`)}showNotification(e){const t=document.createElement("div");t.className="dns-template-notification",t.textContent=e,t.style.cssText=` `,t.querySelector(".dns-template-select-btn").addEventListener("click",()=>this.handleTemplateSelection(s)),t}handleTemplateSelection(s){b(`[DnsTemplateSelector] Template selected: ${s.id}`),this.close(),this.onTemplateSelected?this.onTemplateSelected(s):this.openAppSelector(s.id)}handleSetupLater(){b("[DnsTemplateSelector] DNS setup deferred"),this.progressTracker&&this.progressTracker.markDnsSetupDeferred(),this.close(),this.showNotification("DNS setup deferred. You can set it up later from the App Selector.")}openAppSelector(s){const t=document.querySelector('[onclick*="showAppSelector"]');t?(t.click(),setTimeout(()=>{const a=document.querySelector("#app-search");a&&(a.value=s,a.dispatchEvent(new Event("input",{bubbles:!0})))},300)):this.showNotification(`To deploy ${s}, use the App Selector and search for "${s}"`)}showNotification(s){const t=document.createElement("div");t.className="dns-template-notification",t.textContent=s,t.style.cssText=`
position: fixed; position: fixed;
top: 20px; top: 20px;
right: 20px; right: 20px;
@@ -211,8 +211,8 @@ ${p}`)}}v.TooltipValidation={validateTooltipDefinition:T,validateTooltipDefiniti
box-shadow: 0 4px 12px rgba(0,0,0,0.3); box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001; z-index: 10001;
max-width: 300px; max-width: 300px;
`,document.body.appendChild(t),setTimeout(()=>{t.style.opacity="0",t.style.transition="opacity 0.3s",setTimeout(()=>t.remove(),300)},3e3)}close(){this.modal&&(this.modal.style.display="none",document.body.style.overflow="")}}v.DnsTemplateSelector=T,console.log("[DnsTemplateSelector] Module loaded")})(window),(function(v){"use strict";class T{constructor(e,t,a){this.progressTracker=e,this.themeAdapter=t,this.dnsTemplateSelector=a,this.driver=null,this.currentStepIndex=0,this.isActive=!1,this.resizeHandler=null,this.layoutChangeHandler=null}async initializeDriver(){const e=v.driver?.js?.driver||v.driver?.driver||v.driver;if(typeof e!="function")return console.error("[TourManager] Driver.js not loaded or invalid. window.driver:",v.driver),!1;const t=this.themeAdapter.getDriverTheme();return this.driver=e({showProgress:!0,showButtons:["next","previous","close"],allowClose:!0,overlayClickNext:!1,overlayOpacity:.6,stagePadding:12,stageRadius:12,allowKeyboardControl:!0,popoverClass:"dashcaddy-popover",animate:!0,smoothScroll:!0,onDestroyed:()=>this.onTourComplete(),onDestroyStarted:()=>{this.progressTracker.isTourCompleted()||this.onTourSkip()}}),this.themeAdapter.applyTheme(this.driver),this.themeAdapter.onThemeChange(()=>{this.themeAdapter.applyTheme(this.driver)}),this.setupDynamicRepositioning(),!0}shouldAutoStart(){return!this.progressTracker.isInstallOnboardingCompleted()&&!this.progressTracker.isTourCompleted()&&this.progressTracker.getCurrentStep()===0}async startTour(){if(!this.driver&&!await this.initializeDriver())return;const e=v.TooltipDefinitions.getSortedTooltips(),t=this.progressTracker.getCompletedTooltips(),a=e.filter(y=>!t.includes(y.id));if(a.length===0){console.log("[TourManager] No tooltips to show"),this.progressTracker.markTourCompleted();return}const p=a.map((y,m)=>{const A=m===0,L=m===a.length-1,E={element:y.element,popover:{title:y.popover.title,description:y.popover.description,side:y.popover.position||"bottom",align:y.popover.align||"start",showButtons:this._getButtonsForStep(y,A,L),showProgress:y.popover.showProgress!==!1,onNextClick:()=>{this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()},onPrevClick:()=>{this.progressTracker.setCurrentStep(Math.max(0,m-1)),this.currentStepIndex=Math.max(0,m-1),this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}};return y.id==="dns-priority"&&this.dnsTemplateSelector&&(E.popover.onSetupNowClick=()=>{console.log("[TourManager] Opening DNS template selector"),this.dnsTemplateSelector.showTemplateSelector(),this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()},E.popover.onLaterClick=()=>{console.log("[TourManager] DNS setup deferred"),this.progressTracker.markDnsSetupDeferred(),this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()}),E});this.isActive=!0,this.driver.setSteps(p),this.driver.drive()}async resumeTour(){this.progressTracker.getCurrentStep()>0?await this.startTour():await this.startTour()}skipTour(){this.driver&&this.driver.destroy(),this.cleanupDynamicRepositioning(),this.isActive=!1}async restartTour(){this.progressTracker.resetProgress(),await this.startTour()}async showTooltip(e){const t=v.TooltipDefinitions.getTooltipById(e);if(!t){console.error(`[TourManager] Tooltip not found: ${e}`);return}this.driver||await this.initializeDriver();const a={element:t.element,popover:{title:t.popover.title,description:t.popover.description,side:t.popover.position||"bottom",align:t.popover.align||"start"}};this.driver.highlight(a)}async showWhatsNew(){if(!this.driver&&!await this.initializeDriver())return;const e=v.TooltipDefinitions.getNewFeatureTooltips();if(e.length===0){console.log("[TourManager] No new features to show");return}console.log(`[TourManager] Showing ${e.length} new features`);const t=e.map((a,p)=>{const y=p===0,m=p===e.length-1;return{element:a.element,popover:{title:`\u2728 NEW: ${a.popover.title}`,description:a.popover.description,side:a.popover.position||"bottom",align:a.popover.align||"start",showButtons:this._getButtonsForStep(a,y,m),showProgress:!0,onNextClick:()=>{this.driver.moveNext()},onPrevClick:()=>{this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}}});this.isActive=!0,this.driver.setSteps(t),this.driver.drive()}setupDynamicRepositioning(){let e;this.resizeHandler=()=>{clearTimeout(e),e=setTimeout(()=>{this.isActive&&this.driver&&(console.log("[TourManager] Window resized, repositioning tooltip"),this.driver.refresh())},150)},this.layoutChangeHandler=()=>{this.isActive&&this.driver&&(console.log("[TourManager] Layout changed, repositioning tooltip"),setTimeout(()=>{this.driver&&this.driver.refresh()},100))},v.addEventListener("resize",this.resizeHandler),this.themeAdapter.onThemeChange(this.layoutChangeHandler)}cleanupDynamicRepositioning(){ Line truncated `,document.body.appendChild(t),setTimeout(()=>{t.style.opacity="0",t.style.transition="opacity 0.3s",setTimeout(()=>t.remove(),300)},3e3)}close(){this.modal&&(this.modal.style.display="none",document.body.style.overflow="")}}v.DnsTemplateSelector=k,b("[DnsTemplateSelector] Module loaded")})(window),(function(v){"use strict";const b=new ErrorHandler,k=(...s)=>{v.DASHCADDY_DEBUG&&console.log(...s)};class o{constructor(t,a,d){this.progressTracker=t,this.themeAdapter=a,this.dnsTemplateSelector=d,this.driver=null,this.currentStepIndex=0,this.isActive=!1,this.resizeHandler=null,this.layoutChangeHandler=null}async initializeDriver(){const t=v.driver?.js?.driver||v.driver?.driver||v.driver;if(typeof t!="function")return b.logError("[TourManager] Driver.js Not Loaded",new Error("Driver.js not loaded or invalid"),{windowDriver:typeof v.driver}),!1;const a=this.themeAdapter.getDriverTheme();return this.driver=t({showProgress:!0,showButtons:["next","previous","close"],allowClose:!0,overlayClickNext:!1,overlayOpacity:.6,stagePadding:12,stageRadius:12,allowKeyboardControl:!0,popoverClass:"dashcaddy-popover",animate:!0,smoothScroll:!0,onDestroyed:()=>this.onTourComplete(),onDestroyStarted:()=>{this.progressTracker.isTourCompleted()||this.onTourSkip()}}),this.themeAdapter.applyTheme(this.driver),this.themeAdapter.onThemeChange(()=>{this.themeAdapter.applyTheme(this.driver)}),this.setupDynamicRepositioning(),!0}shouldAutoStart(){return!this.progressTracker.isInstallOnboardingCompleted()&&!this.progressTracker.isTourCompleted()&&this.progressTracker.getCurrentStep()===0}async startTour(){if(!this.driver&&!await this.initializeDriver())return;const t=v.TooltipDefinitions.getSortedTooltips(),a=this.progressTracker.getCompletedTooltips(),d=t.filter(T=>!a.includes(T.id));if(d.length===0){k("[TourManager] No tooltips to show"),this.progressTracker.markTourCompleted();return}const h=d.map((T,c)=>{const x=c===0,p=c===d.length-1,B={element:T.element,popover:{title:T.popover.title,description:T.popover.description,side:T.popover.position||"bottom",align:T.popover.align||"start",showButtons:this._getButtonsForStep(T,x,p),showProgress:T.popover.showProgress!==!1,onNextClick:()=>{this.progressTracker.markTooltipCompleted(T.id),this.progressTracker.setCurrentStep(c+1),this.currentStepIndex=c+1,this.driver.moveNext()},onPrevClick:()=>{this.progressTracker.setCurrentStep(Math.max(0,c-1)),this.currentStepIndex=Math.max(0,c-1),this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}};return T.id==="dns-priority"&&this.dnsTemplateSelector&&(B.popover.onSetupNowClick=()=>{k("[TourManager] Opening DNS template selector"),this.dnsTemplateSelector.showTemplateSelector(),this.progressTracker.markTooltipCompleted(T.id),this.progressTracker.setCurrentStep(c+1),this.currentStepIndex=c+1,this.driver.moveNext()},B.popover.onLaterClick=()=>{k("[TourManager] DNS setup deferred"),this.progressTracker.markDnsSetupDeferred(),this.progressTracker.markTooltipCompleted(T.id),this.progressTracker.setCurrentStep(c+1),this.currentStepIndex=c+1,this.driver.moveNext()}),B});this.isActive=!0,this.driver.setSteps(h),this.driver.drive()}async resumeTour(){this.progressTracker.getCurrentStep()>0?await this.startTour():await this.startTour()}skipTour(){this.driver&&this.driver.destroy(),this.cleanupDynamicRepositioning(),this.isActive=!1}async restartTour(){this.progressTracker.resetProgress(),await this.startTour()}async showTooltip(t){const a=v.TooltipDefinitions.getTooltipById(t);if(!a){b.logError("[TourManager] Tooltip Not Found",new Error(`Tooltip not found: ${t}`),{tooltipId:t});return}this.driver||await this.initializeDriver();const d={element:a.element,popover:{title:a.popover.title,description:a.popover.description,side:a.popover.position||"bottom",align:a.popover.align||"start"}};this.driver.highlight(d)}async showWhatsNew(){if(!this.driver&&!await this.initializeDriver())return;const t=v.TooltipDefinitions.getNewFeatureTooltips();if(t.length===0){k("[TourManager] No new features to show");return}k(`[TourManager] Showing ${t.length} new features`);const a=t.map((d,h)=>{const T=h===0,c=h===t.length-1;return{element:d.element,popover:{title:`\u2728 NEW: ${d.popover.title}`,description:d.popover.description,side:d.popover.position||"bottom",align:d.popover.align||"start",showButtons:this._getButtonsForStep(d,T,c),showProgress:!0,onNextClick:()=>{this.driver.moveNext()},onPrevClick:()=>{this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}}});this.isActive=!0,this.driver.setSteps(a),this.driver.drive()}setupDynamicRepositioning(){let t;this.resizeHandler=()=>{clearTimeout(t),t=setTimeout(()=>{this.isActive&&this.driver&&(k("[TourManager] Window resized, repositioning tooltip"),this.driver.refresh())},150)},this.layoutChangeHandler=()=>{this.isActive&&this.driver&&(k("[TourManager] Layout changed, repositioning tooltip"),setTimeout(()=>{this.driver&&this.driver.refresh()},100))},v.addEventListener("resize",this.resizeHandler),this.themeAdapt Line truncated
Possible issues: Possible issues:
- Driver.js library failed to load - Driver.js library failed to load
- JavaScript errors during initialization`))},E=document.getElementById("restart-tour-btn");if(E){E.onclick=L;return}const d=document.createElement("button");d.id="restart-tour-btn",d.textContent="Help Tour",d.title="Restart the onboarding tour",d.onclick=L,A.appendChild(d)}function y(){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 m(){let A=0;const L=10;function E(){y()?a():(A++,A<L?setTimeout(E,500):(console.error("[Onboarding] Driver.js failed to load after multiple attempts"),t?t.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure()))}E()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),console.log("[Onboarding] System loaded")})(); - JavaScript errors during initialization`))},p=document.getElementById("restart-tour-btn");if(p){p.onclick=x;return}const B=document.createElement("button");B.id="restart-tour-btn",B.textContent="Help Tour",B.title="Restart the onboarding tour",B.onclick=x,c.appendChild(B)}function h(){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 T(){let c=0;const x=10;function p(){h()?a():(c++,c<x?setTimeout(p,500):t?t.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure())}p()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",T):T(),v("[Onboarding] System loaded")})();
+198 -34
View File
@@ -8,7 +8,7 @@
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" /> <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" /> <meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" /> <meta http-equiv="Expires" content="0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-6JZtsKK/PZthh+stCmmCvC2QxCiyk6SwZCBjXE+kYr0='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'"> <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'sha256-Nv8xzCSztfdYOL663VgPKWQn6v0lnM0ACWxkxpFfcfY='; style-src 'self' 'unsafe-inline'; img-src 'self' https://cdn.jsdelivr.net data:; connect-src 'self' https://api.open-meteo.com https://geocoding-api.open-meteo.com; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'">
<link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any"> <link rel="icon" href="/assets/dashcaddy-favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png"> <link rel="icon" type="image/png" sizes="192x192" href="/assets/icon-192.png">
@@ -90,16 +90,18 @@
</button> </button>
<button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button> <button id="theme-customize-btn" class="theme-customize-link" title="Customize theme colors">Customize Theme</button>
</div> </div>
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
<span id="license-topbar-icon">&#9734;</span>
<span id="license-topbar-text">FREE TIER</span>
<span id="license-topbar-time"></span>
</div>
<button id="reload-caddy-top" aria-label="Reload Caddy configuration" style="padding: 10px 20px; font-size: 0.95rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 6px; color: white; cursor: pointer; box-shadow: 0 2px 6px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;"> <button id="reload-caddy-top" aria-label="Reload Caddy configuration" style="padding: 10px 20px; font-size: 0.95rem; font-weight: 600; background: linear-gradient(135deg, #3498db 0%, #2980b9 100%); border: none; border-radius: 6px; color: white; cursor: pointer; box-shadow: 0 2px 6px rgba(52, 152, 219, 0.3); transition: all 0.2s ease;">
🔄 Reload Caddy 🔄 Reload Caddy
</button> </button>
<div class="license-version-row">
<div id="license-status-topbar" class="license-status-topbar free" title="Click to manage license">
<span id="license-topbar-icon">&#9734;</span>
<span id="license-topbar-text">FREE TIER</span>
<span id="license-topbar-time"></span>
</div>
<button id="license-topbar-version" class="dashboard-version dashboard-version-inline" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info"><span class="dashboard-version-bullet" id="license-topbar-version-bullet">·</span> <span id="license-topbar-version-text">Loading…</span></button>
</div>
</div> </div>
<button id="dashboard-version" class="dashboard-version" type="button" title="View DashCaddy verification info" aria-label="View DashCaddy verification info">Version —</button>
</div> </div>
</div> </div>
@@ -110,7 +112,9 @@
<div id="version-info-status" class="version-info-status">Loading…</div> <div id="version-info-status" class="version-info-status">Loading…</div>
<div id="version-info-grid" class="version-info-grid" style="display:none;"></div> <div id="version-info-grid" class="version-info-grid" style="display:none;"></div>
<div id="version-info-history" class="version-info-history" style="display:none;"></div> <div id="version-info-history" class="version-info-history" style="display:none;"></div>
<div class="weather-modal-buttons" style="margin-top: 16px;"> <div id="version-info-actions" class="weather-modal-buttons" style="margin-top: 16px; display:none; justify-content: space-between; gap: 10px;">
<button id="version-info-update">Update Now</button>
<button id="version-info-open-updates">Open Updates</button>
<button id="version-info-close">Close</button> <button id="version-info-close">Close</button>
</div> </div>
</div> </div>
@@ -144,7 +148,8 @@
<span class="tools-section-label">Tools</span> <span class="tools-section-label">Tools</span>
</button> </button>
<div class="tools-section-items"> <div class="tools-section-items">
<button id="view-error-logs" aria-label="View error logs">📋 Logs</button> <button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button> <button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button> <button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
<button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button> <button id="docker-resources-btn" aria-label="Docker resources">🐳 Docker</button>
@@ -159,9 +164,10 @@
<div class="tools-section-items"> <div class="tools-section-items">
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button> <button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button> <button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
<button id="license-btn" aria-label="License management" onclick="window.openLicenseModal && window.openLicenseModal()">🔑 License</button> <button id="snapshot-btn" aria-label="Container snapshots">💾 Snapshots</button>
<button id="api-docs-btn" aria-label="API documentation" onclick="window.open('/api/docs', '_blank')">📖 API</button> <button id="license-btn" aria-label="License management">🔑 License</button>
<button id="help-errors-btn" aria-label="Troubleshooting guide" onclick="window.open('/help-errors.html', '_blank')">❓ Help</button> <button id="api-docs-btn" aria-label="API documentation">📖 API</button>
<button id="help-errors-btn" aria-label="Troubleshooting guide">❓ Help</button>
</div> </div>
</div> </div>
</div> </div>
@@ -242,6 +248,29 @@
</div> </div>
</div> </div>
<!-- Service Filter Bar -->
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="all">All Status</option>
<option value="on">🟢 Online</option>
<option value="off">🔴 Offline</option>
</select>
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
</div>
<!-- Batch Operations Bar (hidden by default) -->
<div id="batch-action-bar" style="display: none; margin-bottom: 16px; padding: 12px 16px; background: var(--accent-bg, #1a3a5c); border: 1px solid var(--accent, #3498db); border-radius: var(--radius);">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<span id="batch-selected-count" style="font-weight: 500;">0 selected</span>
<button id="batch-start-btn" class="btn-sm" style="background: var(--ok-fg, #27ae60);">▶ Start All</button>
<button id="batch-stop-btn" class="btn-sm" style="background: var(--bad-fg, #e74c3c);">⬛ Stop All</button>
<button id="batch-restart-btn" class="btn-sm" style="background: #f39c12;">🔄 Restart All</button>
<button id="batch-cancel-btn" class="btn-sm">Cancel</button>
</div>
</div>
<!-- App/service grid --> <!-- App/service grid -->
<div id="cards" class="grid"></div> <div id="cards" class="grid"></div>
@@ -555,12 +584,18 @@
var yr = document.getElementById('footer-year'); var yr = document.getElementById('footer-year');
if (yr) yr.textContent = new Date().getFullYear(); if (yr) yr.textContent = new Date().getFullYear();
var versionEl = document.getElementById('dashboard-version'); var versionEl = document.getElementById('license-topbar-version');
var versionTextEl = document.getElementById('license-topbar-version-text');
var versionBulletEl = document.getElementById('license-topbar-version-bullet');
var versionInfoModal = document.getElementById('version-info-modal'); var versionInfoModal = document.getElementById('version-info-modal');
var versionInfoStatus = document.getElementById('version-info-status'); var versionInfoStatus = document.getElementById('version-info-status');
var versionInfoGrid = document.getElementById('version-info-grid'); var versionInfoGrid = document.getElementById('version-info-grid');
var versionInfoHistory = document.getElementById('version-info-history'); var versionInfoHistory = document.getElementById('version-info-history');
var versionInfoActions = document.getElementById('version-info-actions');
var versionInfoUpdate = document.getElementById('version-info-update');
var versionInfoOpenUpdates = document.getElementById('version-info-open-updates');
var versionInfoClose = document.getElementById('version-info-close'); var versionInfoClose = document.getElementById('version-info-close');
var latestUpdateCheck = null;
function formatValue(value) { function formatValue(value) {
if (value == null || value === '') return '—'; if (value == null || value === '') return '—';
@@ -588,71 +623,197 @@
}).join(''); }).join('');
} }
function setVersionLabel(text) {
if (versionTextEl) versionTextEl.textContent = text;
}
function setVersionUpdateState(hasUpdate) {
if (!versionEl || !versionBulletEl) return;
versionEl.classList.toggle('has-update', !!hasUpdate);
versionEl.title = hasUpdate
? 'Update available — click for details or to update'
: 'View DashCaddy verification info';
versionEl.setAttribute('aria-label', hasUpdate
? 'DashCaddy update available — click for details or to update'
: 'View DashCaddy verification info');
versionBulletEl.textContent = hasUpdate ? '⬆' : '·';
}
function checkVersionUpdateAvailability() {
return fetch('/api/v1/system/update-check', { cache: 'no-store', credentials: 'same-origin' })
.then(function(response) {
if (response.status === 401) throw new Error('AUTH_PENDING');
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
latestUpdateCheck = data;
setVersionUpdateState(!!(data && data.success && data.available && data.remote));
return data;
})
.catch(function(error) {
if (!(error && error.message === 'AUTH_PENDING')) {
setVersionUpdateState(false);
}
throw error;
});
}
function loadVersionLabel(retryCount) {
if (!versionEl) return;
fetch('/api/v1/system/version', { cache: 'no-store', credentials: 'same-origin' })
.then(function(response) {
if (response.status === 401) {
throw new Error('AUTH_PENDING');
}
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
setVersionLabel('v' + data.version);
checkVersionUpdateAvailability().catch(function() {});
return;
}
setVersionLabel('version unknown');
setVersionUpdateState(false);
})
.catch(function(error) {
if (error && error.message === 'AUTH_PENDING') {
setVersionLabel('signing in…');
if ((retryCount || 0) < 12) {
setTimeout(function() {
loadVersionLabel((retryCount || 0) + 1);
}, 1500);
}
return;
}
setVersionLabel('version unavailable');
setVersionUpdateState(false);
});
}
function openUpdatesPanel() {
var updatesBtn = document.getElementById('updates-btn');
var updatesDashcaddyTab = document.getElementById('updates-dashcaddy-tab');
if (updatesBtn) updatesBtn.click();
setTimeout(function() {
if (updatesDashcaddyTab) updatesDashcaddyTab.click();
}, 50);
}
function applyVersionUpdate() {
if (window.dcApplyUpdate) {
return window.dcApplyUpdate();
}
openUpdatesPanel();
}
function openVersionInfo() { function openVersionInfo() {
if (!versionInfoModal) return; if (!versionInfoModal) return;
versionInfoModal.classList.add('show'); versionInfoModal.classList.add('show');
versionInfoStatus.textContent = 'Loading…'; versionInfoStatus.textContent = 'Loading…';
versionInfoGrid.style.display = 'none'; versionInfoGrid.style.display = 'none';
versionInfoHistory.style.display = 'none'; versionInfoHistory.style.display = 'none';
if (versionInfoActions) versionInfoActions.style.display = 'none';
if (versionInfoUpdate) {
versionInfoUpdate.disabled = true;
versionInfoUpdate.textContent = 'Update Now';
}
versionInfoGrid.innerHTML = ''; versionInfoGrid.innerHTML = '';
versionInfoHistory.innerHTML = ''; versionInfoHistory.innerHTML = '';
Promise.all([ Promise.all([
fetch('/api/v1/system/version', { cache: 'no-store' }).then(function(response) { fetch('/api/v1/system/version', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Version check failed'); if (!response.ok) throw new Error('Version check failed');
return response.json(); return response.json();
}), }),
fetch('/api/v1/system/update-status', { cache: 'no-store' }).then(function(response) { fetch('/api/v1/system/update-status', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Update status failed'); if (!response.ok) throw new Error('Update status failed');
return response.json(); return response.json();
}), }),
fetch('/api/v1/system/update-history', { cache: 'no-store' }).then(function(response) { fetch('/api/v1/system/update-history', { cache: 'no-store', credentials: 'same-origin' }).then(function(response) {
if (!response.ok) throw new Error('Update history failed'); if (!response.ok) throw new Error('Update history failed');
return response.json(); return response.json();
}) }),
checkVersionUpdateAvailability().catch(function() { return latestUpdateCheck || {}; })
]).then(function(results) { ]).then(function(results) {
var versionData = results[0] || {}; var versionData = results[0] || {};
var statusData = results[1] || {}; var statusData = results[1] || {};
var historyData = results[2] || {}; var historyData = results[2] || {};
var lastResult = statusData.lastResult || {}; var updateCheckData = results[3] || latestUpdateCheck || {};
var lastResult = statusData.lastResult || updateCheckData || {};
var lastPolicy = lastResult.policy || {}; var lastPolicy = lastResult.policy || {};
var hasUpdate = !!(updateCheckData && updateCheckData.success && updateCheckData.available && updateCheckData.remote);
versionInfoStatus.textContent = 'Verification info loaded.'; versionInfoStatus.textContent = hasUpdate
? 'Update available — you can install it from here.'
: 'Verification info loaded.';
versionInfoGrid.style.display = 'grid'; versionInfoGrid.style.display = 'grid';
versionInfoGrid.innerHTML = [ versionInfoGrid.innerHTML = [
renderInfoRow('Version', versionData.version), renderInfoRow('Version', versionData.version),
renderInfoRow('Commit', versionData.commit), renderInfoRow('Commit', versionData.commit),
renderInfoRow('Updater Status', statusData.status), renderInfoRow('Updater Status', statusData.status),
renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'), renderInfoRow('Last Check', statusData.lastCheck ? new Date(statusData.lastCheck).toLocaleString() : 'Never'),
renderInfoRow('Update Available', lastResult.available), renderInfoRow('Update Available', hasUpdate),
renderInfoRow('Available Version', updateCheckData.remote && updateCheckData.remote.version),
renderInfoRow('Eligible', lastPolicy.eligible), renderInfoRow('Eligible', lastPolicy.eligible),
renderInfoRow('Policy Reason', lastPolicy.reason), renderInfoRow('Policy Reason', lastPolicy.reason),
renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)), renderInfoRow('Channel', lastPolicy.releaseChannel || (lastResult.instance && lastResult.instance.channel)),
renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId) renderInfoRow('Instance ID', lastResult.instance && lastResult.instance.instanceId)
].join(''); ].join('');
if (versionInfoActions) versionInfoActions.style.display = 'flex';
if (versionInfoUpdate) versionInfoUpdate.disabled = !hasUpdate;
renderHistory(historyData.history); renderHistory(historyData.history);
}).catch(function(error) { }).catch(function(error) {
versionInfoStatus.textContent = 'Could not load verification info: ' + error.message; versionInfoStatus.textContent = 'Could not load verification info: ' + error.message;
if (versionInfoActions) versionInfoActions.style.display = 'flex';
}); });
} }
if (versionEl) { if (versionEl) {
fetch('/api/v1/system/version', { cache: 'no-store' }) loadVersionLabel(0);
.then(function(response) {
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.json();
})
.then(function(data) {
if (data && data.success && data.version) {
versionEl.textContent = 'Version ' + data.version;
}
})
.catch(function() {
versionEl.textContent = 'Version unavailable';
});
versionEl.addEventListener('click', openVersionInfo); versionEl.addEventListener('click', function(event) {
event.stopPropagation();
openVersionInfo();
});
}
var licenseBtn = document.getElementById('license-btn');
var apiDocsBtn = document.getElementById('api-docs-btn');
var helpErrorsBtn = document.getElementById('help-errors-btn');
if (licenseBtn) {
licenseBtn.addEventListener('click', function() {
if (window.openLicenseModal) window.openLicenseModal();
});
}
if (apiDocsBtn) {
apiDocsBtn.addEventListener('click', function() {
window.open('/api/docs', '_blank');
});
}
if (helpErrorsBtn) {
helpErrorsBtn.addEventListener('click', function() {
window.open('/help-errors.html', '_blank');
});
}
if (versionInfoUpdate) {
versionInfoUpdate.addEventListener('click', function() {
applyVersionUpdate();
});
}
if (versionInfoOpenUpdates) {
versionInfoOpenUpdates.addEventListener('click', function() {
versionInfoModal.classList.remove('show');
openUpdatesPanel();
});
} }
if (versionInfoClose && versionInfoModal) { if (versionInfoClose && versionInfoModal) {
@@ -665,6 +826,9 @@
} }
}); });
} }
window.openVersionInfo = openVersionInfo;
window.applyVersionUpdate = applyVersionUpdate;
})(); })();
</script> </script>
+5 -4
View File
@@ -1,5 +1,6 @@
// App Selector System // App Selector System
(function () { (function () {
const errorHandler = new ErrorHandler();
injectModal('app-selector-modal', `<div id="app-selector-modal" class="weather-modal"> injectModal('app-selector-modal', `<div id="app-selector-modal" class="weather-modal">
<div class="app-selector-content"> <div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2> <h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
@@ -230,7 +231,7 @@
return true; return true;
} }
} catch (e) { } catch (e) {
console.error('Failed to fetch app templates:', e); errorHandler.logError('[AppSelector] Fetch Templates', e, { function: 'fetchApiTemplates' });
} }
return false; return false;
} }
@@ -242,7 +243,7 @@
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (e) { } catch (e) {
console.error('Failed to check port:', e); errorHandler.logError('[AppSelector] Check Port', e, { function: 'checkPortAvailability' });
return { available: true }; // Assume available on error return { available: true }; // Assume available on error
} }
} }
@@ -256,7 +257,7 @@
return data.suggestedPort; return data.suggestedPort;
} }
} catch (e) { } catch (e) {
console.error('Failed to get suggested port:', e); errorHandler.logError('[AppSelector] Get Suggested Port', e, { function: 'getSuggestedPort' });
} }
return basePort; return basePort;
} }
@@ -842,7 +843,7 @@
throw new Error(result.error || 'Deployment failed'); throw new Error(result.error || 'Deployment failed');
} }
} catch (error) { } catch (error) {
console.error('Deployment error:', error); errorHandler.logError('[AppSelector] Deployment', error, { function: 'deploy' });
showNotification( showNotification(
`Failed to deploy ${appTemplate.name}: ${error.message}`, `Failed to deploy ${appTemplate.name}: ${error.message}`,
'error', 'error',
+138
View File
@@ -0,0 +1,138 @@
// ========== BATCH CONTAINER OPERATIONS ==========
(function() {
const batchBtn = document.getElementById('batch-operations-btn');
const batchBar = document.getElementById('batch-action-bar');
const batchCount = document.getElementById('batch-selected-count');
const startBtn = document.getElementById('batch-start-btn');
const stopBtn = document.getElementById('batch-stop-btn');
const restartBtn = document.getElementById('batch-restart-btn');
const cancelBtn = document.getElementById('batch-cancel-btn');
let batchMode = false;
let selectedContainers = new Set();
function enterBatchMode() {
batchMode = true;
selectedContainers.clear();
batchBar.style.display = '';
batchBtn.textContent = '✓ Exit Batch Mode';
updateSelectedCount();
// Add checkboxes to all cards with containerId
const cards = document.querySelectorAll('#cards .card[data-app]');
cards.forEach(card => {
const containerId = card.dataset.containerId;
if (!containerId) return;
// Remove existing checkbox if any
const existing = card.querySelector('.batch-checkbox');
if (existing) existing.remove();
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'batch-checkbox';
checkbox.dataset.containerId = containerId;
checkbox.dataset.serviceName = card.querySelector('.name')?.textContent || containerId;
checkbox.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;';
checkbox.addEventListener('change', (e) => {
e.stopPropagation();
if (checkbox.checked) {
selectedContainers.add(containerId);
} else {
selectedContainers.delete(containerId);
}
updateSelectedCount();
});
card.style.position = 'relative';
card.insertBefore(checkbox, card.firstChild);
});
}
function exitBatchMode() {
batchMode = false;
selectedContainers.clear();
batchBar.style.display = 'none';
batchBtn.textContent = '☰ Batch Operations';
// Remove all checkboxes
document.querySelectorAll('.batch-checkbox').forEach(cb => cb.remove());
}
function updateSelectedCount() {
const count = selectedContainers.size;
batchCount.textContent = `${count} selected`;
startBtn.disabled = count === 0;
stopBtn.disabled = count === 0;
restartBtn.disabled = count === 0;
}
async function batchAction(action) {
if (selectedContainers.size === 0) return;
const containers = Array.from(selectedContainers);
const actionLabel = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action];
if (!confirm(`${actionLabel} ${containers.length} container(s)? This cannot be undone.`)) return;
const btns = [startBtn, stopBtn, restartBtn];
btns.forEach(b => { b.disabled = true; b.textContent = '...'; });
let success = 0;
let failed = 0;
const errors = [];
for (const containerId of containers) {
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(containerId)}/${action}`, {
method: 'POST'
});
if (res.ok) {
success++;
} else {
failed++;
const data = await res.json().catch(() => ({}));
errors.push(`${containerId}: ${data.error || res.statusText}`);
}
} catch (e) {
failed++;
errors.push(`${containerId}: ${e.message}`);
}
}
// Restore buttons
btns[0].textContent = '▶ Start All';
btns[1].textContent = '⬛ Stop All';
btns[2].textContent = '🔄 Restart All';
updateSelectedCount();
// Show results
if (failed === 0) {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel} completed: ${success} container(s)`, 'success');
}
} else {
if (typeof showNotification === 'function') {
showNotification(`${actionLabel}: ${success} succeeded, ${failed} failed`, 'warning');
}
console.error('Batch operation errors:', errors);
}
// Refresh dashboard after a short delay
setTimeout(() => {
if (typeof refreshAll === 'function') refreshAll();
}, 1500);
}
batchBtn?.addEventListener('click', () => {
if (batchMode) {
exitBatchMode();
} else {
enterBatchMode();
}
});
startBtn?.addEventListener('click', () => batchAction('start'));
stopBtn?.addEventListener('click', () => batchAction('stop'));
restartBtn?.addEventListener('click', () => batchAction('restart'));
cancelBtn?.addEventListener('click', exitBatchMode);
})();
+429
View File
@@ -0,0 +1,429 @@
// ========== CONTAINER LOG VIEWER ==========
(function() {
// Inject modal HTML
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>
<h3 style="margin: 0;">📜 Container Logs</h3>
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">View and stream Docker container logs</p>
</div>
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center;">
<select id="cl-container-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 200px;">
<option value="">Select a container...</option>
</select>
<input type="text" id="cl-log-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 150px;" />
<select id="cl-log-tail" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
<option value="50">Last 50 lines</option>
<option value="100" selected>Last 100 lines</option>
<option value="500">Last 500 lines</option>
<option value="1000">Last 1000 lines</option>
<option value="all">All logs</option>
</select>
<button id="cl-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Refresh</button>
<button id="cl-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">▶ Stream</button>
<button id="cl-download" style="padding: 6px 14px; font-size: 0.85rem;">💾 Download</button>
<button id="cl-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">✕</button>
<button id="cl-close" class="close-btn" style="padding: 6px 10px;">✕</button>
</div>
</div>
<div id="cl-container-info" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem;">
<span><strong>Image:</strong> <span id="cl-image">-</span></span>
<span><strong>Status:</strong> <span id="cl-status">-</span></span>
<span><strong>Created:</strong> <span id="cl-created">-</span></span>
</div>
<div id="cl-log-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>
</div>
<div id="cl-stream-status" style="display: none; padding: 8px 12px; background: var(--ok-bg, #1a3a1a); color: var(--ok-fg, #4ade80); border-radius: 6px; margin-top: 8px; font-size: 0.82rem;">
<span id="cl-stream-indicator">🔴</span> <span id="cl-stream-text">Disconnected</span>
</div>
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
<span id="cl-line-count">0 lines</span>
<span>|</span>
<span id="cl-filter-count">0 filtered</span>
</div>
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('container-logs-modal');
const containerSelect = document.getElementById('cl-container-select');
const logContent = document.getElementById('cl-log-content');
const logSearch = document.getElementById('cl-log-search');
const logTail = document.getElementById('cl-log-tail');
const refreshBtn = document.getElementById('cl-refresh');
const streamBtn = document.getElementById('cl-stream');
const downloadBtn = document.getElementById('cl-download');
const clearSearchBtn = document.getElementById('cl-clear-search');
const closeBtn = document.getElementById('cl-close');
const closeBtn2 = document.getElementById('cl-close-btn');
const streamStatus = document.getElementById('cl-stream-status');
const streamIndicator = document.getElementById('cl-stream-indicator');
const streamText = document.getElementById('cl-stream-text');
const lineCount = document.getElementById('cl-line-count');
const filterCount = document.getElementById('cl-filter-count');
// Container info elements
const imageEl = document.getElementById('cl-image');
const statusEl = document.getElementById('cl-status');
const createdEl = document.getElementById('cl-created');
let currentContainerId = null;
let currentLogs = [];
let filteredLogs = [];
let eventSource = null;
let isStreaming = false;
let searchTimeout = null;
// Format date
function formatDate(dateStr) {
if (!dateStr) return '-';
const d = new Date(dateStr);
if (isNaN(d.getTime())) return dateStr;
return d.toLocaleString();
}
// Escape HTML
function escapeHtml(str) {
if (!str) return '';
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Format log entry for display
function formatLogEntry(log, index) {
const streamClass = log.stream === 'stderr' ? 'log-stderr' : 'log-stdout';
const streamIcon = log.stream === 'stderr' ? '⚠️' : '📤';
return `
<div class="log-entry ${streamClass}" data-index="${index}" 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;">${index + 1}</span>
<span class="log-stream-icon" style="color: ${log.stream === 'stderr' ? '#f59e0b' : '#22c55e'};">${streamIcon}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${escapeHtml(log.text)}</span>
</div>
`;
}
// Render logs to the content area
function renderLogs(logs, searchTerm = '') {
if (!logs || logs.length === 0) {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>';
lineCount.textContent = '0 lines';
filterCount.textContent = '0 filtered';
return;
}
currentLogs = logs;
filteredLogs = searchTerm ? logs.filter(log =>
log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase())
) : logs;
lineCount.textContent = `${logs.length} lines`;
filterCount.textContent = searchTerm ? `${filteredLogs.length} of ${logs.length} shown` : `${logs.length} shown`;
if (filteredLogs.length === 0) {
logContent.innerHTML = `<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${escapeHtml(searchTerm)}"</div>`;
return;
}
logContent.innerHTML = filteredLogs.map((log, i) => formatLogEntry(log, i)).join('');
// Scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
// Load container list
async function loadContainers() {
try {
const data = await getJSON('/api/v1/logs/containers');
const containers = data.containers || [];
// Store current selection
const currentVal = containerSelect.value;
containerSelect.innerHTML = '<option value="">Select a container...</option>';
containers.forEach(c => {
const option = document.createElement('option');
option.value = c.id;
option.textContent = `${c.name} (${c.image.split(':')[0]}) - ${c.status}`;
option.dataset.name = c.name;
option.dataset.image = c.image;
option.dataset.status = c.status;
option.dataset.created = c.created;
containerSelect.appendChild(option);
});
// Restore selection if still valid
if (currentVal && containerSelect.querySelector(`option[value="${currentVal}"]`)) {
containerSelect.value = currentVal;
loadContainerInfo(currentVal);
}
} catch (err) {
console.error('Failed to load containers:', err);
}
}
// Load container info
function loadContainerInfo(containerId) {
const option = containerSelect.querySelector(`option[value="${containerId}"]`);
if (option) {
imageEl.textContent = option.dataset.image || '-';
statusEl.textContent = option.dataset.status || '-';
statusEl.style.color = option.dataset.status === 'running' ? 'var(--ok-fg, #4ade80)' : 'var(--bad-fg, #ef4444)';
createdEl.textContent = formatDate(option.dataset.created);
}
}
// Load logs for selected container
async function loadLogs() {
const containerId = containerSelect.value;
if (!containerId) {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
return;
}
// Stop any existing stream
stopStream();
currentContainerId = containerId;
loadContainerInfo(containerId);
const tail = logTail.value;
const searchTerm = logSearch.value.trim();
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';
try {
const url = `/api/v1/logs/container/${containerId}${tail !== 'all' ? `?tail=${tail}` : ''}`;
const data = await getJSON(url);
if (data.logs && data.logs.length > 0) {
renderLogs(data.logs, searchTerm);
} else {
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>';
lineCount.textContent = '0 lines';
filterCount.textContent = '0 filtered';
}
} catch (err) {
logContent.innerHTML = `<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${escapeHtml(err.message)}</div>`;
}
}
// Start streaming logs
function startStream() {
const containerId = containerSelect.value;
if (!containerId) return;
// Stop any existing stream
stopStream();
isStreaming = true;
streamBtn.textContent = '⏹ Stop';
streamStatus.style.display = 'flex';
streamIndicator.textContent = '🟢';
streamText.textContent = 'Connecting...';
const url = `/api/v1/logs/stream/${containerId}`;
eventSource = new EventSource(url);
eventSource.onopen = () => {
streamIndicator.textContent = '🟢';
streamText.textContent = 'Connected - streaming logs';
};
eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data);
if (log.error) {
streamIndicator.textContent = '🔴';
streamText.textContent = `Error: ${log.error}`;
return;
}
// Add to current logs
currentLogs.push(log);
filteredLogs.push(log);
// Update counts
lineCount.textContent = `${currentLogs.length} lines`;
filterCount.textContent = `${filteredLogs.length} shown`;
// Append new log entry
const searchTerm = logSearch.value.trim();
if (!searchTerm || (log.text && log.text.toLowerCase().includes(searchTerm.toLowerCase()))) {
const entry = document.createElement('div');
entry.innerHTML = formatLogEntry(log, filteredLogs.length - 1);
const entryDiv = entry.firstElementChild;
entryDiv.style.background = '#1a3a1a';
logContent.appendChild(entryDiv);
// Auto-scroll to bottom
logContent.scrollTop = logContent.scrollHeight;
}
} catch (e) {
console.error('Error parsing log:', e);
}
};
eventSource.onerror = () => {
streamIndicator.textContent = '🔴';
streamText.textContent = 'Disconnected';
isStreaming = false;
streamBtn.textContent = '▶ Stream';
};
// Store the EventSource for cleanup
modal._eventSource = eventSource;
}
// Stop streaming logs
function stopStream() {
if (eventSource) {
eventSource.close();
eventSource = null;
}
if (modal._eventSource) {
modal._eventSource.close();
modal._eventSource = null;
}
isStreaming = false;
streamBtn.textContent = '▶ Stream';
streamStatus.style.display = 'none';
}
// Download logs as file
function downloadLogs() {
if (!currentLogs || currentLogs.length === 0) {
showNotification('No logs to download', 'error');
return;
}
const containerName = containerSelect.querySelector(`option[value="${currentContainerId}"]`)?.dataset.name || currentContainerId;
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const filename = `${containerName}-logs-${timestamp}.txt`;
const content = currentLogs.map(log => {
const timestamp = log.timestamp || '';
const stream = log.stream === 'stderr' ? '[ERR]' : '[OUT]';
return `${timestamp ? timestamp + ' ' : ''}${stream} ${log.text}`;
}).join('\n');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showNotification(`Downloaded ${currentLogs.length} log lines`, 'success');
}
// Event listeners
containerSelect?.addEventListener('change', () => {
loadLogs();
});
logTail?.addEventListener('change', () => {
loadLogs();
});
refreshBtn?.addEventListener('click', () => {
loadLogs();
});
streamBtn?.addEventListener('click', () => {
if (isStreaming) {
stopStream();
} else {
startStream();
}
});
downloadBtn?.addEventListener('click', () => {
downloadLogs();
});
clearSearchBtn?.addEventListener('click', () => {
logSearch.value = '';
renderLogs(currentLogs, '');
});
logSearch?.addEventListener('input', () => {
// Debounce search
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
renderLogs(currentLogs, logSearch.value.trim());
}, 300);
});
logSearch?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
logSearch.value = '';
renderLogs(currentLogs, '');
}
});
// Open modal
const openBtn = document.getElementById('view-container-logs');
openBtn?.addEventListener('click', () => {
modal.classList.add('show');
loadContainers();
});
// Close modal handlers
function closeModal() {
stopStream();
modal.classList.remove('show');
}
closeBtn?.addEventListener('click', closeModal);
closeBtn2?.addEventListener('click', closeModal);
// Close on escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && modal.classList.contains('show')) {
closeModal();
}
});
// Wire modal (close on backdrop click)
wireModal(modal, null, closeModal);
// Expose for use by service card buttons (grid.js calls openContainerLogsModal)
window.openContainerLogsModal = function(containerId, containerName) {
modal.classList.add('show');
loadContainers().then(() => {
// Try to find and select the container
const option = Array.from(containerSelect.options).find(opt =>
opt.value === containerId || opt.dataset.name === containerName
);
if (option) {
containerSelect.value = option.value;
loadContainerInfo(option.value);
loadLogs();
} else if (containerId) {
// If container not found in list but we have an ID, try loading directly
currentContainerId = containerId;
imageEl.textContent = containerName || containerId;
statusEl.textContent = '-';
createdEl.textContent = '-';
loadLogs();
} else {
// No container ID, just show modal with container list
logContent.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';
}
});
};
})();
+8 -4
View File
@@ -6,12 +6,16 @@
(function(window) { (function(window) {
'use strict'; 'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) console.log(...args);
};
class DnsTemplateSelector { class DnsTemplateSelector {
constructor(progressTracker) { constructor(progressTracker) {
this.progressTracker = progressTracker; this.progressTracker = progressTracker;
this.modal = null; this.modal = null;
this.onTemplateSelected = null; this.onTemplateSelected = null;
console.log('[DnsTemplateSelector] Module loaded'); debug('[DnsTemplateSelector] Module loaded');
} }
/** /**
@@ -216,7 +220,7 @@
* @private * @private
*/ */
handleTemplateSelection(template) { handleTemplateSelection(template) {
console.log(`[DnsTemplateSelector] Template selected: ${template.id}`); debug(`[DnsTemplateSelector] Template selected: ${template.id}`);
// Close modal // Close modal
this.close(); this.close();
@@ -235,7 +239,7 @@
* @private * @private
*/ */
handleSetupLater() { handleSetupLater() {
console.log('[DnsTemplateSelector] DNS setup deferred'); debug('[DnsTemplateSelector] DNS setup deferred');
// Mark as deferred in progress tracker // Mark as deferred in progress tracker
if (this.progressTracker) { if (this.progressTracker) {
@@ -316,6 +320,6 @@
} }
window.DnsTemplateSelector = DnsTemplateSelector; window.DnsTemplateSelector = DnsTemplateSelector;
console.log('[DnsTemplateSelector] Module loaded'); debug('[DnsTemplateSelector] Module loaded');
})(window); })(window);
+5 -2
View File
@@ -24,6 +24,9 @@ const DC = {
}, },
}; };
// Error handler for tracking issues
const errorHandler = new ErrorHandler();
// ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) ===== // ===== GLOBAL SITE CONFIG (loaded from server, cached in localStorage) =====
// Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API // Only non-sensitive display preferences are cached; DNS IPs/topology are fetched from API
const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null'); const _cachedCfg = JSON.parse(localStorage.getItem('dashcaddy_site_config') || 'null');
@@ -160,7 +163,7 @@ async function getCSRFToken() {
csrfToken = data.token; csrfToken = data.token;
return csrfToken; return csrfToken;
} catch (error) { } catch (error) {
console.error('Failed to get CSRF token:', error); errorHandler.logError('[CSRF] Get Token', error, { function: 'getCSRFToken' });
throw error; throw error;
} }
} }
@@ -185,7 +188,7 @@ async function secureFetch(url, options = {}) {
'X-CSRF-Token': token 'X-CSRF-Token': token
}; };
} catch (error) { } catch (error) {
console.error('Failed to add CSRF token to request:', error); errorHandler.logError('[CSRF] Add to Request', error, { function: 'secureFetch' });
} }
} }
+8 -4
View File
@@ -6,6 +6,10 @@
(function() { (function() {
'use strict'; 'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
// All modal selectors that can be closed with Escape // All modal selectors that can be closed with Escape
const MODAL_SELECTORS = [ const MODAL_SELECTORS = [
'#app-selector-modal', '#app-selector-modal',
@@ -39,9 +43,9 @@
// Add global keyboard listener // Add global keyboard listener
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
console.log('[Keyboard Shortcuts] Initialized'); debug('[Keyboard Shortcuts] Initialized');
console.log('[Keyboard Shortcuts] Press Ctrl+K to open quick search'); debug('[Keyboard Shortcuts] Press Ctrl+K to open quick search');
console.log('[Keyboard Shortcuts] Press Escape to close modals'); debug('[Keyboard Shortcuts] Press Escape to close modals');
} catch (e) { } catch (e) {
console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message); console.warn('[Keyboard Shortcuts] Failed to initialize:', e.message);
} }
@@ -599,7 +603,7 @@
} }
break; break;
default: default:
console.log('[Keyboard Shortcuts] Unknown action:', action); debug('[Keyboard Shortcuts] Unknown action:', action);
} }
} catch (e) { } catch (e) {
console.warn('[Keyboard Shortcuts] Error executing action:', e.message); console.warn('[Keyboard Shortcuts] Error executing action:', e.message);
+1 -1
View File
@@ -11,7 +11,7 @@
es.addEventListener('connected', () => { es.addEventListener('connected', () => {
reconnectDelay = 1000; // reset backoff reconnectDelay = 1000; // reset backoff
console.log('[SSE] Connected to event stream'); debug('[SSE] Connected to event stream');
}); });
// Health status changes → update card dots/badges in real time // Health status changes → update card dots/badges in real time
+3 -2
View File
@@ -1,5 +1,6 @@
// ========== NOTIFICATION SETTINGS ========== // ========== NOTIFICATION SETTINGS ==========
(function() { (function() {
const errorHandler = new ErrorHandler();
// Inject modal HTML // Inject modal HTML
injectModal('notifications-modal', `<div id="notifications-modal" class="weather-modal"> injectModal('notifications-modal', `<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;"> <div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
@@ -255,7 +256,7 @@
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false; document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
} }
} catch (error) { } catch (error) {
console.error('Failed to load notification config:', error); errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
} }
} }
@@ -289,7 +290,7 @@
container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'; container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>';
} }
} catch (error) { } catch (error) {
console.error('Failed to load notification history:', error); errorHandler.logError('[Notifications] Load History', error, { function: 'loadHistory' });
} }
} }
+22 -18
View File
@@ -9,6 +9,12 @@
(function() { (function() {
'use strict'; 'use strict';
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) {
console.log(...args);
}
};
let progressTracker; let progressTracker;
let themeAdapter; let themeAdapter;
let tourManager; let tourManager;
@@ -20,7 +26,7 @@
*/ */
async function initializeOnboarding() { async function initializeOnboarding() {
try { try {
console.log('[Onboarding] Initializing system...'); debug('[Onboarding] Initializing system...');
if (window.__dashcaddySiteConfigLoaded) { if (window.__dashcaddySiteConfigLoaded) {
try { try {
@@ -30,27 +36,27 @@
// Initialize Error Handler first // Initialize Error Handler first
errorHandler = new ErrorHandler(); errorHandler = new ErrorHandler();
console.log('[Onboarding] Error Handler initialized'); debug('[Onboarding] Error Handler initialized');
// Initialize Progress Tracker // Initialize Progress Tracker
progressTracker = new ProgressTracker('dashcaddy_onboarding'); progressTracker = new ProgressTracker('dashcaddy_onboarding');
console.log('[Onboarding] Progress Tracker initialized'); debug('[Onboarding] Progress Tracker initialized');
// Initialize Theme Adapter // Initialize Theme Adapter
themeAdapter = new ThemeAdapter(); themeAdapter = new ThemeAdapter();
console.log('[Onboarding] Theme Adapter initialized'); debug('[Onboarding] Theme Adapter initialized');
// Initialize DNS Template Selector // Initialize DNS Template Selector
dnsTemplateSelector = new DnsTemplateSelector(progressTracker); dnsTemplateSelector = new DnsTemplateSelector(progressTracker);
console.log('[Onboarding] DNS Template Selector initialized'); debug('[Onboarding] DNS Template Selector initialized');
// Initialize Tour Manager // Initialize Tour Manager
tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector); tourManager = new TourManager(progressTracker, themeAdapter, dnsTemplateSelector);
console.log('[Onboarding] Tour Manager initialized'); debug('[Onboarding] Tour Manager initialized');
// Check if tour should auto-start // Check if tour should auto-start
if (tourManager.shouldAutoStart()) { if (tourManager.shouldAutoStart()) {
console.log('[Onboarding] Auto-starting tour for first-time install'); debug('[Onboarding] Auto-starting tour for first-time install');
await progressTracker.markInstallOnboardingCompleted(); await progressTracker.markInstallOnboardingCompleted();
// Wait a bit for page to fully load // Wait a bit for page to fully load
setTimeout(() => { setTimeout(() => {
@@ -59,11 +65,11 @@
} else { } else {
const tourCompleted = progressTracker.isTourCompleted(); const tourCompleted = progressTracker.isTourCompleted();
const currentStep = progressTracker.getCurrentStep(); const currentStep = progressTracker.getCurrentStep();
console.log(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`); debug(`[Onboarding] Tour not auto-starting (completed: ${tourCompleted}, step: ${currentStep})`);
// If tour is in progress, offer to resume // If tour is in progress, offer to resume
if (!tourCompleted && currentStep > 0) { if (!tourCompleted && currentStep > 0) {
console.log('[Onboarding] Tour in progress, can be resumed manually'); debug('[Onboarding] Tour in progress, can be resumed manually');
} }
} }
@@ -81,13 +87,10 @@
getErrorStats: () => errorHandler.getStatistics() getErrorStats: () => errorHandler.getStatistics()
}; };
console.log('[Onboarding] System initialized successfully'); debug('[Onboarding] System initialized successfully');
} catch (error) { } catch (error) {
console.error('[Onboarding] Initialization error:', error);
// Use error handler if available
if (errorHandler) { if (errorHandler) {
errorHandler.logError('Initialization', error); errorHandler.logError('[Onboarding] Initialization', error);
} }
// Graceful degradation - don't break the dashboard // Graceful degradation - don't break the dashboard
@@ -104,10 +107,12 @@
const clickHandler = () => { const clickHandler = () => {
if (tourManager) { if (tourManager) {
console.log('[Onboarding] Starting tour via button click'); debug('[Onboarding] Starting tour via button click');
tourManager.restartTour(); tourManager.restartTour();
} else { } else {
console.error('[Onboarding] Tour manager not initialized'); if (errorHandler) {
errorHandler.logError('[Onboarding] Tour Manager Not Initialized', new Error('Tour manager not initialized'));
}
alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization'); alert('Tour is not available. Check browser console for errors.\n\nPossible issues:\n- Driver.js library failed to load\n- JavaScript errors during initialization');
} }
}; };
@@ -157,7 +162,6 @@
setTimeout(attemptInit, 500); setTimeout(attemptInit, 500);
} else { } else {
// Max retries reached, show fallback // Max retries reached, show fallback
console.error('[Onboarding] Driver.js failed to load after multiple attempts');
if (errorHandler) { if (errorHandler) {
errorHandler.handleDriverLoadFailure(); errorHandler.handleDriverLoadFailure();
} else { } else {
@@ -179,6 +183,6 @@
waitForDriver(); waitForDriver();
} }
console.log('[Onboarding] System loaded'); debug('[Onboarding] System loaded');
})(); })();
+11 -5
View File
@@ -18,6 +18,12 @@
(function(window) { (function(window) {
'use strict'; 'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/** /**
* ProgressTracker class * ProgressTracker class
* Manages persistent storage of onboarding progress * Manages persistent storage of onboarding progress
@@ -68,7 +74,7 @@
const data = localStorage.getItem(this.storageKey); const data = localStorage.getItem(this.storageKey);
return data ? JSON.parse(data) : null; return data ? JSON.parse(data) : null;
} catch (error) { } catch (error) {
console.error('[ProgressTracker] Error reading from storage:', error); errorHandler.logError('[ProgressTracker] Read Storage', error, { function: '_getStorage' });
return null; return null;
} }
} }
@@ -82,7 +88,7 @@
try { try {
localStorage.setItem(this.storageKey, JSON.stringify(state)); localStorage.setItem(this.storageKey, JSON.stringify(state));
} catch (error) { } catch (error) {
console.error('[ProgressTracker] Error writing to storage:', error); errorHandler.logError('[ProgressTracker] Write Storage', error, { function: '_setStorage' });
// Handle quota exceeded or storage unavailable // Handle quota exceeded or storage unavailable
// Fall back to session storage or in-memory storage // Fall back to session storage or in-memory storage
this._handleStorageError(error); this._handleStorageError(error);
@@ -100,7 +106,7 @@
sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage())); sessionStorage.setItem(this.storageKey, JSON.stringify(this._getStorage()));
console.warn('[ProgressTracker] Falling back to session storage'); console.warn('[ProgressTracker] Falling back to session storage');
} catch (sessionError) { } catch (sessionError) {
console.error('[ProgressTracker] Session storage also unavailable:', sessionError); errorHandler.logError('[ProgressTracker] Session Storage Unavailable', sessionError, { function: '_handleStorageError' });
// Could implement in-memory fallback here if needed // Could implement in-memory fallback here if needed
} }
} }
@@ -186,7 +192,7 @@
body: JSON.stringify({ onboardingCompleted: true }) body: JSON.stringify({ onboardingCompleted: true })
}); });
} catch (error) { } catch (error) {
console.error('[ProgressTracker] Failed to persist install onboarding state:', error); errorHandler.logError('[ProgressTracker] Persist Install Onboarding', error, { function: 'markInstallOnboardingCompleted' });
} }
} }
@@ -308,6 +314,6 @@
// Export to global scope // Export to global scope
window.ProgressTracker = ProgressTracker; window.ProgressTracker = ProgressTracker;
console.log('[ProgressTracker] Module loaded'); debug('[ProgressTracker] Module loaded');
})(window); })(window);
+3 -2
View File
@@ -1,5 +1,6 @@
// ===== SERVICE CREDENTIALS ===== // ===== SERVICE CREDENTIALS =====
(function() { (function() {
const errorHandler = new ErrorHandler();
injectModal('folder-browser-modal', `<div id="folder-browser-modal" class="weather-modal"> injectModal('folder-browser-modal', `<div id="folder-browser-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;"> <div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
<h3>📂 Browse for Media Folders</h3> <h3>📂 Browse for Media Folders</h3>
@@ -433,7 +434,7 @@
await loadServiceCreds(currentService); await loadServiceCreds(currentService);
} catch (e) { } catch (e) {
console.error('Failed to save credentials:', e); errorHandler.logError('[ServiceCredentials] Save', e, { function: 'saveCredentials' });
showError('Failed to save: ' + (e.message || 'Unknown error')); showError('Failed to save: ' + (e.message || 'Unknown error'));
} }
saveBtn.textContent = 'Save'; saveBtn.textContent = 'Save';
@@ -460,7 +461,7 @@
if (btn) btn.classList.remove('has-creds'); if (btn) btn.classList.remove('has-creds');
await loadServiceCreds(currentService); await loadServiceCreds(currentService);
} catch (e) { } catch (e) {
console.error('Failed to clear credentials:', e); errorHandler.logError('[ServiceCredentials] Clear', e, { function: 'clearCredentials' });
showError('Failed to clear: ' + (e.message || 'Unknown error')); showError('Failed to clear: ' + (e.message || 'Unknown error'));
} }
}); });
+57
View File
@@ -0,0 +1,57 @@
// ========== SERVICE FILTER ==========
(function() {
const searchInput = document.getElementById('service-filter-search');
const statusSelect = document.getElementById('service-filter-status');
const countSpan = document.getElementById('service-filter-count');
function updateFilter() {
const query = searchInput.value.toLowerCase().trim();
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
const cards = document.querySelectorAll('#cards .card');
let visibleCount = 0;
cards.forEach(card => {
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
const app = card.dataset.app?.toLowerCase() || '';
const status = card.dataset.status || 'off'; // 'on' or 'off'
const matchesSearch = !query || name.includes(query) || app.includes(query);
const matchesStatus = statusFilter === 'all' || status === statusFilter;
if (matchesSearch && matchesStatus) {
card.style.display = '';
visibleCount++;
} else {
card.style.display = 'none';
}
});
if (countSpan) {
const total = cards.length;
countSpan.textContent = `${visibleCount} of ${total} services`;
}
}
// Debounce helper
function debounce(fn, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
searchInput?.addEventListener('input', debounce(updateFilter, 200));
statusSelect?.addEventListener('change', updateFilter);
// Initial count on page load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(updateFilter, 500));
} else {
setTimeout(updateFilter, 500);
}
// Expose for external triggers
window.refreshServiceFilter = updateFilter;
})();
+4 -2
View File
@@ -1,4 +1,6 @@
// Shared timezone utility — used by setup wizard and settings modal // Shared timezone utility — used by setup wizard and settings modal
const errorHandler = new ErrorHandler();
window.populateTimezoneSelect = function(selectEl, selectedTz) { window.populateTimezoneSelect = function(selectEl, selectedTz) {
const timezones = Intl.supportedValuesOf('timeZone'); const timezones = Intl.supportedValuesOf('timeZone');
const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; const detected = selectedTz || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
@@ -150,11 +152,11 @@ window.populateTimezoneSelect = function(selectEl, selectedTz) {
await response.json(); await response.json();
return true; return true;
} else { } else {
console.error('Failed to save config to server:', response.status); errorHandler.logError('[SetupWizard] Save Config', new Error(`Server returned ${response.status}`), { function: 'saveConfigToServer' });
return false; return false;
} }
} catch (error) { } catch (error) {
console.error('Error saving config to server:', error); errorHandler.logError('[SetupWizard] Save Config', error, { function: 'saveConfigToServer' });
return false; return false;
} }
} }
+180
View File
@@ -0,0 +1,180 @@
// ========== CONTAINER SNAPSHOT / CHECKPOINT ==========
(function() {
// Inject modal HTML
injectModal('snapshot-modal', `<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>💾 Container Snapshots</h3>
<p class="modal-subtitle">
Create and manage Docker container checkpoints for instant state recovery.
</p>
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
<option value="">-- Select a container --</option>
</select>
</div>
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
</div>
</div>
<div class="panel-tabs" style="margin-bottom: 12px;">
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
</div>
<div id="snapshot-create" class="panel-section active">
<div style="margin-bottom: 12px;">
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
</div>
<div style="margin-bottom: 12px;">
<label class="checkbox-label" style="font-size: 0.85rem;">
<input type="checkbox" id="snapshot-leave-running" checked />
Leave container running after checkpoint (resume without restart)
</label>
</div>
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">💾 Create Snapshot</button>
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
</div>
<div id="snapshot-list" class="panel-section" style="display: none;">
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
<div class="panel-empty"><span class="empty-icon">💾</span>Select a container to view its snapshots</div>
</div>
</div>
<div class="weather-modal-buttons modal-footer-bar">
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);
const modal = document.getElementById('snapshot-modal');
const openBtn = document.getElementById('snapshot-btn');
const closeBtn = document.getElementById('snapshot-close');
const containerSelect = document.getElementById('snapshot-container-select');
const detailsDiv = document.getElementById('snapshot-details');
const createBtn = document.getElementById('snapshot-create-btn');
const createStatus = document.getElementById('snapshot-create-status');
let currentContainerId = null;
async function loadContainers() {
try {
const res = await fetch('/api/v1/containers');
const data = await res.json();
if (!data.success || !data.containers) return;
containerSelect.innerHTML = '<option value="">-- Select a container --</option>';
for (const c of data.containers) {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.name || c.id} (${c.image || 'unknown'})`;
opt.dataset.name = c.name;
opt.dataset.image = c.image;
opt.dataset.status = c.status;
opt.dataset.created = c.created;
containerSelect.appendChild(opt);
}
} catch (e) {
console.error('Failed to load containers:', e);
}
}
function showContainerDetails(opt) {
if (!opt || !opt.value) {
detailsDiv.style.display = 'none';
currentContainerId = null;
return;
}
currentContainerId = opt.value;
document.getElementById('snapshot-image').textContent = opt.dataset.image || '-';
document.getElementById('snapshot-status').textContent = opt.dataset.status || '-';
document.getElementById('snapshot-created').textContent = opt.dataset.created ? new Date(opt.dataset.created * 1000).toLocaleString() : '-';
document.getElementById('snapshot-id').textContent = opt.value.substring(0, 12);
detailsDiv.style.display = '';
}
async function createSnapshot() {
if (!currentContainerId) {
createStatus.textContent = 'Please select a container first';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const name = document.getElementById('snapshot-name').value.trim();
if (!name) {
createStatus.textContent = 'Please enter a snapshot name';
createStatus.style.color = 'var(--bad-fg)';
return;
}
const leaveRunning = document.getElementById('snapshot-leave-running').checked;
createBtn.disabled = true;
createBtn.textContent = 'Creating...';
createStatus.textContent = '';
try {
const res = await fetch(`/api/v1/containers/${encodeURIComponent(currentContainerId)}/checkpoint`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, leaveRunning })
});
const data = await res.json();
if (data.success) {
createStatus.textContent = `✓ Snapshot "${name}" created successfully`;
createStatus.style.color = 'var(--ok-fg)';
document.getElementById('snapshot-name').value = '';
} else {
createStatus.textContent = `✗ Failed: ${data.error || 'Unknown error'}`;
createStatus.style.color = 'var(--bad-fg)';
}
} catch (e) {
createStatus.textContent = `✗ Error: ${e.message}`;
createStatus.style.color = 'var(--bad-fg)';
} finally {
createBtn.disabled = false;
createBtn.textContent = '💾 Create Snapshot';
}
}
function openModal() {
modal.classList.add('show');
loadContainers();
}
function closeModal() {
modal.classList.remove('show');
detailsDiv.style.display = 'none';
currentContainerId = null;
containerSelect.selectedIndex = 0;
}
openBtn?.addEventListener('click', openModal);
closeBtn?.addEventListener('click', closeModal);
wireModal(modal, closeBtn);
containerSelect?.addEventListener('change', (e) => {
const opt = containerSelect.options[containerSelect.selectedIndex];
showContainerDetails(opt);
});
createBtn?.addEventListener('click', createSnapshot);
// Tab switching
modal?.querySelectorAll('.panel-tab').forEach(tab => {
tab.addEventListener('click', () => {
modal.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active'));
modal.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active'));
tab.classList.add('active');
modal.querySelector(`#${tab.dataset.panel}`).classList.add('active');
});
});
})();
+11 -5
View File
@@ -7,6 +7,12 @@
(function(window) { (function(window) {
'use strict'; 'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/** /**
* Theme configuration mapping for Driver.js * Theme configuration mapping for Driver.js
* Maps dashboard themes to Driver.js styling * Maps dashboard themes to Driver.js styling
@@ -170,7 +176,7 @@
attributeFilter: ['class'] attributeFilter: ['class']
}); });
console.log('[ThemeAdapter] Theme change listener initialized'); debug('[ThemeAdapter] Theme change listener initialized');
} }
/** /**
@@ -180,13 +186,13 @@
* @param {string} oldTheme - Old theme name * @param {string} oldTheme - Old theme name
*/ */
_notifyThemeChange(newTheme, oldTheme) { _notifyThemeChange(newTheme, oldTheme) {
console.log(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`); debug(`[ThemeAdapter] Theme changed: ${oldTheme} → ${newTheme}`);
this.themeChangeCallbacks.forEach(callback => { this.themeChangeCallbacks.forEach(callback => {
try { try {
callback(newTheme, oldTheme); callback(newTheme, oldTheme);
} catch (error) { } catch (error) {
console.error('[ThemeAdapter] Error in theme change callback:', error); errorHandler.logError('[ThemeAdapter] Theme Change Callback', error, { function: '_notifyThemeChange' });
} }
}); });
} }
@@ -207,7 +213,7 @@
// Note: Driver.js v1.0+ uses CSS variables, so we inject a style element // Note: Driver.js v1.0+ uses CSS variables, so we inject a style element
this._injectDriverStyles(themeConfig); this._injectDriverStyles(themeConfig);
console.log('[ThemeAdapter] Theme applied to driver:', this.currentTheme); debug('[ThemeAdapter] Theme applied to driver:', this.currentTheme);
} }
/** /**
@@ -302,6 +308,6 @@
// Export to global scope // Export to global scope
window.ThemeAdapter = ThemeAdapter; window.ThemeAdapter = ThemeAdapter;
console.log('[ThemeAdapter] Module loaded'); debug('[ThemeAdapter] Module loaded');
})(window); })(window);
+10 -4
View File
@@ -6,6 +6,12 @@
(function(window) { (function(window) {
'use strict'; 'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
/** /**
* Validate a tooltip definition * Validate a tooltip definition
* @param {Object} tooltip - The tooltip definition to validate * @param {Object} tooltip - The tooltip definition to validate
@@ -147,7 +153,7 @@
`${e.tooltip}: ${e.errors.join(', ')}` `${e.tooltip}: ${e.errors.join(', ')}`
).join('\n'); ).join('\n');
console.error('[TooltipDefinitions] Validation errors:', errorMessages); errorHandler.logError('[TooltipDefinitions] Validation', errorMessages, { function: 'validateTooltip' });
throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`); throw new TooltipError(`Tooltip validation failed:\n${errorMessages}`);
} }
} }
@@ -160,7 +166,7 @@
TooltipError TooltipError
}; };
console.log('[TooltipDefinitions] Validation module loaded'); debug('[TooltipDefinitions] Validation module loaded');
})(window); })(window);
@@ -489,7 +495,7 @@ function getActiveTooltips() {
try { try {
return tooltip.condition(); return tooltip.condition();
} catch (error) { } catch (error) {
console.error(`[TooltipDefinitions] Error evaluating condition for ${tooltip.id}:`, error); errorHandler.logError('[TooltipDefinitions] Condition Eval', error, { function: 'evaluateCondition', tooltipId: tooltip.id });
return false; return false;
} }
} }
@@ -534,5 +540,5 @@ window.TooltipDefinitions = {
getNewFeatureTooltips getNewFeatureTooltips
}; };
console.log('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips'); debug('[TooltipDefinitions] Definitions loaded:', TOOLTIP_DEFINITIONS.length, 'tooltips');
+5 -4
View File
@@ -1,5 +1,6 @@
// ===== TOTP SETTINGS ===== // ===== TOTP SETTINGS =====
(function() { (function() {
const errorHandler = new ErrorHandler();
injectModal('totp-settings-modal', `<div id="totp-settings-modal" class="weather-modal"> injectModal('totp-settings-modal', `<div id="totp-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;"> <div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3> <h3 style="margin: 0 0 16px; font-size: 1.1rem;">Authentication Settings</h3>
@@ -185,7 +186,7 @@
document.getElementById('totp-setup-code').focus(); document.getElementById('totp-setup-code').focus();
} }
} catch (e) { } catch (e) {
console.error('TOTP setup failed:', e); errorHandler.logError('[TOTP] Setup Failed', e, { function: 'setupTOTP' });
} }
}); });
@@ -274,7 +275,7 @@
}); });
loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP) loadTotpSettings(); // Refresh modal + card (handles "never" disabling TOTP)
} catch (err) { } catch (err) {
console.error('Failed to update session duration:', err); errorHandler.logError('[TOTP] Update Session Duration', err, { function: 'updateSessionDuration' });
} }
}); });
@@ -290,7 +291,7 @@
const data = await res.json(); const data = await res.json();
if (data.success) loadTotpSettings(); if (data.success) loadTotpSettings();
} catch (e) { } catch (e) {
console.error('Failed to disable TOTP:', e); errorHandler.logError('[TOTP] Disable Failed', e, { function: 'disableTOTP' });
} }
}); });
@@ -322,7 +323,7 @@
const active = data.config.enabled && data.config.isSetUp; const active = data.config.enabled && data.config.isSetUp;
updateAuthCard(active, data.config.sessionDuration); updateAuthCard(active, data.config.sessionDuration);
} }
} catch (e) { console.error('[AuthCard] Failed to update:', e); } } catch (e) { errorHandler.logError('[TOTP] AuthCard Update', e, { function: 'authCardUpdate' }); }
})(); })();
})(); })();
+18 -12
View File
@@ -6,6 +6,12 @@
(function(window) { (function(window) {
'use strict'; 'use strict';
const errorHandler = new ErrorHandler();
const debug = (...args) => {
if (window.DASHCADDY_DEBUG) { console.log(...args); }
};
class TourManager { class TourManager {
constructor(progressTracker, themeAdapter, dnsTemplateSelector) { constructor(progressTracker, themeAdapter, dnsTemplateSelector) {
this.progressTracker = progressTracker; this.progressTracker = progressTracker;
@@ -26,7 +32,7 @@
const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver; const driverFactory = window.driver?.js?.driver || window.driver?.driver || window.driver;
if (typeof driverFactory !== 'function') { if (typeof driverFactory !== 'function') {
console.error('[TourManager] Driver.js not loaded or invalid. window.driver:', window.driver); errorHandler.logError('[TourManager] Driver.js Not Loaded', new Error('Driver.js not loaded or invalid'), { windowDriver: typeof window.driver });
return false; return false;
} }
@@ -92,7 +98,7 @@
const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id)); const activeTooltips = allTooltips.filter(t => !completedIds.includes(t.id));
if (activeTooltips.length === 0) { if (activeTooltips.length === 0) {
console.log('[TourManager] No tooltips to show'); debug('[TourManager] No tooltips to show');
this.progressTracker.markTourCompleted(); this.progressTracker.markTourCompleted();
return; return;
} }
@@ -131,7 +137,7 @@
// Add custom handlers for DNS tooltip // Add custom handlers for DNS tooltip
if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) { if (tooltip.id === 'dns-priority' && this.dnsTemplateSelector) {
step.popover.onSetupNowClick = () => { step.popover.onSetupNowClick = () => {
console.log('[TourManager] Opening DNS template selector'); debug('[TourManager] Opening DNS template selector');
this.dnsTemplateSelector.showTemplateSelector(); this.dnsTemplateSelector.showTemplateSelector();
// Mark tooltip as completed and move to next // Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id); this.progressTracker.markTooltipCompleted(tooltip.id);
@@ -141,7 +147,7 @@
}; };
step.popover.onLaterClick = () => { step.popover.onLaterClick = () => {
console.log('[TourManager] DNS setup deferred'); debug('[TourManager] DNS setup deferred');
this.progressTracker.markDnsSetupDeferred(); this.progressTracker.markDnsSetupDeferred();
// Mark tooltip as completed and move to next // Mark tooltip as completed and move to next
this.progressTracker.markTooltipCompleted(tooltip.id); this.progressTracker.markTooltipCompleted(tooltip.id);
@@ -198,7 +204,7 @@
async showTooltip(tooltipId) { async showTooltip(tooltipId) {
const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId); const tooltip = window.TooltipDefinitions.getTooltipById(tooltipId);
if (!tooltip) { if (!tooltip) {
console.error(`[TourManager] Tooltip not found: ${tooltipId}`); errorHandler.logError('[TourManager] Tooltip Not Found', new Error(`Tooltip not found: ${tooltipId}`), { tooltipId });
return; return;
} }
@@ -232,11 +238,11 @@
const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips(); const newFeatureTooltips = window.TooltipDefinitions.getNewFeatureTooltips();
if (newFeatureTooltips.length === 0) { if (newFeatureTooltips.length === 0) {
console.log('[TourManager] No new features to show'); debug('[TourManager] No new features to show');
return; return;
} }
console.log(`[TourManager] Showing ${newFeatureTooltips.length} new features`); debug(`[TourManager] Showing ${newFeatureTooltips.length} new features`);
// Convert to Driver.js steps // Convert to Driver.js steps
const steps = newFeatureTooltips.map((tooltip, index) => { const steps = newFeatureTooltips.map((tooltip, index) => {
@@ -280,7 +286,7 @@
clearTimeout(resizeTimeout); clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => { resizeTimeout = setTimeout(() => {
if (this.isActive && this.driver) { if (this.isActive && this.driver) {
console.log('[TourManager] Window resized, repositioning tooltip'); debug('[TourManager] Window resized, repositioning tooltip');
this.driver.refresh(); this.driver.refresh();
} }
}, 150); // Debounce for 150ms }, 150); // Debounce for 150ms
@@ -289,7 +295,7 @@
// Layout change handler (for theme changes, DOM mutations) // Layout change handler (for theme changes, DOM mutations)
this.layoutChangeHandler = () => { this.layoutChangeHandler = () => {
if (this.isActive && this.driver) { if (this.isActive && this.driver) {
console.log('[TourManager] Layout changed, repositioning tooltip'); debug('[TourManager] Layout changed, repositioning tooltip');
// Small delay to allow layout to settle // Small delay to allow layout to settle
setTimeout(() => { setTimeout(() => {
if (this.driver) { if (this.driver) {
@@ -347,7 +353,7 @@
onTourComplete() { onTourComplete() {
this.progressTracker.markTourCompleted(); this.progressTracker.markTourCompleted();
this.isActive = false; this.isActive = false;
console.log('[TourManager] Tour completed'); debug('[TourManager] Tour completed');
} }
/** /**
@@ -355,12 +361,12 @@
*/ */
onTourSkip() { onTourSkip() {
// Save current progress but don't mark as completed // Save current progress but don't mark as completed
console.log('[TourManager] Tour skipped'); debug('[TourManager] Tour skipped');
this.isActive = false; this.isActive = false;
} }
} }
window.TourManager = TourManager; window.TourManager = TourManager;
console.log('[TourManager] Module loaded'); debug('[TourManager] Module loaded');
})(window); })(window);
+7 -2
View File
@@ -397,7 +397,7 @@
} }
async function dcApplyUpdate() { async function dcApplyUpdate() {
if (!confirm('Apply DashCaddy update? The API container will restart.')) return; if (!confirm('Apply DashCaddy update? The API container will restart.')) return false;
dcApplyBtn.textContent = 'Updating...'; dcApplyBtn.textContent = 'Updating...';
dcApplyBtn.disabled = true; dcApplyBtn.disabled = true;
dcShowStatus('Downloading and applying update...', 'info'); dcShowStatus('Downloading and applying update...', 'info');
@@ -409,6 +409,7 @@
dcApplyBtn.textContent = 'Applied!'; dcApplyBtn.textContent = 'Applied!';
// Remove notification dots // Remove notification dots
document.querySelectorAll('.update-dot').forEach(d => d.remove()); document.querySelectorAll('.update-dot').forEach(d => d.remove());
return true;
} else { } else {
throw new Error(data.error || 'Update failed'); throw new Error(data.error || 'Update failed');
} }
@@ -416,6 +417,7 @@
dcShowStatus('Update failed: ' + e.message, 'error'); dcShowStatus('Update failed: ' + e.message, 'error');
dcApplyBtn.textContent = 'Update Now'; dcApplyBtn.textContent = 'Update Now';
dcApplyBtn.disabled = false; dcApplyBtn.disabled = false;
throw e;
} }
} }
@@ -487,7 +489,7 @@
} }
dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false)); dcCheckBtn?.addEventListener('click', () => dcCheckForUpdate(false));
dcApplyBtn?.addEventListener('click', dcApplyUpdate); dcApplyBtn?.addEventListener('click', () => dcApplyUpdate().catch(() => {}));
dcRollbackBtn?.addEventListener('click', dcShowRollback); dcRollbackBtn?.addEventListener('click', dcShowRollback);
checkBtn?.addEventListener('click', checkForUpdates); checkBtn?.addEventListener('click', checkForUpdates);
@@ -506,6 +508,9 @@
if (!dcLastCheck) dcCheckForUpdate(true); if (!dcLastCheck) dcCheckForUpdate(true);
}); });
window.dcApplyUpdate = dcApplyUpdate;
window.dcCheckForUpdate = dcCheckForUpdate;
// Non-blocking check on page load — just adds notification dot if update available // Non-blocking check on page load — just adds notification dot if update available
setTimeout(() => dcCheckForUpdate(true), 5000); setTimeout(() => dcCheckForUpdate(true), 5000);
})(); })();
+2 -1
View File
@@ -1,5 +1,6 @@
// ========== WEATHER WIDGET ========== // ========== WEATHER WIDGET ==========
(function() { (function() {
const errorHandler = new ErrorHandler();
// Inject modal HTML // Inject modal HTML
injectModal('weather-modal', `<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3> 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> <label for="weather-location-input">Location:</label>
@@ -166,7 +167,7 @@
weatherWidget.icon.innerHTML = `<span class="weather-emoji">${escapeHtml(weather.icon)}</span>`; weatherWidget.icon.innerHTML = `<span class="weather-emoji">${escapeHtml(weather.icon)}</span>`;
} }
} catch (error) { } catch (error) {
console.error('Weather update error:', error); errorHandler.logError('[Weather] Update Error', error, { function: 'updateWeather' });
weatherWidget.location.textContent = 'Weather Error'; weatherWidget.location.textContent = 'Weather Error';
weatherWidget.temp.textContent = 'Error'; weatherWidget.temp.textContent = 'Error';
weatherWidget.condition.textContent = 'Failed to load'; weatherWidget.condition.textContent = 'Failed to load';