Add service filter, batch operations, and snapshot features

- Service Filter Bar: search by name, filter by status (online/offline)
- Batch Operations: multi-select containers for start/stop/restart
- Container Snapshots: create and manage Docker checkpoints
- Added filter bar and batch action bar to index.html
- Added snapshot button to Admin tools section
- New JS modules: service-filter.js, batch-operations.js, snapshot.js
- Updated build.js to include new modules in bundle
This commit is contained in:
Krystie committed 2026-05-15 02:17:24 -07:00
1 parent 0cec447bf1
commit 77688daec7
7 files changed
+642 -186

No files matched your search

+62 -62
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"}},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 k=await fetch("/api/v1/config");if(k.ok){const v=await k.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 i=document.getElementById("manage-tokens");i&&(i.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(k=>k.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>',k=o.firstElementChild;m.forEach(v=>{const i=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="${i}-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="${i}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${i}-time" class="response-time">--</span></div><div class="health-row" id="health-${i}"><span id="uptime-${i}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${i}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${i}-restart" class="restart-btn">Restart</button><button id="${i}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${i}-open">Open</button><button id="${i}-logs" class="logs-btn">Logs</button><button id="${i}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,o.insertBefore(n,k)})}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(),k=!["GET","HEAD","OPTIONS"].includes(l);if(k)try{const i=await getCSRFToken();m.headers={...m.headers,"X-CSRF-Token":i}}catch(i){errorHandler.logError("[CSRF] Add to Request",i,{function:"secureFetch"})}m.signal||(m={...m,signal:AbortSignal.timeout(15e3)});const v=await fetch(o,m);if(k&&v.status===403)try{const i=await v.clone().json();if(i.errLine 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;
background: ${i.bg}; color: ${i.fg};
background: ${r.bg}; color: ${r.fg};
padding: 16px 24px; border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,.3);
z-index: 10000; animation: slideIn 0.3s ease-out;
max-width: 400px; white-space: pre-line; font-size: 14px;
`,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(k=>k.id===o);if(l){for(const[k,v]of Object.entries(m))k!=="__proto__"&&k!=="constructor"&&k!=="prototype"&&(l[k]=v);DC_BUS.emit("apps:changed",this._apps)}return l}};(function(){function o(){const k=document.createElement("div");return k.className="skeleton-card",k.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>',k}function m(k){const v=document.getElementById("cards");if(!(!v||v.querySelector(".card"))){k=k||6;for(let i=0;i<k;i++){const h=o();v.appendChild(h),setTimeout(function(){h.classList.add("loaded")},i*60)}}}function l(){const k=document.getElementById("cards");if(k){var v=k.querySelectorAll(".skeleton-card");v.length&&(v.forEach(function(i,h){setTimeout(function(){i.style.opacity="0",i.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){v.forEach(function(i){i.parentNode&&i.remove()})},v.length*25+300))}}window.SkeletonLoader={show:m,hide:l}})(),(function(){var o="theme",m="user-themes",l="custom-theme",k=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],v=k.slice(),i=["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"],x={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:
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(`+y.r+","+y.g+","+y.b+`, .05), transparent 55%),
var(--bg);
}
`:":root."+w+` body {
`:":root."+E+` body {
background:
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(`+y.r+","+y.g+","+y.b+`, .07), transparent 55%),
var(--bg);
}
`}function u(w,T){var $=T.lightBg||T.bg&&g(T.bg)>.4;return $?":root."+w+` 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%);
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);
}
`:":root."+w+` button:hover {
`:":root."+E+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function d(){i.forEach(function(w){document.documentElement.style.removeProperty("--"+w)})}function f(w,T){var $=w.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),k.indexOf($)!==-1&&($=$+"-custom");for(var b=safeGetJSON(m,{}),y=$,I=2;b[$]&&$!==T;)$=y+"-"+I++;return $}function r(w){var T=document.getElementById("user-theme-styles");T&&T.remove(),v.length=k.length,Object.keys(x).forEach(function(E){k.indexOf(E)===-1&&delete x[E]});var $=w||safeGetJSON(m,{}),b=Object.keys($);if(b=b.filter(function(E){return k.indexOf(E)===-1}),!!b.length){var y="";b.forEach(function(E){var C=$[E];v.indexOf(E)===-1&&v.push(E);var P={};i.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])}),x[E]=P,y+=":root."+E+` {
`,i.forEach(function(O){P[O]&&(y+=" --"+O+": "+P[O]+`;
`}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+` {
`,r.forEach(function(O){P[O]&&(y+=" --"+O+": "+P[O]+`;
`)}),y+=`}
`,y+=s(E,P),y+=u(E,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=y,document.head.appendChild(I)}}function S(){secureFetch("/api/v1/themes").then(function(w){return w.json()}).then(function(w){if(!(!w.success||!w.themes)){var T=w.themes,$=safeGetJSON(m,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(m,JSON.stringify(T)),r(T);var b=safeGet(o);b&&v.indexOf(b)!==-1&&L(b)}}}).catch(function(){})}function B(){var w=safeGetJSON(l);if(w){var T=w.name||"Custom",$=f(T),b={name:T};i.forEach(function(E){w[E]&&(b[E]=w[E])});var y=safeGetJSON(m,{});y[$]=b,safeSet(m,JSON.stringify(y)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(l);var I={};i.forEach(function(E){b[E]&&(I[E]=b[E])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:I})}).catch(function(){})}}function L(w){document.documentElement.classList.add("theme-transitioning"),v.forEach(function(y){y!=="dark"&&document.documentElement.classList.remove(y)}),d(),w!=="dark"&&document.documentElement.classList.add(w),safeSet(o,w);var T=x[w],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var b=T&&T.lightBg;!b&&T&&T.bg&&(b=g(T.bg)>.4),b?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"&&v.indexOf(A)===-1&&(A=null),L(A||t()),S(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(w){safeGet(o)||L(w.matches?"dark":"light")}),window.THEMES=v,window.BUILTIN_THEMES=k,window.THEME_COLORS=x,window.THEME_PROPS=i,window.BASE_PROPS=h,window.DERIVED_PROPS=n,window.USER_THEMES_KEY=m,window.applyTheme=L,window.clearCustomProperties=d,window.injectUserThemeStyles=r,window.syncThemesFromServer=S,window.slugifyThemeName=f,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=p,window.rgbToHex=a,window.blendColors=c})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const x=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!x)return;const p=(.299*+x[0]+.587*+x[1]+.114*+x[2])/255,a=h.querySelector(".totp-logo-dark"),c=h.querySelector(".totp-logo-light");a&&(a.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 k=document.getElementById("totp-digits");if(k){const h=k.querySelectorAll("input");h.forEach((n,x)=>{n.addEventListener("input",p=>{const a=p.target.value.replace(/\D/g,"");p.target.value=a.slice(0,1),a&&x<h.length-1&&h[x+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&&x>0&&(h[x-1].focus(),h[x-1].value="")}),n.addEventListener("paste",p=>{p.preventDefault();const a=(p.clipboardData.getData("text")||"").replace(/\D/g,"");a.length>=6&&(h.forEach((c,g)=>{c.value=a[g]||""}),h[5].focus(),v(a.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 a=safeSessionGet("totp_redirect");if(a){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=a;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{n.textContent=p.error||"Invalid code",n.className="totp-error";const a=document.querySelectorAll("#totp-digits input");a.forEach(c=>{c.value=""}),a[0]?.focus()}}catch{n.textContent="Connection error",n.className="totp-error"}}const i=new URLSearchParams(window.location.search);if(i.get("auth")==="required"){const h=i.get("return");if(h)try{const n=new URL(h,window.location.origin),x=n.hostname,p=n.origin===window.location.origin,a=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,c=x.endsWith(a)||x===a.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">
`,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;">
<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>
</div>
</div>
</div>`);const m=document.getElementById("service-creds-modal");let l=null;const k=["sonarr","radarr","prowlarr","overseerr"],v=["sonarr","radarr"];function i(a){return a.externalUrl||a.url||""}function h(a){const c=document.getElementById("svc-creds-error");c.textContent=a,c.style.display=""}function n(){const a=document.getElementById("svc-creds-error");a.textContent="",a.style.display="none"}window.openServiceCredsModal=async function(a){l=a,n();const c=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),s=document.getElementById("svc-creds-apikey"),u=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");c.textContent=a.name+" Credentials";const d=!!a.isExternal,f=k.includes(a.id)||k.includes(a.appTemplate),r=v.includes(a.id)||v.includes(a.appTemplate);e.style.display=d?"":"none",s.style.display=f?"":"none",t.style.display=r?"":"none",u.style.display=d?"none":"";const S=document.getElementById("svc-quality-select");S.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 ${a.name}`):f?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await x(a),m.classList.add("show")};async function x(a){const c=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let s=!1;if(a.isExternal){try{const d=await(await fetch(`/api/v1/seedhost-creds?serviceId=${a.id}`)).json();d.success?(document.getElementById("svc-seedhost-user").value=d.username||"",d.hasCredentials&&(s=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const d=await(await fetch(`/api/v1/services/${a.id}/credentials`)).json();d.success&&(d.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",s=!0):document.getElementById("svc-apikey-input").value="",d.hasBasicAuth&&!a.isExternal?(document.getElementById("svc-basic-user").value=d.username||"",s=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const u=a.id||a.appTemplate;if(v.includes(u)&&await p(a),s){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-${a.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(a){const c=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=a.id||a.appTemplate,s=i(a);if(!s){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:s}),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 r=document.createElement("option");r.value=f.id,r.textContent=f.name,c.appendChild(r)}if(d.storedProfileId&&(c.value=String(d.storedProfileId)),!c.value){const f=d.profiles.find(r=>/720/i.test(r.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 a=l.id||l.appTemplate,c=i(l),e=document.getElementById("svc-apikey-input")?.value.trim(),s=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}s.innerHTML='<option value="">Fetching...</option>',u.textContent="";try{const t=new URLSearchParams({service:a,url:c,apiKey:e}),f=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!f.success){s.innerHTML='<option value="">-- Error --</option>',u.innerHTML=`<span style="color: var(--error, #c62828);">${f.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;">
<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>
</div>
</div>
</div>`);async function m(){try{const i=await(await fetch("/api/v1/totp/config")).json();if(!i.success)return;const{enabled:h,sessionDuration:n,isSetUp:x}=i.config,p=document.getElementById("totp-status-dot"),a=document.getElementById("totp-status-text"),c=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),s=document.getElementById("totp-duration-section"),u=document.getElementById("totp-disable-section");h&&x?(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)",a.textContent="TOTP is active",a.style.color="var(--ok-fg, #7ef2ff)",g.style.display="none",e.style.display="none",s.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",a.textContent="TOTP is not configured",a.style.color="var(--muted)",g.style.display="block",e.style.display="none",s.style.display="none",u.style.display="none"),k(h&&x,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 k(v,i){const h=document.getElementById("auth-card"),n=document.getElementById("auth-pill"),x=document.getElementById("auth-dot"),p=document.getElementById("auth-status-text");h&&(v?(h.setAttribute("data-status","on"),n.className="badge on",n.textContent="YES",x.className="dot ok at-bl",p.textContent="Session: "+(l[i]||i)):(h.setAttribute("data-status","off"),n.className="badge off",n.textContent="NO",x.className="dot bad at-bl",p.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const i=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();i.success&&(document.getElementById("totp-qr-image").src=i.qrCode,document.getElementById("totp-manual-key").textContent=i.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(),i=document.getElementById("totp-import-error");if(i.textContent="",!v){i.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?(i.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()):i.textContent=n.error||n.message||"Import failed"}catch{i.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 i=document.getElementById("totp-copy-key");i.textContent="\u2705",setTimeout(()=>{i.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const v=document.getElementById("totp-setup-code").value,i=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(v)){i.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?(i.textContent="",m()):i.textContent=n.error||"Invalid code"}catch{i.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(i){o.logError("[TOTP] Update Session Duration",i,{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>`);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 class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
@@ -263,7 +263,7 @@ const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HE
</div>
<div class="token-status" id="${f}-token-status"></div>
</div>
`)}function k(){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,r=>r.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const v=k();function i(t,d){if(!t)return"";const f=crypto.getRandomValues(new Uint8Array(8)),r=Array.from(f,L=>L.toString(16).padStart(2,"0")).join(""),S=new TextEncoder().encode(d+r);let B="";for(let L=0;L<t.length;L++){const A=t.charCodeAt(L)^S[L%S.length]^f[L%f.length]^L*31+17&255;B+=String.fromCharCode(A)}return r+":"+btoa(B)}function h(t,d){if(!t)return"";try{if(t.indexOf(":")===16){const B=t.substring(0,16),L=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),w=new TextEncoder().encode(d+B);let T="";for(let $=0;$<A.length;$++){const b=A.charCodeAt($)^w[$%w.length]^L[$%L.length]^$*31+17&255;T+=String.fromCharCode(b)}return T}const r=atob(t);let S="";for(let B=0;B<r.length;B++){const L=r.charCodeAt(B)^d.charCodeAt(B%d.length);S+=String.fromCharCode(L)}return S}catch{return""}}function n(t,d,f){const r=safeGet(`${t}-${d}-${f}-enc`);return h(r,v)}function x(t,d,f,r){const S=`${t}-${d}-${f}-enc`;r?safeSet(S,i(r,v)):safeRemove(S)}function p(t,d){return n(t,d,"token")}function a(t,d){return n(t,d,"username")}function c(t,d,f){x(t,d,"token",f)}function g(t,d,f){x(t,d,"username",f)}function e(){const t={};for(const d of o())t[d]={readonly:{username:a(d,"readonly"),token:p(d,"readonly")},admin:{username:a(d,"admin"),token:p(d,"admin")}};return t}function s(){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=a(t,"readonly"),r=p(t,"admin"),S=a(t,"admin"),B=h(safeGet(`${t}-token-enc`),v),L=h(safeGet(`${t}-username-enc`),v);return{username:S||f||L,token:r||d||B,readonlyToken:d||B,readonlyUsername:f||L,adminToken:r||B,adminUsername:S||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{l();const t=document.getElementById("token-management-modal"),d=e();o().forEach(f=>{const r=d[f];document.getElementById(`${f}-readonly-username`).value=r.readonly.username,document.getElementById(`${f}-readonly-token`).value=r.readonly.token,document.getElementById(`${f}-admin-username`).value=r.admin.username,document.getElementById(`${f}-admin-token`).value=r.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,r=document.getElementById(f);r.type==="password"?(r.type="text",d.textContent="\u{1F648}"):(r.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(r=>{g(r,"readonly",document.getElementById(`${r}-readonly-username`).value.trim()),c(r,"readonly",document.getElementById(`${r}-readonly-token`).value.trim()),g(r,"admin",document.getElementById(`${r}-admin-username`).value.trim()),c(r,"admin",document.getElementById(`${r}-admin-token`).value.trim())});const d={};let f=!1;if(t.forEach(r=>{const S={},B=document.getElementById(`${r}-readonly-username`).value.trim(),L=document.getElementById(`${r}-readonly-token`).value.trim(),A=document.getElementById(`${r}-admin-username`).value.trim(),w=document.getElementById(`${r}-admin-token`).value.trim();B&&L&&(S.readonly={username:B,password:L},f=!0),A&&w&&(S.admin={username:A,password:w},f=!0),Object.keys(S).length>0&&(d[r]=S)}),f){t.forEach(r=>{d[r]&&(document.getElementById(`${r}-token-status`).textContent="Verifying...",document.getElementById(`${r}-token-status`).className="token-status")});try{const S=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:d})})).json();S.results?t.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!d[B]){L.textContent="";return}const A=S.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")}):S.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 "+(S.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: ${g.currentVersion}
New: ${g.updateVersion}
@@ -271,9 +271,9 @@ New: ${g.updateVersion}
`+(g.updateTitle?`${g.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){p.textContent=a,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(x)}`,{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?`
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: ${u.downloadLink}`:"",d=u.instructionsLink?`
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=a,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=a,p.disabled=!1,p.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",n=>{const x=n.target.closest('[id$="-update"]');if(!x)return;const p=x.id.replace("-update","");SITE.dnsServers[p]&&m(p,SITE.dnsServers[p]?.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 class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3 id="dns-settings-title">DNS Settings</h3>
@@ -300,7 +300,7 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} upd
<button id="dns-settings-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);let l=null;function k(n){l=n;const x=SITE.dnsServers[n]||{},p=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(x.name||n).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=x.ip||"",document.getElementById("dns-edit-port").value=x.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=x.name||"",p.classList.add("show")}async function v(){if(!l)return;const n=document.getElementById("dns-edit-ip").value.trim(),x=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 a={dnsServers:{}};a.dnsServers[l]={ip:n,port:String(x)},p&&(a.dnsServers[l].name=p);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();g.success?(SITE.dnsServers[l]=a.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 i(){if(l&&confirm(`Remove ${l.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const x=await(await secureFetch("/api/v1/config")).json();x.dnsServers&&delete x.dnsServers[l];const a=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:x.dnsServers||{}})})).json();if(a.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(a.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",i),document.getElementById("dns-settings-modal")?.addEventListener("click",n=>{n.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",n=>{const x=n.target.closest('[id$="-settings"]');if(!x)return;const p=x.id.replace("-settings","");SITE.dnsServers[p]&&(n.stopPropagation(),k(p))}),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 class="logs-modal-content" style="min-width: 800px; max-width: 1000px;">
<div class="logs-header">
@@ -324,13 +324,13 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} upd
</div>
</div>
</div>
</div>`);let o=null,m=null,l=!1,k=null,v=null,i=!1,h=null,n=null,x=!1,p=null,a=!1;async function c(b,y=25){try{const I=getDnsServerAddr(b),E=await fetch(`/api/v1/dns/logs?server=${I}&limit=${y}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const C=await E.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return E.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${E.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function g(b){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"}[b]||"var(--fg)"}function e(b){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;",b.parsed===!1)return y.style.gridTemplateColumns="1fr",y.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(b.raw)}</span>`,y;const I=g(b.rcode),E=b.rcode==="Refused"||b.rcode==="REFUSED";return y.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(b.client)}">${escapeHtml(b.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(b.domain)}">${escapeHtml(b.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.type)}</span>
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(b.rcode)}</span>
`,y}async function s(){if(x){await T();return}if(i){await B();return}if(l||!o)return;const b=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await c(o,b);if(I.error){y.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(x.timestamp)}</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; ${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(x.type)}</span>
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span>
`,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="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(I.error)}</div>
@@ -341,19 +341,19 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} upd
<span>Domain</span>
<span>Type</span>
<span>Status</span>
</div>`,I.logs&&I.logs.length>0?I.logs.forEach(E=>{const C=e(E);y.appendChild(C)}):y.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);">
No DNS queries logged yet
</div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function u(b){o=b,l=!1,i=!1;const y=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),E=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");I.textContent=`${b.toUpperCase()} DNS Logs`,E.textContent="\u23F8\uFE0F Pause",E.classList.remove("paused"),C&&(C.style.display="none"),y.classList.add("show"),s(),m=setInterval(s,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),m&&(clearInterval(m),m=null),f(),o=null,i=!1,k=null,v=null,x=!1,h=null,n=null,l=!1}function d(b){p&&f();const y=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),E=document.getElementById("logs-content");m&&(clearInterval(m),m=null);try{p=new EventSource(`/api/v1/logs/stream/${b}`),a=!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>`}}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: 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(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),a=!1;const b=document.getElementById("logs-stream"),y=document.getElementById("logs-pause"),I=document.getElementById("logs-title");b&&(b.classList.remove("active"),b.textContent="\u{1F4E1} Live",b.title="Enable real-time streaming"),y&&(y.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),i&&k&&!m&&(m=setInterval(B,DC.POLL.LOGS))}async function r(b,y=100){try{const I=`/api/v1/logs/container/${b}?tail=${y}&timestamps=true`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const C=await E.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 ${E.status}: ${E.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function S(b){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=b.stream==="stderr"?"var(--bad-fg)":"var(--fg)",E=b.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: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(b.text)}</div>
`,y}async function B(){if(l||!k||!i)return;const b=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await r(k,b);if(I.error){y.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;">${S}</div>
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
`,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="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(I.error)}</div>
@@ -361,29 +361,29 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} upd
<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: 1;">Log Output</span>
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(E=>{const C=S(E);y.appendChild(C)}),y.scrollTop=y.scrollHeight):y.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);">
No logs available for this container
</div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function L(b,y){k=b,v=y,i=!0,x=!1,l=!1,f();const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.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(b,y=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(b)}&tail=${y}`,E=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(E.ok){const C=await E.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 E.json().catch(()=>({}))).error||`HTTP ${E.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function w(b){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=b.text;let E="INFO",C="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(E="ERROR",C="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(E="WARN",C="#f39c12"):I.match(/DEBUG/i)&&(E="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;">${E}</span>`;return y.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;">${D}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
`,y}async function T(){if(l||!h||!x)return;const b=parseInt(document.getElementById("log-lines").value),y=document.getElementById("logs-content");try{const I=await A(h,b);if(I.error){y.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="font-size: 1.2rem; margin-bottom: 8px;">\u26A0\uFE0F Error</div>
<div>${escapeHtml(I.error)}</div>
</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;">
<span style="flex: 1;">Log Output (${I.count} of ${I.totalLines} lines)</span>
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(E=>{const C=w(E);y.appendChild(C)}),y.scrollTop=y.scrollHeight):y.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);">
No logs available in this file
</div>`}catch(I){y.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(I.message)}
</div>`}}function $(b,y){h=b,n=y,x=!0,i=!1,l=!1;const I=document.getElementById("logs-modal"),E=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");E.textContent=`\u{1F4CB} ${y} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),T(),m=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",b=>{const y=b.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 b=document.getElementById("logs-pause");l?(b.textContent="\u25B6\uFE0F Resume",b.classList.add("paused")):(b.textContent="\u23F8\uFE0F Pause",b.classList.remove("paused"),s())}),document.getElementById("log-lines")?.addEventListener("change",()=>{l||s()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!i||!k||(a?f():d(k))}),document.getElementById("logs-modal")?.addEventListener("click",b=>{b.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",b=>{b.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=u})(),(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 class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3 id="service-edit-title">Edit Service</h3>
@@ -712,44 +712,44 @@ Instructions: ${u.instructionsLink}`:"";showNotification(`${n.toUpperCase()} upd
<button id="add-service-create" class="btn-accent">Create Service</button>
</div>
</div>
</div>`)})(),(function(){async function o(i){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(i)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const n=await h.json();if(n.status==="success"){const x=document.getElementById("existing-ca-select");return x.innerHTML="",n.data.cas.length===0?x.innerHTML='<option value="">No CAs found in Caddyfile</option>':(x.innerHTML='<option value="">Select existing CA...</option>',n.data.cas.forEach(p=>{const a=document.createElement("option");typeof p=="object"?(a.value=p.id,a.textContent=p.displayName||p.name):(a.value=p,a.textContent=p),x.appendChild(a)})),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(i){const{subdomain:h,port:n,ip:x,sslType:p,caName:a,existingCa:c,enableAuth:g,enableCors:e,customHeaders:s,upstreamPath:u,healthCheck:t,timeout:d,tailscaleOnly:f}=i;let r=`${buildDomain(h)} {
`;switch(f&&(r+=` @blocked not remote_ip 100.64.0.0/10
`,r+=` respond @blocked "Access denied. Tailscale connection required." 403
`),p){case"letsencrypt":break;case"caddy-managed":r+=` tls internal
`;break;case"existing-ca":c&&(r+=` tls {
</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(f&&(i+=` @blocked not remote_ip 100.64.0.0/10
`,i+=` respond @blocked "Access denied. Tailscale connection required." 403
`),p){case"letsencrypt":break;case"caddy-managed":i+=` tls internal
`;break;case"existing-ca":c&&(i+=` tls {
ca ${c}
}
`);break;case"custom-ca":a&&(r+=` tls {
ca ${a}
`);break;case"custom-ca":s&&(i+=` tls {
ca ${s}
}
`);break}if(g&&(r+=` basicauth {
`);break}if(g&&(i+=` basicauth {
admin $2a$14$hashed_password_here
}
`),e&&(r+=` header {
`,r+=` Access-Control-Allow-Origin "*"
`,r+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,r+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,r+=` }
`),s)try{const S=JSON.parse(s);r+=` header {
`,Object.entries(S).forEach(([B,L])=>{r+=` ${B} "${L}"
`}),r+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(r+=` health_uri ${t}
`),r+=` reverse_proxy ${x}:${n} {
`,u&&u!=="/"&&(r+=` rewrite ${u}
`),d&&d!==30&&(r+=` transport http {
`,r+=` dial_timeout ${d}s
`,r+=` response_header_timeout ${d}s
`,r+=` }
`),r+=` }
`,r+=`}
`,r}async function l(i,h,n=DC.DEFAULTS.TTL){const x=window.getToken(getPrimaryDnsId(),"admin");if(!x)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const p=buildDomain(i),a=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:p,ip:h,ttl:n,token:x,server:SITE.dnsIp})});if(!a.ok){const g=await a.text();throw new Error(`DNS API Error: ${a.status} - ${g}`)}const c=await a.json();if(!c.success)throw new Error(`DNS Error: ${c.error||"Unknown error"}`);return c}async function k(i){const h={id:i.subdomain,name:i.name,logo:i.logo||`/assets/${i.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 x=await n.json();throw new Error(x.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(i){const h=document.getElementById("service-subdomain-input").value.trim(),n=document.getElementById("service-ip-input").value.trim()||"localhost",x=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}:${x}`,config:i})}),a=await p.json();if(!p.ok||!a.success)throw new Error(a.error||`Caddy API Error: ${p.status}`);return a}window.loadExistingCAs=o,window.generateCaddyConfig=m,window.createDnsRecord=l,window.addServiceToConfig=k,window.addToCaddyfile=v})(),(function(){let o=null;function m(n){o=n;const x=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||"",x.classList.add("show")}function l(){closeModal("service-edit-modal"),o=null}async function k(){if(!o)return;const n=document.getElementById("edit-subdomain").value.trim().toLowerCase(),x=document.getElementById("edit-service-name").value.trim(),p=document.getElementById("edit-port").value.trim(),a=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,s=[];if(n!==e&&s.push("subdomain"),x&&x!==o.name&&s.push("name"),p&&p!==String(o.port)&&s.push("port"),a!==o.ip&&s.push("ip"),c!==(o.tailscaleOnly||!1)&&s.push("tailscale"),g&&g!==o.logo&&s.push("logo"),s.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:x||o.name,port:p||o.port,ip:a,tailscaleOnly:c,logo:g||void 0})})).json();if(!d.success)throw new Error(d.error||"Failed to update service");const f=window.APPS.findIndex(r=>r.id===e);f!==-1&&(window.APPS[f]={...window.APPS[f],id:n,name:x||window.APPS[f].name,port:p||window.APPS[f].port,ip:a,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 x=n.target.files[0];if(!x)return;if(!x.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const p=new FileReader;p.onload=async a=>{const c=a.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(x)}),document.getElementById("service-edit-cancel")?.addEventListener("click",l),document.getElementById("service-edit-save")?.addEventListener("click",k),document.getElementById("service-edit-modal")?.addEventListener("click",n=>{n.target.id==="service-edit-modal"&&l()});function v(n,x,p){return new Promise(a=>{const c=document.getElementById("delete-ser Line truncated
`),e&&(i+=` header {
`,i+=` Access-Control-Allow-Origin "*"
`,i+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,i+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,i+=` }
`),a)try{const k=JSON.parse(a);i+=` header {
`,Object.entries(k).forEach(([B,T])=>{i+=` ${B} "${T}"
`}),i+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(i+=` health_uri ${t}
`),i+=` reverse_proxy ${b}:${n} {
`,u&&u!=="/"&&(i+=` rewrite ${u}
`),d&&d!==30&&(i+=` transport http {
`,i+=` dial_timeout ${d}s
`,i+=` response_header_timeout ${d}s
`,i+=` }
`),i+=` }
`,i+=`}
`,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:
1. Pull the latest image
2. Stop the container
3. Recreate with same settings
The service will be briefly unavailable.`))try{a&&(a.textContent="\u{1F504}",a.disabled=!0,a.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${n}/update`,{method:"POST"})).json();if(e.success){const s=window.APPS.find(u=>u.id===p);s&&e.newContainerId&&(s.containerId=e.newContainerId),a&&(a.textContent="\u2705",a.title="Updated successfully!",setTimeout(()=>{a.textContent=c,a.disabled=!1,a.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${x} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),a&&(a.textContent="\u274C",a.title="Update failed",setTimeout(()=>{a.textContent=c,a.disabled=!1,a.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${x}: ${g.message}`,"error")}}async function h(n,x){const p=window.APPS.find(r=>r.id===n),a=p?buildDomain(p.id):null,c=p?.containerId,g=await v(x||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 r=new URLSearchParams({containerId:p.containerId,subdomain:p.id,ip:p.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(p.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(g&&a){try{const r=p?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(a)}&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 S=await(await secureFetch(`/api/v1/site/${encodeURIComponent(a)}`,{method:"DELETE"})).json();e.caddy=S.success||S.error&&S.error.includes("not found")?"removed":S.error||"failed"}catch(r){e.caddy=r.message}}const s=window.APPS.findIndex(r=>r.id===n);s>-1&&(window.APPS.splice(s,1),e.dashboard=!0);try{const r=safeGetJSON("custom-apps",[]),S=r.findIndex(B=>B.id===n);S>-1&&(r.splice(S,1),safeSet("custom-apps",JSON.stringify(r)))}catch{}try{const S=await(await secureFetch(`/api/v1/services/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.service=S.success?"removed":S.error||"failed"}catch(r){e.service=r.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=r=>!r||d.some(S=>r.toLowerCase().includes(S.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 "${x||n}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=m,window.showDeleteModal=v,window.updateContainer=i,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",s=document.getElementById("service-ip-input").value||k.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,r=document.getElementById("enable-auth").checked,S=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,w=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 b={subdomain:e,port:u,ip:s,sslType:t,caName:d,existingCa:f,enableAuth:r,enableCors:S,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:w},y=window.generateCaddyConfig(b),I=document.getElementById("caddy-config-preview");I&&(I.value=y)}const k={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&&(k.lan=d.lan),d.tailscale&&(k.tailscale=d.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),s=document.getElementById("quick-ip-tailscale");e&&(k.lan?(e.dataset.ip=k.lan,e.textContent=`LAN (${k.lan})`,e.title=`LAN IP: ${k.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: var(--muted); margin-left: 6px;">${S.self?.hostname} (${S.self?.ip})</span>
`,f.disabled=!1):S.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"),s=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 r(){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",r),s?.addEventListener("change",r)}function x(){const e=document.getElementById("service-name-input"),s=document.getElementById("service-subdomain-input"),u=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=o(e.value);!t&&s&&(s.value=L),u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),l()}),s?.addEventListener("input",()=>{t=s.value!==o(e?.value||"");const L=s.value.trim()||o(e?.value||"");u&&(u.textContent=L?`\u2192 ${buildDomain(L)}`:""),l()});const d=document.getElementById("external-service-name"),f=document.getElementById("external-service-subdomain"),r=document.getElementById("external-subdomain-preview"),S=document.getElementById("external-domain-preview");let B=!1;d?.addEventListener("input",()=>{const L=o(d.value);!B&&f&&(f.value=L);const A=f?.value||L;r&&(r.textContent=A?`\u2192 ${buildDomain(A)}`:""),S&&(S.textContent=A?buildDomain(A):"")}),f?.addEventListener("input",()=>{B=f.value!==o(d?.value||"");const L=f.value.trim()||o(d?.value||"");r&&(r.textContent=L?`\u2192 ${buildDomain(L)}`:""),S&&(S.textContent=L?buildDomain(L):"")})}async function p(){const e=document.getElementById("external-service-name").value.trim(),s=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,r=document.getElementById("external-create-caddy").checked,S=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(!u){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(u);try{const w={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:S,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();w.dns=C.success?"created":C.error||"failed"}catch(E){w.dns=E.message}else w.dns="no admin token (configure in \u{1F511} Tokens)";if(r)try{const I={subdomain:u,externalUrl:s,preserveHost:B,followRedirects:L,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();w.caddy=C.success?"created":C.error||"failed"}catch(I){w.caddy=I.message}const T={id:u,name:e,url:`https://${A}`,externalUrl:s,logo:t||d||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),w.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],b=window.APPS.filter(I=>!$.includes(I.id));safeSet("custom-services",JSON.stringify(b));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(),a();const y=[`External service "${e}" added!`];f&&y.push(`DNS: ${w.dns==="created"?"\u2713":"\u26A0 "+w.dns}`),r&&y.push(`Caddy: ${w.caddy==="created"?"\u2713":"\u26A0 "+w.caddy}`),y.push(`Access at: https://${A}`),showNotification(y.join(" | "),"success",6e3)}catch(w){console.error("Failed to create external service:",w),showNotification(`Failed to create external service: ${w.message}`,"error")}}function a(){closeModal("a Line truncated
<span style="color: var(--muted); margin-left: 6px;">${k.self?.hostname} (${k.self?.ip})</span>
`,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