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:
7 files changed
+642
-186
No files matched your search
@@ -25,6 +25,8 @@ const bundles = {
|
||||
JS('core', 'service-crud.js'),
|
||||
JS('core', 'service-create.js'),
|
||||
JS('live-events.js'),
|
||||
JS('service-filter.js'),
|
||||
JS('batch-operations.js'),
|
||||
],
|
||||
'features.js': [
|
||||
JS('logo-customization.js'),
|
||||
@@ -34,6 +36,7 @@ const bundles = {
|
||||
JS('import-export.js'),
|
||||
JS('error-logs.js'),
|
||||
JS('container-logs.js'),
|
||||
JS('snapshot.js'),
|
||||
JS('smart-arr-connect.js'),
|
||||
JS('notification-settings.js'),
|
||||
JS('panel-tabs.js'),
|
||||
|
||||
Vendored
+62
-62
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}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}×tamps=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}×tamps=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
|
||||
Vendored
+178
-124
@@ -90,19 +90,19 @@
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),L=document.getElementById("logo-same-both"),A=document.getElementById("logo-dual-uploads"),T=document.getElementById("logo-single-upload"),$=document.getElementById("logo-upload-dark"),y=document.getElementById("logo-upload-light"),S=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),B=document.querySelector(".top-row"),M=document.getElementById("dashboard-title"),R=DC.NAME;let P=null,j=null,C=null,D="left",b=R;L?.addEventListener("change",()=>{L.checked?(A.style.display="none",T.style.display="",P=null,j=null):(A.style.display="flex",T.style.display="none",C=null)});function H(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}$?.addEventListener("change",a=>{H(a.target.files[0],e=>{P=e,E.src=e,I.textContent="New dark logo ready to save"})}),y?.addEventListener("change",a=>{H(a.target.files[0],e=>{j=e,N.src=e,I.textContent="New light logo ready to save"})}),S?.addEventListener("change",a=>{H(a.target.files[0],e=>{C=e,E.src=e,N.src=e,I.textContent="New logo ready to save (both themes)"})});function f(a){B.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){b=a||R,document.title=b;const e=document.querySelector(".dashboard-title");e&&(e.textContent=b)}async function g(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(D=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{D=a.dataset.pos,f(D)})}),document.getElementById("brand")?.addEventListener("click",()=>{P=null,j=null,C=null,$&&($.value=""),y&&(y.value=""),S&&(S.value=""),L&&(L.checked=!1),A.style.display="flex",T.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");I.textContent=a?"Using custom logo":"Using default logos",f(D),M.value=b,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=M.value.trim()||R,e={position:D,dashboardTitle:a};L?.checked&&C?(e.dataDark=C,e.dataLight=C):(P&&(e.dataDark=P),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(D),p(a),w.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
</div>`);const b=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),T=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),L=document.getElementById("logo-single-upload"),H=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),I=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),$=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),R=DC.NAME;let M=null,j=null,B=null,A="left",w=R;T?.addEventListener("change",()=>{T.checked?(P.style.display="none",L.style.display="",M=null,j=null):(P.style.display="flex",L.style.display="none",B=null)});function z(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}H?.addEventListener("change",a=>{z(a.target.files[0],e=>{M=e,E.src=e,S.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{z(a.target.files[0],e=>{j=e,N.src=e,S.textContent="New light logo ready to save"})}),I?.addEventListener("change",a=>{z(a.target.files[0],e=>{B=e,E.src=e,N.src=e,S.textContent="New logo ready to save (both themes)"})});function f(a){$.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){w=a||R,document.title=w;const e=document.querySelector(".dashboard-title");e&&(e.textContent=w)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(A=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{A=a.dataset.pos,f(A)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,j=null,B=null,H&&(H.value=""),g&&(g.value=""),I&&(I.value=""),T&&(T.checked=!1),P.style.display="flex",L.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");S.textContent=a?"Using custom logo":"Using default logos",f(A),C.value=w,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=C.value.trim()||R,e={position:A,dashboardTitle:a};T?.checked&&B?(e.dataDark=B,e.dataLight=B):(M&&(e.dataDark=M),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(A),p(a),b.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",P=null,j=null,C=null,M.value=R,p(R),D="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,u.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),u.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),g();const m=document.getElementById("settings-timezone");m&&(new MutationObserver(()=>{w.classList.contains("show")&&m.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(m,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=m.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})();const errorHandler=new ErrorHandler;window.populateTimezoneSelect=function(w,E){const N=Intl.supportedValuesOf("timeZone"),I=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const L of N){const A=document.createElement("option");A.value=L,A.textContent=L.replace(/_/g," "),L===I&&(A.selected=!0),w.appendChild(A)}},(function(){let w="homelab",E=null;async function N(){try{const H=await fetch("/api/v1/config");if(H.ok&&(E=await H.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(H){console.warn("Could not fetch server config, checking localStorage fallback:",H.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function L(b){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const H=document.getElementById(b);H&&(H.style.display="block")}function A(){const b=document.getElementById("setup-summary-content");if(!b)return;let H='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",g=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",u=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;H+=`
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",M=null,j=null,B=null,C.value=R,p(R),A="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),m=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),m.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,m.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),m.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),y();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{b.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})();const errorHandler=new ErrorHandler;window.populateTimezoneSelect=function(b,E){const N=Intl.supportedValuesOf("timeZone"),S=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const T of N){const P=document.createElement("option");P.value=T,P.textContent=T.replace(/_/g," "),T===S&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",E=null;async function N(){try{const z=await fetch("/api/v1/config");if(z.ok&&(E=await z.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(z){console.warn("Could not fetch server config, checking localStorage fallback:",z.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function T(w){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const z=document.getElementById(w);z&&(z.style.display="block")}function P(){const w=document.getElementById("setup-summary-content");if(!w)return;let z='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",m=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;z+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>TLD:</strong> ${p}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${g}</div>
|
||||
<div><strong>DNS Server:</strong> ${v}:${u}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${y}</div>
|
||||
<div><strong>DNS Server:</strong> ${v}:${m}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${p}, https://nextcloud${p}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(w==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";H+=`
|
||||
`}else if(b==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";z+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
@@ -112,22 +112,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
<div><strong>Example URLs:</strong> http://${p}:8080, http://${p}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(w==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;H+=`
|
||||
`}else if(b==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;z+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Domain:</strong> ${p}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${g}</div>
|
||||
<div><strong>Email:</strong> ${y}</div>
|
||||
<div><strong>Routing:</strong> ${v==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${u}</div>
|
||||
<div><strong>Example URLs:</strong> ${m}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";H+=`
|
||||
`}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";z+=`
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,H+="</div>",b.innerHTML=H,L("setup-step-summary")}async function T(b){try{const H=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)});return H.ok?(await H.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${H.status}`),{function:"saveConfigToServer"}),!1)}catch(H){return errorHandler.logError("[SetupWizard] Save Config",H,{function:"saveConfigToServer"}),!1}}async function $(){const b={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};w==="homelab"?(b.tld=document.getElementById("setup-tld")?.value?.trim()||".home",b.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",b.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},b.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):w==="simple"?(b.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",b.defaults={dnsType:"none",sslType:"none",targetIP:b.defaultIP}):w==="public"&&(b.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",b.email=document.getElementById("setup-public-email")?.value?.trim()||"",b.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",b.defaults={dnsType:b.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const H=await T(b);safeSet("dashcaddy-config",JSON.stringify(b)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",p=H?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const y=document.getElementById("setup-step-1-next");y&&(y.onclick=function(b){b.preventDefault();const H=document.querySelector('input[name="config-type"]:checked');H&&(w=H.value),L(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const S=document.getElementById("setup-skip");S&&(S.onclick=async function(b){b.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await T({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(b){const H=b.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=H),p&&(p.textContent=H)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(b){b.preventDefault(),L("setup-step-1")});const B=document.getElementById("setup-homelab-next");B&&(B.onclick=function(b){b.preventDefault();const H=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!H||!H.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}A()});const M=document.getElementById("setup-simple-back");M&&(M.onclick=function(b){b.preventDefault(),L("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(b){b.preventDefault(),A()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(b){b.onchange=function(){var H=document.getElementById("dns-requirement-note");H&&(H.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const P=document.getElementById("setup-public-back");P&&(P.onclick=function(b){b.preventDefault(),L("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(b){b.preventDefault();const H=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!H){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}A()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(b){b.preventDefault(),w==="homelab"?L("setup-step-homelab"):w==="simple"?L("setup-step-simple"):w==="public"&&L("setup-step-pub
Line truncated
|
||||
`,z+="</div>",w.innerHTML=z,T("setup-step-summary")}async function L(w){try{const z=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(w)});return z.ok?(await z.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${z.status}`),{function:"saveConfigToServer"}),!1)}catch(z){return errorHandler.logError("[SetupWizard] Save Config",z,{function:"saveConfigToServer"}),!1}}async function H(){const w={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};b==="homelab"?(w.tld=document.getElementById("setup-tld")?.value?.trim()||".home",w.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",w.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},w.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):b==="simple"?(w.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",w.defaults={dnsType:"none",sslType:"none",targetIP:w.defaultIP}):b==="public"&&(w.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",w.email=document.getElementById("setup-public-email")?.value?.trim()||"",w.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",w.defaults={dnsType:w.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const z=await L(w);safeSet("dashcaddy-config",JSON.stringify(w)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",p=z?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(w){w.preventDefault();const z=document.querySelector('input[name="config-type"]:checked');z&&(b=z.value),T(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const I=document.getElementById("setup-skip");I&&(I.onclick=async function(w){w.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await L({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(w){const z=w.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=z),p&&(p.textContent=z)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(w){w.preventDefault(),T("setup-step-1")});const $=document.getElementById("setup-homelab-next");$&&($.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!z||!z.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}P()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(w){w.preventDefault(),T("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(w){w.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(w){w.onchange=function(){var z=document.getElementById("dns-requirement-note");z&&(z.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(w){w.preventDefault(),T("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!z){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const B=document.getElementById("setup-summary-back");B&&(B.onclick=function(w){w.preventDefault(),b==="homelab"?T("setup-step-homelab"):b==="simple"?T("setup-step-simple"):b==="public"&&T("setup-step-pub
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const E="custom-apps";let N=null,I=null;const L=document.getElementById("app-selector-modal"),A=document.getElementById("app-selector-grid");async function T(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,I=c.categories,!0}catch(r){w.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function $(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return w.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function y(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){w.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function S(){if(A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await T()){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}A.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const m=h.category||"Other";r[m]||(r[m]=[]),r[m].push({id:s,...h})}const c=I?Object.keys(I):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const m=document.createElement("div");m.className="app-category-header";const a=I?.[s]||{};m.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(m.style.borderBottomColor=a.color),A.appendChild(m),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
||||
</div>`);const E="custom-apps";let N=null,S=null;const T=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function L(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,S=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function H(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function g(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function I(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await L()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const u=h.category||"Other";r[u]||(r[u]=[]),r[u].push({id:s,...h})}const c=S?Object.keys(S):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const a=S?.[s]||{};u.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(u.style.borderBottomColor=a.color),P.appendChild(u),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
||||
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
||||
${o}${d}
|
||||
`,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),A.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(A)}function k(r,c){const s="widget-"+r.id+"-enabled",m=!(safeGet(s)!=="false");safeSet(s,String(m));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=m?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=m?"ON":"OFF",e.style.background=m?"#2ecc7130":"#e74c3c30",e.style.color=m?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${m?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),m=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.name} container:
|
||||
`,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),P.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function k(r,c){const s="widget-"+r.id+"-enabled",u=!(safeGet(s)!=="false");safeSet(s,String(u));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=u?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.name} container:
|
||||
|
||||
Container: ${V.name}
|
||||
Status: ${V.status}
|
||||
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
|
||||
Would you like to use this existing container?
|
||||
|
||||
Click OK to configure DNS/Caddy for the existing container.
|
||||
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),z=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);z?z.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),J=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=W.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),D=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);D?D.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),J=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=W.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
||||
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
||||
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
||||
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await $(_);if(J.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await y(se);Y.innerHTML=`
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await H(_);if(J.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(J.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?t.innerHTML=`
|
||||
<span style="color: #4caf50;">Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span>
|
||||
`:J.installed?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.value||"subdomain",J=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}m.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),L.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function B(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,m=s.find(a=>a.id===r.subdomain);if(!(m&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(m){const a=s.indexOf(m);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await $(a);if(!e.available){const n=await y(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
`:J.installed?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.value||"subdomain",J=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}u.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),T.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function $(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,u=s.find(a=>a.id===r.subdomain);if(!(u&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(u){const a=s.indexOf(u);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await H(a);if(!e.available){const n=await g(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
|
||||
Would you like to use port ${n} instead?`))r.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(h?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const a={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};h&&(a.config.useExisting=!0,a.config.existingContainerId=c._existingContainer.id,a.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(a.config.port=c._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();if(n.success){const t={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:n.containerId,url:n.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};s.push(t),safeSet(E,JSON.stringify(s)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(t),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=n.usedExisting?`${c.name} configured with existing container!
|
||||
URL: ${n.url}`:`${c.name} deployed successfully!
|
||||
URL: ${n.url}`;n.warning&&(i+=`
|
||||
|
||||
\u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&M(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){w.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function M(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,m=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(m,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(m,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{S(),L.classList.add("show")}),wireModal(L,document.getElementById("app-selector-cancel"));const P=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{P.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(P.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(m=>{s.push({hostPath:m.value.trim(),containerPath:m.dataset.containerPath})});const h={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}P.classList.remove("show"),B(h)}),wireModal(P);const j=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),D=document.getElementById("folder-browser-list"),b=document.getElementById("folder-browser-selected"),H=document.getElementById("folder-browser-selected-list");let f="",p=[],g=null;window.openFolderBrowser=function(r){g=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",u(),v(""),j.classList.add("show")};async function v(r){C.textContent=r||"Select a drive...",D.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!s.success){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",C.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<div class="folder-item" data-path="${escapeHtml(s.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
\u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&C(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){b.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function C(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,u=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{I(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(M.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{s.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const h={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),$(h)}),wireModal(M);const j=document.getElementById("folder-browser-modal"),B=document.getElementById("folder-browser-path"),A=document.getElementById("folder-browser-list"),w=document.getElementById("folder-browser-selected"),z=document.getElementById("folder-browser-selected-list");let f="",p=[],y=null;window.openFolderBrowser=function(r){y=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",m(),v(""),j.classList.add("show")};async function v(r){B.textContent=r||"Select a drive...",A.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!s.success){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",B.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<div class="folder-item" data-path="${escapeHtml(s.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
||||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||||
</div>`),s.items.length===0&&!s.parent?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(m=>{const a=m.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(m.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(m.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
||||
</div>`),s.items.length===0&&!s.parent?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(u=>{const a=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(u.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
||||
<span style="font-size: 1.2rem;">${a}</span>
|
||||
<span style="flex: 1;">${escapeHtml(m.name)}</span>
|
||||
<span style="flex: 1;">${escapeHtml(u.name)}</span>
|
||||
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
||||
</div>`}),D.innerHTML=h,D.querySelectorAll(".folder-item").forEach(m=>{m.addEventListener("click",()=>{v(m.dataset.path)}),m.addEventListener("mouseenter",()=>{m.style.background="var(--card-bg)"}),m.addEventListener("mouseleave",()=>{const a=p.includes(m.dataset.path);m.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){D.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function u(){if(p.length===0){b.style.display="none";return}b.style.display="block",H.innerHTML=p.map(r=>`
|
||||
</div>`}),A.innerHTML=h,A.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{v(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const a=p.includes(u.dataset.path);u.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function m(){if(p.length===0){w.style.display="none";return}w.style.display="block",z.innerHTML=p.map(r=>`
|
||||
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
||||
${escapeHtml(r)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
</span>
|
||||
`).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),u(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),u(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{g&&(g.value=p.join(", ")),j.classList.remove("show")}),R()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
`).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),m(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),m(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{y&&(y.value=p.join(", ")),j.classList.remove("show")}),R()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
||||
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
||||
|
||||
@@ -445,31 +445,31 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let w=null,E=null,N=null,I=1,L=!1;const A=document.getElementById("recipe-deploy-modal"),T=document.getElementById("recipe-cancel"),$=document.getElementById("recipe-prev"),y=document.getElementById("recipe-next");wireModal(A,T);async function S(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return w=p.templates,E=p.categories,!0;if(f.status===403)return L=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{L=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{L=!1}return L}window.renderRecipeCards=async function(f){await k();let p;if(L&&w?p=w:p=x(),!p||p.length===0)return;const g=document.createElement("div");g.className="app-category-header",g.innerHTML="\u{1F9EA} Recipes",g.style.borderBottomColor="#8e44ad",f.appendChild(g);const v=Array.isArray(p)?p:Object.values(p);v.sort((u,r)=>(r.popularity||0)-(u.popularity||0));for(const u of v){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${u.componentCount||u.components?.length||"?"} apps</div>`,s=L?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
|
||||
</div>`);let b=null,E=null,N=null,S=1,T=!1;const P=document.getElementById("recipe-deploy-modal"),L=document.getElementById("recipe-cancel"),H=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,L);async function I(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return b=p.templates,E=p.categories,!0;if(f.status===403)return T=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{T=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{T=!1}return T}window.renderRecipeCards=async function(f){await k();let p;if(T&&b?p=b:p=x(),!p||p.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",f.appendChild(y);const v=Array.isArray(p)?p:Object.values(p);v.sort((m,r)=>(r.popularity||0)-(m.popularity||0));for(const m of v){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${m.componentCount||m.components?.length||"?"} apps</div>`,s=T?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
|
||||
${s}
|
||||
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(u.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
|
||||
<div class="app-option-icon">${escapeHtml(m.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(m.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(m.description||"")}</div>
|
||||
${c}
|
||||
`,r.onclick=()=>{if(!L){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}B(u)},f.appendChild(r)}};function x(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function B(f){N=f,I=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,M(),R(),A.classList.add("show")}function M(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===I),f.classList.toggle("completed",p<I)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===I?"":"none")}$.style.display=I>1&&I<4?"":"none",I===4?(y.style.display="none",T.textContent="Close"):I===3?(y.textContent="\u{1F680} Deploy",y.style.display="",T.textContent="Cancel"):(y.textContent="Next",y.style.display="",T.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const g of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=g.required,r=g.internal;v.innerHTML=`
|
||||
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${g.id}"
|
||||
`,r.onclick=()=>{if(!T){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}$(m)},f.appendChild(r)}};function x(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function $(f){N=f,S=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,C(),R(),P.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===S),f.classList.toggle("completed",p<S)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===S?"":"none")}H.style.display=S>1&&S<4?"":"none",S===4?(g.style.display="none",L.textContent="Close"):S===3?(g.textContent="\u{1F680} Deploy",g.style.display="",L.textContent="Cancel"):(g.textContent="Next",g.style.display="",L.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const y of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const m=y.required,r=y.internal;v.innerHTML=`
|
||||
<input type="checkbox" ${m?"checked disabled":"checked"} data-component-id="${y.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(g.role||g.id)}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">
|
||||
${g.templateRef?escapeHtml(g.templateRef):"Built-in"}
|
||||
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${y.templateRef?escapeHtml(y.templateRef):"Built-in"}
|
||||
${m?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
</div>
|
||||
${g.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(g.note)}</div>`:""}
|
||||
${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""}
|
||||
</div>
|
||||
`,f.appendChild(v)}}function P(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),g=N.sharedVolumes;if(g&&Object.keys(g).length>0){f.style.display="",p.innerHTML="";for(const[v,u]of Object.entries(g)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||v)}</label>
|
||||
<input type="text" data-volume-key="${v}" value="${escapeHtml(u.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(u.defaultPath||"/path")}"
|
||||
`,f.appendChild(v)}}function M(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),y=N.sharedVolumes;if(y&&Object.keys(y).length>0){f.style.display="",p.innerHTML="";for(const[v,m]of Object.entries(y)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(m.label||v)}</label>
|
||||
<input type="text" data-volume-key="${v}" value="${escapeHtml(m.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(m.defaultPath||"/path")}"
|
||||
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
||||
<div class="form-hint-sm">${escapeHtml(u.description||"")}</div>
|
||||
`,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=C(),g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};g.forEach(s=>{v[s.dataset.volumeKey]=s.value});const u=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
|
||||
<div class="form-hint-sm">${escapeHtml(m.description||"")}</div>
|
||||
`,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=B(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};y.forEach(s=>{v[s.dataset.volumeKey]=s.value});const m=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(N.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(N.description||"")}</div>
|
||||
|
||||
@@ -488,15 +488,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
</div>`:""}
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
Timezone: ${escapeHtml(u)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
||||
Timezone: ${escapeHtml(m)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
||||
</div>
|
||||
|
||||
${N.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(N.network.name)}</code></div>`:""}
|
||||
`}function C(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const g=N.components||[];return g.filter(v=>v.required).forEach(v=>p.add(v.id)),g.filter(v=>p.has(v.id))}async function D(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const g=C();for(const c of g){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",s.innerHTML=`
|
||||
`}function B(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const y=N.components||[];return y.filter(v=>v.required).forEach(v=>p.add(v.id)),y.filter(v=>p.has(v.id))}async function A(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const y=B();for(const c of y){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",s.innerHTML=`
|
||||
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
||||
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
||||
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
||||
`,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};v.forEach(c=>{u[c.dataset.volumeKey]=c.value});const r={selectedComponents:g.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:u},componentOverrides:{}};for(const c of g)b(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])b(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])b(h.componentId,"error",h.error);p.style.display="",p.innerHTML=`
|
||||
`,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};v.forEach(c=>{m[c.dataset.volumeKey]=c.value});const r={selectedComponents:y.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:m},componentOverrides:{}};for(const c of y)w(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])w(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])w(h.componentId,"error",h.error);p.style.display="",p.innerHTML=`
|
||||
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(s.message||"Deployed!")}</div>
|
||||
${s.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
@@ -508,7 +508,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(s.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${s.error}`,"error",5e3)}catch(c){p.style.display="",p.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
||||
</div>`}}function b(f,p,g){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const u=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(u.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(u.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(u.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=g}y.addEventListener("click",()=>{if(I===3){I=4,M(),D();return}I<3&&(I++,M(),I===2&&P(),I===3&&j())}),$.addEventListener("click",()=>{I>1&&I<4&&(I--,M())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(g=>{const v=g.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(g)});for(const[g,v]of Object.entries(p))v.length<2||v.forEach((u,r)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=u.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=g.replace(/-/g," "),u.style.position="relative",u.appendChild(c))}})},window.manageRecipe=async function(f,p){const g=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",u=p==="remove"?`/api/v1/recipes/${f}`:g;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(u,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const H=document.createElement("style");H.textContent=`
|
||||
</div>`}}function w(f,p,y){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const m=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(m.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(m.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(m.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=y}g.addEventListener("click",()=>{if(S===3){S=4,C(),A();return}S<3&&(S++,C(),S===2&&M(),S===3&&j())}),H.addEventListener("click",()=>{S>1&&S<4&&(S--,C())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(y=>{const v=y.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(y)});for(const[y,v]of Object.entries(p))v.length<2||v.forEach((m,r)=>{if(m.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=m.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=y.replace(/-/g," "),m.style.position="relative",m.appendChild(c))}})},window.manageRecipe=async function(f,p){const y=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",m=p==="remove"?`/api/v1/recipes/${f}`:y;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(m,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const z=document.createElement("style");z.textContent=`
|
||||
.recipe-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
.recipe-step-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
`,document.head.appendChild(H),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),E=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await N.json();if(N.ok&&I.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=E,w.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(N){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{w.textContent=E,w.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const w=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),L=document.getElementById("error-log-clear"),A=document.getElementById("error-log-close");async function T(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const S=await(await fetch("/api/v1/error-logs")).json();S.success&&S.logs?S.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=S.logs.map(k=>`
|
||||
`,document.head.appendChild(z),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),E=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await N.json();if(N.ok&&S.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(N){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const b=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),S=document.getElementById("error-log-refresh"),T=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function L(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const I=await(await fetch("/api/v1/error-logs")).json();I.success&&I.logs?I.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=I.logs.map(k=>`
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${new Date(k.timestamp).toLocaleString()}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
@@ -559,7 +559,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
${k.details?`<br><small style="opacity: 0.7;">${escapeHtml(k.details)}</small>`:""}
|
||||
</div>
|
||||
</div>
|
||||
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(y){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(y.message)}</div>`}}async function $(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),T()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(y){showNotification(`\u274C Error: ${y.message}`,"error",3e3)}}N?.addEventListener("click",()=>{w.classList.add("show"),T()}),I?.addEventListener("click",T),L?.addEventListener("click",$),wireModal(w,A)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function H(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),L()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}N?.addEventListener("click",()=>{b.classList.add("show"),L()}),S?.addEventListener("click",L),T?.addEventListener("click",H),wireModal(b,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
||||
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
||||
<div>
|
||||
@@ -609,14 +609,68 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),I=document.getElementById("cl-log-search"),L=document.getElementById("cl-log-tail"),A=document.getElementById("cl-refresh"),T=document.getElementById("cl-stream"),$=document.getElementById("cl-download"),y=document.getElementById("cl-clear-search"),S=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),B=document.getElementById("cl-stream-indicator"),M=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),P=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),C=document.getElementById("cl-status"),D=document.getElementById("cl-created");let b=null,H=[],f=[],p=null,g=!1,v=null;function u(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const z=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${z}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
</div>`);const b=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),T=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),L=document.getElementById("cl-stream"),H=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),I=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),$=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),B=document.getElementById("cl-status"),A=document.getElementById("cl-created");let w=null,z=[],f=[],p=null,y=!1,v=null;function m(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const D=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${D}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${d.stream==="stderr"?"#f59e0b":"#22c55e"};">${O}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(d.text)}</span>
|
||||
</div>
|
||||
`}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",P.textContent="0 filtered";return}if(H=d,f=l?d.filter(z=>z.text&&z.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,P.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((z,O)=>c(z,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],z=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),z&&E.querySelector(`option[value="${z}"]`)&&(E.value=z,m(z))}catch(d){console.error("Failed to load containers:",d)}}function m(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",C.textContent=l.dataset.status||"-",C.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",D.textContent=u(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),b=d,m(d);const l=L.value,z=I.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,z):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",P.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),g=!0,T.textContent="\u23F9 Stop",x.style.display="flex",B.textContent="\u{1F7E2}",M.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{B.textContent="\u{1F7E2}",M.textContent="Connected - streaming logs"},p.onmessage=z=>{try{const O=JSON.parse(z.data);if(O.error){B.textContent="\u{1F534}",M.textContent=`Error: ${O.error}`;return}H.push(O),f.push(O),R.textContent=`${H.length} lines`,P.textContent=`${f.length} shown`;const F=I.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{B.textContent="\u{1F534}",M.textContent="Disconnected",g=!1,T.textContent="\u25B6 Stream"},w._eventSource=p}function n(){p&&(p.close(),p=null),w._eventSource&&(w._eventSource.close(),w._eventSource=null),g=!1,T.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!H||H.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${b}"]`)?.dataset.name||b,l=new Date().toISOString().replace(/[:.]/g,"-"),z=`${d}-logs-${l}.txt`,O=H.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
|
||||
`),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=z,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${H.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),L?.addEventListener("change",()=>{a()}),A?.addEventListener("click",()=>{a()}),T?.addEventListener("click",()=>{g?n():e()}),$?.addEventListener("click",()=>{t()}),y?.addEventListener("click",()=>{I.value="",s(H,"")}),I?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(H,I.value.trim())},300)}),I?.addEventListener("keydown",d=>{d.key==="Escape"&&(I.value="",s(H,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{w.classList.add("show"),h()});function o(){n(),w.classList.remove("show")}S?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&w.classList.contains("show")&&o()}),wireModal(w,null,o),window.openContainerLogsModal=function(d,l){w.classList.add("show"),h().then(()=>{const z=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);z?(E.value=z.value,m(z.value),a()):d?(b=d,j.textContent=l||d,C.textContent="-",D.textContent="-",a()):N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
`}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",M.textContent="0 filtered";return}if(z=d,f=l?d.filter(D=>D.text&&D.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,M.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((D,O)=>c(D,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],D=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),D&&E.querySelector(`option[value="${D}"]`)&&(E.value=D,u(D))}catch(d){console.error("Failed to load containers:",d)}}function u(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",B.textContent=l.dataset.status||"-",B.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",A.textContent=m(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),w=d,u(d);const l=T.value,D=S.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,D):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",M.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),y=!0,L.textContent="\u23F9 Stop",x.style.display="flex",$.textContent="\u{1F7E2}",C.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{$.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},p.onmessage=D=>{try{const O=JSON.parse(D.data);if(O.error){$.textContent="\u{1F534}",C.textContent=`Error: ${O.error}`;return}z.push(O),f.push(O),R.textContent=`${z.length} lines`,M.textContent=`${f.length} shown`;const F=S.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{$.textContent="\u{1F534}",C.textContent="Disconnected",y=!1,L.textContent="\u25B6 Stream"},b._eventSource=p}function n(){p&&(p.close(),p=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),y=!1,L.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!z||z.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${w}"]`)?.dataset.name||w,l=new Date().toISOString().replace(/[:.]/g,"-"),D=`${d}-logs-${l}.txt`,O=z.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
|
||||
`),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=D,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${z.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),T?.addEventListener("change",()=>{a()}),P?.addEventListener("click",()=>{a()}),L?.addEventListener("click",()=>{y?n():e()}),H?.addEventListener("click",()=>{t()}),g?.addEventListener("click",()=>{S.value="",s(z,"")}),S?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(z,S.value.trim())},300)}),S?.addEventListener("keydown",d=>{d.key==="Escape"&&(S.value="",s(z,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),h()});function o(){n(),b.classList.remove("show")}I?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&b.classList.contains("show")&&o()}),wireModal(b,null,o),window.openContainerLogsModal=function(d,l){b.classList.add("show"),h().then(()=>{const D=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);D?(E.value=D.value,u(D.value),a()):d?(w=d,j.textContent=l||d,B.textContent="-",A.textContent="-",a()):N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
||||
<h3>\u{1F4BE} Container Snapshots</h3>
|
||||
<p class="modal-subtitle">
|
||||
Create and manage Docker container checkpoints for instant state recovery.
|
||||
</p>
|
||||
|
||||
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
|
||||
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
|
||||
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="">-- Select a container --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
|
||||
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
|
||||
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
|
||||
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
|
||||
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs" style="margin-bottom: 12px;">
|
||||
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
|
||||
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-create" class="panel-section active">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
|
||||
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
|
||||
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label class="checkbox-label" style="font-size: 0.85rem;">
|
||||
<input type="checkbox" id="snapshot-leave-running" checked />
|
||||
Leave container running after checkpoint (resume without restart)
|
||||
</label>
|
||||
</div>
|
||||
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">\u{1F4BE} Create Snapshot</button>
|
||||
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-list" class="panel-section" style="display: none;">
|
||||
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
|
||||
<div class="panel-empty"><span class="empty-icon">\u{1F4BE}</span>Select a container to view its snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="snapshot-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const b=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),N=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),T=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),L=document.getElementById("snapshot-create-status");let H=null;async function g(){try{const R=await(await fetch("/api/v1/containers")).json();if(!R.success||!R.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const M of R.containers){const j=document.createElement("option");j.value=M.id,j.textContent=`${M.name||M.id} (${M.image||"unknown"})`,j.dataset.name=M.name,j.dataset.image=M.image,j.dataset.status=M.status,j.dataset.created=M.created,S.appendChild(j)}}catch(C){console.error("Failed to load containers:",C)}}function I(C){if(!C||!C.value){T.style.display="none",H=null;return}H=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),T.style.display=""}async function k(){if(!H){L.textContent="Please select a container first",L.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){L.textContent="Please enter a snapshot name",L.style.color="var(--bad-fg)";return}const R=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",L.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(H)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:R})})).json();j.success?(L.textContent=`\u2713 Snapshot "${C}" created successfully`,L.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(L.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,L.style.color="var(--bad-fg)")}catch(M){L.textContent=`\u2717 Error: ${M.message}`,L.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function x(){b.classList.add("show"),g()}function $(){b.classList.remove("show"),T.style.display="none",H=null,S.selectedIndex=0}E?.addEventListener("click",x),N?.addEventListener("click",$),wireModal(b,N),S?.addEventListener("change",C=>{const R=S.options[S.selectedIndex];I(R)}),P?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(R=>R.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(R=>R.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
||||
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
||||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||||
@@ -695,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="arr-setup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),L=document.getElementById("smart-phase-detect"),A=document.getElementById("smart-phase-credentials"),T=document.getElementById("smart-phase-progress"),$=document.getElementById("smart-phase-results"),y=document.getElementById("smart-detect-results"),S=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),B=document.getElementById("smart-plex-libraries"),M=document.getElementById("smart-retry-btn");let R=null;const P={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(v){L.style.display=v==="detect"?"block":"none",A.style.display=v==="credentials"?"block":"none",T.style.display=v==="progress"?"block":"none",$.style.display=v==="results"?"block":"none"}function D(v){const u={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},r=u[v]||u.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function b(){C("detect"),y.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,y.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=P[c]||"\u{1F4E6}",m=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
</div>`);const b=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),T=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),L=document.getElementById("smart-phase-progress"),H=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),I=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),$=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let R=null;const M={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function B(v){T.style.display=v==="detect"?"block":"none",P.style.display=v==="credentials"?"block":"none",L.style.display=v==="progress"?"block":"none",H.style.display=v==="results"?"block":"none"}function A(v){const m={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},r=m[v]||m.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function w(){B("detect"),g.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,g.style.display="block";return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=M[c]||"\u{1F4E6}",u=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";m+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${h}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${m}</div>
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
${a} ${e} ${n}
|
||||
</div>
|
||||
</div>
|
||||
${D(s.status)}
|
||||
</div>`}u+="</div>";const r=R.summary;u+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${A(s.status)}
|
||||
</div>`}m+="</div>";const r=R.summary;m+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` · <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,y.innerHTML=u,y.style.display="block",H(R),setTimeout(()=>{C("credentials")},800)}catch(v){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,y.style.display="block"}}function H(v){let u="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const m of c){const a=r[m];if(!a||a.status==="not_found"&&!a.url)continue;const e=P[m],n=j[m],t=a.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`,g.innerHTML=m,g.style.display="block",z(R),setTimeout(()=>{B("credentials")},800)}catch(v){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,g.style.display="block"}}function z(v){let m="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const u of c){const a=r[u];if(!a||a.status==="not_found"&&!a.url)continue;const e=M[u],n=j[u],t=a.status==="connected";m+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.1rem;">${e}</span>
|
||||
<span style="font-weight: 500;">${n}</span>
|
||||
<span id="smart-${m}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
<span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${t?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
||||
</span>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
||||
<input type="text" id="smart-${m}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${m}/"
|
||||
<input type="text" id="smart-${u}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${u}/"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
||||
<input type="password" id="smart-${m}-key" placeholder="${t?"(saved)":"Paste API key"}"
|
||||
<input type="password" id="smart-${u}-key" placeholder="${t?"(saved)":"Paste API key"}"
|
||||
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="smartTestConnection('${m}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const s=r.plex;if(s){const m=s.status==="connected";u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const s=r.plex;if(s){const u=s.status==="connected";m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
||||
<span style="font-weight: 500;">Plex</span>
|
||||
${D(s.status)}
|
||||
${A(s.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(s.source||"")}</span>
|
||||
</div>
|
||||
</div>`}const h=r.seerr;if(h){const m=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
</div>`}const h=r.seerr;if(h){const u=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
||||
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
||||
${e.plex?"✓ Plex":"✗ Plex"}
|
||||
</div>`}u+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${m?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`}m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
||||
<span style="font-weight: 500;">Seerr</span>
|
||||
${D(h.status)}
|
||||
${A(h.status)}
|
||||
</div>
|
||||
${a}
|
||||
</div>`}S.innerHTML=u}window.smartTestConnection=async function(v){const u=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=u?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(a.error)}</span>`}catch(m){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(m.message)}</span>`}};async function f(){C("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const u={services:Object.keys(v).length>0?v:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();let s="";for(const h of c.steps||[]){const m=h.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${m}
|
||||
</div>`}I.innerHTML=m}window.smartTestConnection=async function(v){const m=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=m?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(a.error)}</span>`}catch(u){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(u.message)}</span>`}};async function f(){B("progress"),k.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const m={services:Object.keys(v).length>0?v:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();let s="";for(const h of c.steps||[]){const u=h.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${u}
|
||||
<span>${escapeHtml(h.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(h.details||"")}</span>
|
||||
</div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){C("results");const u=v.summary||{},r=u.failed===0&&u.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"✓":"⚠",h=r?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.totalSteps))} Steps Succeeded`;let m=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
||||
</div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){B("results");const m=v.summary||{},r=m.failed===0&&m.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"✓":"⚠",h=r?"All Connected!":`${escapeHtml(String(m.succeeded))}/${escapeHtml(String(m.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${c};">${s}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${h}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
|
||||
</div>`;m+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';m+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(m.succeeded))} succeeded, ${escapeHtml(String(m.failed))} failed</div>
|
||||
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span>
|
||||
</div>`}m+="</div>",x.innerHTML=m,M.style.display=u.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&g()}async function g(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(u.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of u.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
</div>`}u+="</div>",x.innerHTML=u,C.style.display=m.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const m=await(await fetch("/api/v1/plex/libraries")).json();if(m.success&&m.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(m.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of m.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${s} <strong>${escapeHtml(c.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
||||
</div>`}r+="</div></div>",B.innerHTML=r,B.style.display="block"}}catch{}}E?.addEventListener("click",()=>{w.classList.add("show"),B.style.display="none",b()}),wireModal(w,N),I?.addEventListener("click",f),M?.addEventListener("click",f)})(),(function(){const w=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
</div>`}r+="</div></div>",$.innerHTML=r,$.style.display="block"}}catch{}}E?.addEventListener("click",()=>{b.classList.add("show"),$.style.display="none",w()}),wireModal(b,N),S?.addEventListener("click",f),C?.addEventListener("click",f)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F514} Notification Settings</h3>
|
||||
|
||||
@@ -941,15 +995,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),I=document.getElementById("notifications-save"),L=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const B=document.getElementById(`${x}-enabled`),M=document.getElementById(`${x}-config`);B?.addEventListener("change",()=>{M.style.display=B.checked?"block":"none"})});const A=document.getElementById("health-check-enabled"),T=document.getElementById("health-check-config");A?.addEventListener("change",()=>{T.style.opacity=A.checked?"1":"0.5"});async function $(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const M=B.config;document.getElementById("notifications-enabled").checked=M.enabled,document.getElementById("discord-enabled").checked=M.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=M.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=M.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=M.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=M.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=M.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=M.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=M.providers?.email?.enabled?"block":"none",M.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=M.providers.ntfy.serverUrl),M.providers?.email?.host&&(document.getElementById("email-host").value=M.providers.email.host),M.providers?.email?.from&&(document.getElementById("email-from").value=M.providers.email.from),document.getElementById("health-check-enabled").checked=M.healthCheck?.enabled||!1,M.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=M.healthCheck.intervalMinutes),M.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(M.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=M.events?.containerDown!==!1,document.getElementById("event-container-up").checked=M.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=M.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=M.events?.deploymentFailed!==!1}}catch(x){w.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function y(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),M=document.getElementById("notification-history");B.success&&B.history?.length>0?M.innerHTML=B.history.map(R=>{const P=new Date(R.timestamp).toLocaleString();return`
|
||||
</div>`);const E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const $=document.getElementById(`${x}-enabled`),C=document.getElementById(`${x}-config`);$?.addEventListener("change",()=>{C.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),L=document.getElementById("health-check-config");P?.addEventListener("change",()=>{L.style.opacity=P.checked?"1":"0.5"});async function H(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const C=$.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");$.success&&$.history?.length>0?C.innerHTML=$.history.map(R=>{const M=new Date(R.timestamp).toLocaleString();return`
|
||||
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${P}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${M}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join(""):M.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){w.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function S(){try{const x={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},M=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();M.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${M.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const M=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();M.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${M.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),$(),y()}),I?.addEventListener("click",S),wireModal(E,L)})(),(function(){document.addEventListener("click",w=>{const E=w.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const I=E.closest(".panel-tabs"),L=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach(T=>T.classList.remove("active")),E.classList.add("active"),L.querySelectorAll(".panel-section").forEach(T=>T.classList.remove("active"));const A=L.querySelector("#"+N);A&&A.classList.add("active")})})(),(function(){var w=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var e={},n=0;n<w.length;n++){var t=w[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function I(e){return e.version&&!e.files&&e.services}function L(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),e.services&&Array.isArray(e.serv
Line truncated
|
||||
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function I(){try{const x={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();C.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),H(),g()}),S?.addEventListener("click",I),wireModal(E,T)})(),(function(){document.addEventListener("click",b=>{const E=b.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const S=E.closest(".panel-tabs"),T=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),E.classList.add("active"),T.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active"));const P=T.querySelector("#"+N);P&&P.classList.add("active")})})(),(function(){var b=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var e={},n=0;n<b.length;n++){var t=b[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function S(e){return e.version&&!e.files&&e.services}function T(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),e.services&&Array.isArray(e.serv
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F4BE} Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1033,7 +1087,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="backup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);var A=document.getElementById("backup-modal"),T=document.getElementById("backup-restore-btn"),$=document.getElementById("backup-cancel"),y=document.getElementById("backup-export-btn"),S=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),B=document.getElementById("backup-preview"),M=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),P=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),C=document.getElementById("backup-history-container"),D=null;T?.addEventListener("click",function(){A.classList.add("show"),P&&(P.style.display="none"),B&&(B.style.display="none"),x&&(x.style.display="none"),D=null}),wireModal(A,$),y?.addEventListener("click",async function(){y.disabled=!0,y.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;P.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),P.style.display="block",P.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",P.style.border="1px solid var(--ok-fg)"}catch(z){P.innerHTML="\u274C Export failed: "+escapeHtml(z.message),P.style.display="block",P.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",P.style.border="1px solid var(--bad-fg)"}y.disabled=!1,y.innerHTML="\u2B07\uFE0F Download Full Backup"}),S?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",P.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(I(i)){D=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",M.innerHTML=o,B.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){D=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var z in l.preview.files){var O=l.preview.files[z],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),M.innerHTML=o,B.style.display="block"}else P.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),P.style.display="block",P.style.background="color-mix(in srgb, #f39c12 15%, transparent)",P.style.border="1px solid #f39c12",B.style.display="none"}catch(q){P.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),P.style.display="block",P.style.background="color-mix(in Line truncated
|
||||
</div>`);var P=document.getElementById("backup-modal"),L=document.getElementById("backup-restore-btn"),H=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),I=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),$=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),B=document.getElementById("backup-history-container"),A=null;L?.addEventListener("click",function(){P.classList.add("show"),M&&(M.style.display="none"),$&&($.style.display="none"),x&&(x.style.display="none"),A=null}),wireModal(P,H),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(D){M.innerHTML="\u274C Export failed: "+escapeHtml(D.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),I?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",M.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(S(i)){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",C.innerHTML=o,$.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var D in l.preview.files){var O=l.preview.files[D],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=o,$.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",$.style.display="none"}catch(q){M.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),M.style.display="block",M.style.background="color-mix(in Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F4CA} Resource Monitor</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1113,7 +1167,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),L=document.getElementById("stats-auto-refresh"),A=document.getElementById("stats-container"),T=document.getElementById("stats-aggregated-container"),$=document.getElementById("stats-alerts-container"),y=document.getElementById("stats-last-update");let S=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,m=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+m[a]}function B(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function M(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};k=s}}if(!s||Object.keys(s).length===0){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=B(t),d=M(i),l=n.memory?.usage||n.memory?.used||0,z=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;m+=`
|
||||
</div>`);const b=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),T=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),L=document.getElementById("stats-aggregated-container"),H=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let I=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,u=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+u[a]}function $(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function C(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};k=s}}if(!s||Object.keys(s).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=$(t),d=C(i),l=n.memory?.usage||n.memory?.used||0,D=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;u+=`
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${e.name||a}</span>
|
||||
@@ -1138,7 +1192,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${d}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(z)}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(D)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||||
@@ -1149,8 +1203,8 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}m+="</div>",A.innerHTML=m,y.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){A.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function P(){if(!T)return;const s=k;if(!s||Object.keys(s).length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${a.name||m}</div>
|
||||
</div>`}u+="</div>",P.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function M(){if(!L)return;const s=k;if(!s||Object.keys(s).length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${a.name||u}</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
||||
@@ -1158,49 +1212,49 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
||||
</div>
|
||||
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
||||
</div>`)}h+="</div>",T.innerHTML=h}async function j(){if(!$)return;$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[m,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
</div>`)}h+="</div>",L.innerHTML=h}async function j(){if(!H)return;H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${a.name||m}</span>
|
||||
<span style="font-weight: 600; flex: 1;">${a.name||u}</span>
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-enabled" data-container="${m}" ${e.enabled?"checked":""} /> Enabled
|
||||
<input type="checkbox" class="alert-enabled" data-container="${u}" ${e.enabled?"checked":""} /> Enabled
|
||||
</label>
|
||||
</div>
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
||||
<input type="number" class="alert-cpu" data-container="${m}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
<input type="number" class="alert-cpu" data-container="${u}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
||||
<input type="number" class="alert-mem" data-container="${m}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
<input type="number" class="alert-mem" data-container="${u}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
||||
<input type="number" class="alert-cooldown" data-container="${m}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
||||
<input type="number" class="alert-cooldown" data-container="${u}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
||||
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
||||
<input type="checkbox" class="alert-autorestart" data-container="${m}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
|
||||
<input type="checkbox" class="alert-autorestart" data-container="${u}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
|
||||
</label>
|
||||
<span style="flex: 1;"></span>
|
||||
<button class="alert-save-btn" data-container="${m}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
||||
<button class="alert-save-btn" data-container="${u}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
||||
</div>
|
||||
</div>`}h+="</div>",$.innerHTML=h,$.querySelectorAll(".alert-save-btn").forEach(m=>{m.addEventListener("click",async()=>{const a=m.dataset.container,e=$.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt($.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt($.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt($.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=$.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();m.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{m.textContent="Save"},2e3)}catch{m.textContent="\u274C Error",setTimeout(()=>{m.textContent="Save"},2e3)}})})}function C(){S&&clearInterval(S),L?.checked&&(S=setInterval(R,DC.POLL.STATS))}function D(){S&&(clearInterval(S),S=null)}E?.addEventListener("click",()=>{w.classList.add("show"),R(),C()}),N?.addEventListener("click",()=>{w.classList.remove("show"),D()}),w?.addEventListener("click",s=>{s.target===w&&(w.classList.remove("show"),D())}),I?.addEventListener("click",R),L?.addEventListener("change",()=>{L.checked?C():D()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",P),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const b=document.getElementById("stats-history-container"),H=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function g(s){switch(s){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function u(s,h,m,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,z=4,O=(d-z*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=z+W*O,Q=l-z-(G-i)/o*(l-z*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.length;return`
|
||||
</div>`}h+="</div>",H.innerHTML=h,H.querySelectorAll(".alert-save-btn").forEach(u=>{u.addEventListener("click",async()=>{const a=u.dataset.container,e=H.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(H.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(H.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt(H.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=H.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();u.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{u.textContent="Save"},2e3)}catch{u.textContent="\u274C Error",setTimeout(()=>{u.textContent="Save"},2e3)}})})}function B(){I&&clearInterval(I),T?.checked&&(I=setInterval(R,DC.POLL.STATS))}function A(){I&&(clearInterval(I),I=null)}E?.addEventListener("click",()=>{b.classList.add("show"),R(),B()}),N?.addEventListener("click",()=>{b.classList.remove("show"),A()}),b?.addEventListener("click",s=>{s.target===b&&(b.classList.remove("show"),A())}),S?.addEventListener("click",R),T?.addEventListener("change",()=>{T.checked?B():A()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const w=document.getElementById("stats-history-container"),z=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function y(s){switch(s){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function m(s,h,u,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,D=4,O=(d-D*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=D+W*O,Q=l-D-(G-i)/o*(l-D*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.length;return`
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
||||
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(a)}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${d} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${m}" stroke-width="1.5" points="${F}" />
|
||||
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
|
||||
</svg>
|
||||
</div>
|
||||
`}function r(){if(!b)return;const s=k||{},h=b.value,m=Object.entries(s);if(m.length===0){b.innerHTML='<option value="">No containers</option>';return}b.innerHTML=m.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(b.value=h)}async function c(){if(!H||!b)return;const s=b.value;if(!s){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),m=h-g(p);H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${m}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){H.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,z=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
|
||||
`}function r(){if(!w)return;const s=k||{},h=w.value,u=Object.entries(s);if(u.length===0){w.innerHTML='<option value="">No containers</option>';return}w.innerHTML=u.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(w.value=h)}async function c(){if(!z||!w)return;const s=w.value;if(!s){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),u=h-y(p);z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${u}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,D=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
||||
${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(m).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
|
||||
${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
|
||||
</div>
|
||||
`;O+=u(n,o,"#2ecc71","CPU","%"),O+=u(n,d,"#3498db","Memory","%"),O+=u(n,l,"#9b59b6","Network RX"," MB"),O+=u(n,z,"#e67e22","Network TX"," MB"),H.innerHTML=O}catch(a){H.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),b?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
`;O+=m(n,o,"#2ecc71","CPU","%"),O+=m(n,d,"#3498db","Memory","%"),O+=m(n,l,"#9b59b6","Network RX"," MB"),O+=m(n,D,"#e67e22","Network TX"," MB"),z.innerHTML=O}catch(a){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),w?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
||||
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1286,24 +1340,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),L=document.getElementById("health-status-container"),A=document.getElementById("health-incidents-container"),T=document.getElementById("health-config-container"),$=document.getElementById("health-last-update"),y=document.getElementById("health-add-btn"),S=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),B=document.getElementById("health-form-save");let M=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function P(f){const p={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const g=Object.values(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const u of g){const r=u.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=u.uptime?.["24h"]??"-",h=u.uptime?.["7d"]??"-",m=u.avgResponseTime!=null?Math.round(u.avgResponseTime)+"ms":"-",a=u.timestamp?timeAgo(u.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(u.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${m}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(u.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}v+="</table>",L.innerHTML=v,$.textContent="Updated "+new Date().toLocaleTimeString(),L.querySelectorAll("tr[data-health-id]").forEach(u=>{u.addEventListener("click",async()=>{const r=u.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const m=h.stats,a=m.responseTime||{};c.querySelector("td").innerHTML=`
|
||||
</div>`);const b=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),T=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),L=document.getElementById("health-config-container"),H=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),I=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),$=document.getElementById("health-form-save");let C=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function M(f){const p={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const y=Object.values(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of y){const r=m.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=m.uptime?.["24h"]??"-",h=m.uptime?.["7d"]??"-",u=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",a=m.timestamp?timeAgo(m.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${u}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}v+="</table>",T.innerHTML=v,H.textContent="Updated "+new Date().toLocaleTimeString(),T.querySelectorAll("tr[data-health-id]").forEach(m=>{m.addEventListener("click",async()=>{const r=m.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const u=h.stats,a=u.responseTime||{};c.querySelector("td").innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${m.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(m.uptime||0)};">${(m.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${m.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${m.downChecks||0}</strong></div>
|
||||
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function C(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),g=await f.json(),v=await p.json();let u="";const r=g.success&&g.incidents?g.incidents:[];if(r.length>0){u+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)u+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
|
||||
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function B(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await f.json(),v=await p.json();let m="";const r=y.success&&y.incidents?y.incidents:[];if(r.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="font-weight: 500;">${escapeHtml(s.serviceId)}</span>
|
||||
<span>${P(s.severity)}</span>
|
||||
<span>${M(s.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(s.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(s.createdAt)} \xB7 ${s.occurrences||1} occurrence(s)</div>
|
||||
</div>`;u+="</div>"}else u+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=v.success&&v.history?v.history:[];if(c.length>0){u+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',u+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const s of c){const h=s.status==="resolved",m=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,u+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,u+=`<td style="padding: 6px;">${P(s.severity)}</td>`,u+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,u+=`<td style="padding: 6px;">${m}</td>`,u+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,u+="</tr>"}u+="</table>"}A.innerHTML=u||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function D(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),g=p.success&&p.status?Object.values(p.status):[];if(g.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const u of g){const r=u.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.name||u.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${u.sla?.target?u.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(u.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",T.innerHTML=v}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function b(f,p,g,v,u,r,c){M=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=g||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=u||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,S.style.display="",y.style.display="none"}function H(){S.style.display="none",y.style.display="",M=null}y?.addEventListener("click",()=>b("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",H),B?.addEventListener("click",async()=>{const f=M||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const g=document.getElementById("health-form-codes").value.split(",").map(u=>parseInt(u.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:g.length?g:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
</div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=v.success&&v.history?v.history:[];if(c.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const s of c){const h=s.status==="resolved",u=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,m+=`<td style="padding: 6px;">${M(s.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,m+=`<td style="padding: 6px;">${u}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function A(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),y=p.success&&p.status?Object.values(p.status):[];if(y.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of y){const r=m.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",L.innerHTML=v}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function w(f,p,y,v,m,r,c){C=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,I.style.display="",g.style.display="none"}function z(){I.style.display="none",g.style.display="",C=null}g?.addEventListener("click",()=>w("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",z),$?.addEventListener("click",async()=>{const f=C||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:y.length?y:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u2B06\uFE0F Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1375,17 +1429,17 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),L=document.getElementById("updates-available-container"),A=document.getElementById("updates-history-container"),T=document.getElementById("updates-auto-container"),$=document.getElementById("updates-last-check");async function y(){try{const m=await(await fetch("/api/v1/updates/available")).json();if(!m.success)throw new Error(m.error);const a=m.updates||[];if(a.length===0){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',$.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",L.innerHTML=e,$.textContent=a.length+" update(s) available",L.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>y(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),L.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>y(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function S(){I.textContent="\u{1F50D} Checking...",I.disabled=!0;try{const m=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!m.success)throw new Error(m.error);I.textContent="\u2705 Done!",await y()}catch(h){I.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{I.textContent="\u{1F50D} Check for Updates",I.disabled=!1},3e3)}async function k(){try{A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const m=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=m.success&&m.history?m.history:[];if(a.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
|
||||
</div>`);const b=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),T=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),L=document.getElementById("updates-auto-container"),H=document.getElementById("updates-last-check");async function g(){try{const u=await(await fetch("/api/v1/updates/available")).json();if(!u.success)throw new Error(u.error);const a=u.updates||[];if(a.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',H.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",T.innerHTML=e,H.textContent=a.length+" update(s) available",T.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),T.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function I(){S.textContent="\u{1F50D} Checking...",S.disabled=!0;try{const u=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!u.success)throw new Error(u.error);S.textContent="\u2705 Done!",await g()}catch(h){S.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{S.textContent="\u{1F50D} Check for Updates",S.disabled=!1},3e3)}async function k(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const u=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml(l)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${O?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",T.innerHTML=i,T.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),z=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!z,schedule:z||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),M=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),P=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),C=document.getElementById("dashcaddy-apply-btn"),D=document.getElementById("dashcaddy-check-btn"),b=document.getElementById("dashcaddy-rollback-btn"),H=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function g(h,m){H&&(H.style.display="block",H.style.background=m==="error"?"var(--bad-bg)":m==="success"?"var(--ok-bg)":"var(--bg)",H.style.color=m==="error"?"var(--bad-fg)":m==="success"?"var(--ok-fg)":"var(--fg)",H.textContent=h)}async function v(){try{const m=await(await fetch("/api/v1/system/version")).json();if(m.success){const a=m.commit&&m.commit!=="unknown"?m.commit:null;B.textContent="v"+m.version+(a?" ("+a.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function u(h){h||(D.textContent="Checking...",D.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){M.style.display="",R.style.display="",P.textContent="v"+a.remote.version,j.textContent=a.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",n.appendChild(t)}}else M.style.display="none",R.style.display="none",await v(),h||g("You are running the latest version.","success");h||(D.textContent="Check for Updates",D.disabled=!1)}catch(m){h||(g("Failed to check: "+m.message,"error"),D.textContent="Check for Updates",D.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;C.textContent="Updating...",C.disabled=!0,g("Downloading and applying update...","info");try{const m=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(m.success)return g("Update initiated: v"+(m.fromVersion||"?")+" \u2192 v"+(m.toVersion||"?")+". The container will restart shortly.","success"),C.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(m.error||"Update failed")}catch(h){throw g("Update failed: "+h.message,"error"),C.textContent="Update Now",C.disabled=!1,h}}async function c(){try{const m=await(await fetch("/api/v1/system/update-history")).json(),a=m.success&&m.history?m.history:[];if(a.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
</select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",L.innerHTML=i,L.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),D=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!D,schedule:D||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),M=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),w=document.getElementById("dashcaddy-rollback-btn"),z=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function y(h,u){z&&(z.style.display="block",z.style.background=u==="error"?"var(--bad-bg)":u==="success"?"var(--ok-bg)":"var(--bg)",z.style.color=u==="error"?"var(--bad-fg)":u==="success"?"var(--ok-fg)":"var(--fg)",z.textContent=h)}async function v(){try{const u=await(await fetch("/api/v1/system/version")).json();if(u.success){const a=u.commit&&u.commit!=="unknown"?u.commit:null;$.textContent="v"+u.version+(a?" ("+a.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function m(h){h||(A.textContent="Checking...",A.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){C.style.display="",R.style.display="",M.textContent="v"+a.remote.version,j.textContent=a.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",n.appendChild(t)}}else C.style.display="none",R.style.display="none",await v(),h||y("You are running the latest version.","success");h||(A.textContent="Check for Updates",A.disabled=!1)}catch(u){h||(y("Failed to check: "+u.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,y("Downloading and applying update...","info");try{const u=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(u.success)return y("Update initiated: v"+(u.fromVersion||"?")+" \u2192 v"+(u.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(u.error||"Update failed")}catch(h){throw y("Update failed: "+h.message,"error"),B.textContent="Update Now",B.disabled=!1,h}}async function c(){try{const u=await(await fetch("/api/v1/system/update-history")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
`+a.join(`
|
||||
`)+`
|
||||
|
||||
Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;g("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)g("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(h){g("Rollback failed: "+h.message,"error")}}D?.addEventListener("click",()=>u(!1)),C?.addEventListener("click",()=>r().catch(()=>{})),b?.addEventListener("click",s),I?.addEventListener("click",S),E?.addEventListener("click",()=>{w?.classList.add("show"),y()}),wireModal(w,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||u(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=u,setTimeout(()=>u(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;y("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)y("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(h){y("Rollback failed: "+h.message,"error")}}A?.addEventListener("click",()=>m(!1)),B?.addEventListener("click",()=>r().catch(()=>{})),w?.addEventListener("click",s),S?.addEventListener("click",I),E?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||m(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=m,setTimeout(()=>m(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F433} Docker Resources</h3>
|
||||
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||
@@ -1434,7 +1488,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function I($){if(!$||$===0)return"0 B";const y=["B","KB","MB","GB","TB"],S=Math.floor(Math.log(Math.abs($))/Math.log(1024));return($/Math.pow(1024,S)).toFixed(1)+" "+y[S]}async function L(){const $=document.getElementById("dr-vol-list");try{const S=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(S.length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of S){const B=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',B||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",$.innerHTML=k,$.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),L()}catch(B){showNotification("Delete failed: "+B.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(y){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const $=document.getElementById("dr-vol-name"),y=$.value.trim();if(!y){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:y}),$.value="",showNotification(`Volume "${y}" created`,"success"),L()}catch(S){showNotification("Create failed: "+S.message,"error")}});async function A(){const $=document.getElementById("dr-net-list");try{const S=(await getJSON("/api/v1/docker/networks")).networks||[];if(S.length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of S){const B=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',B||(k+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",$.innerHTML=k,$.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),A()}catch(B){showNotification("Delete failed: "+B.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(y){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const $=document.getElementById("dr-net-name"),y=document.getElementById("dr-net-driver"),S=$.value.trim();if(!S){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:S,driver:y.value}),$.value="",showNotification(`Network "${S}" created`,"success"),A()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
|
||||
</div>`);const b=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function S(H){if(!H||H===0)return"0 B";const g=["B","KB","MB","GB","TB"],I=Math.floor(Math.log(Math.abs(H))/Math.log(1024));return(H/Math.pow(1024,I)).toFixed(1)+" "+g[I]}async function T(){const H=document.getElementById("dr-vol-list");try{const I=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),T()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-vol-name"),g=H.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),H.value="",showNotification(`Volume "${g}" created`,"success"),T()}catch(I){showNotification("Create failed: "+I.message,"error")}});async function P(){const H=document.getElementById("dr-net-list");try{const I=(await getJSON("/api/v1/docker/networks")).networks||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const x of I){const $=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),P()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),I=H.value.trim();if(!I){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:I,driver:g.value}),H.value="",showNotification(`Network "${I}" created`,"success"),P()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||
<h3>\u{1F4E6} Import Docker Compose</h3>
|
||||
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||
@@ -1475,8 +1529,8 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(w,N);let I=null;function L(T){document.getElementById("compose-step-paste").style.display=T==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=T==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=T==="progress"?"":"none"}E?.addEventListener("click",()=>{L("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",w?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",T=>{const $=T.target.files[0];if(!$)return;const y=new FileReader;y.onload=()=>{document.getElementById("compose-yaml").value=y.result},y.readAsText($)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const T=document.getElementById("compose-yaml").value.trim(),$=document.getElementById("compose-stack-name").value.trim()||"stack";if(!T){showNotification("Paste a docker-compose.yml","warning");return}const y=document.getElementById("compose-parse-btn"),S=y.textContent;y.textContent="Parsing...",y.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:T,stackName:$});I=k,I.stackName=$,A(k),L("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{y.textContent=S,y.disabled=!1}});function A(T){const $=document.getElementById("compose-preview-content");let y="";T.networks&&T.networks.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${T.networks.map(S=>`<code>${escapeHtml(S)}</code>`).join(", ")}</div>`),T.volumes&&T.volumes.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${T.volumes.map(S=>`<code>${escapeHtml(S)}</code>`).join(", ")}</div>`),y+=`<div style="font-weight: 600; margin-bottom: 8px;">${T.services.length} service(s)</div>`,y+='<div class="scroll-container" style="max-height: 350px;">';for(const S of T.services){const k=S.skip?"var(--bad-fg)":"var(--border)";if(y+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,y+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(S.name)}`,S.skip&&(y+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(S.reason)}</span>`),y+="</div>",!S.skip&&(y+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(S.image)}</code></div>`,S.ports?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${S.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),S.volumes?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${S.volumes.length}</div>`),Object.keys(S.environment||{}).length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(S.environment).length}</div>`),S.envFileWarning&&(y+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(S.envFileWarning)}</div>`),S.resources?.cpus||S.resources?.memory)){const x=[];S.resources.cpus&&x.push(`CPU: ${S.resources.cpus}`),S.resources.memory&&x.push(`Mem: ${S.resources.memory}MB`),y+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}y+="</div>"}y+="</div>",$.innerHTML=y}document.getElementById("compose-back-btn")?.addEventListener("click",()=>L("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const T=document.getElementById("compose-deploy-btn");T.textContent="Deploying...",T.disabled=!0,L("progress");const $=document.getElementById("compose-progress-content");$.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const y=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let S=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(y.stackName)}" \u2014 Deployment Complete</div>`;S+='<div class="scroll-container" style="max-height: 350px;">';for(const k of y.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";S+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',S+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(S+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(S+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(S+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),S+="</div>"}S+="</div>",S+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>L("paste"))}finally{T.textContent="Deploy All",T.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
</div>`);const b=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(b,N);let S=null;function T(L){document.getElementById("compose-step-paste").style.display=L==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=L==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=L==="progress"?"":"none"}E?.addEventListener("click",()=>{T("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",L=>{const H=L.target.files[0];if(!H)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(H)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const L=document.getElementById("compose-yaml").value.trim(),H=document.getElementById("compose-stack-name").value.trim()||"stack";if(!L){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),I=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:L,stackName:H});S=k,S.stackName=H,P(k),T("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=I,g.disabled=!1}});function P(L){const H=document.getElementById("compose-preview-content");let g="";L.networks&&L.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${L.networks.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),L.volumes&&L.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${L.volumes.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${L.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const I of L.services){const k=I.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.name)}`,I.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(I.reason)}</span>`),g+="</div>",!I.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(I.image)}</code></div>`,I.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${I.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),I.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${I.volumes.length}</div>`),Object.keys(I.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(I.environment).length}</div>`),I.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(I.envFileWarning)}</div>`),I.resources?.cpus||I.resources?.memory)){const x=[];I.resources.cpus&&x.push(`CPU: ${I.resources.cpus}`),I.resources.memory&&x.push(`Mem: ${I.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",H.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>T("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const L=document.getElementById("compose-deploy-btn");L.textContent="Deploying...",L.disabled=!0,T("progress");const H=document.getElementById("compose-progress-content");H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let I=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;I+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";I+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',I+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(I+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(I+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(I+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),I+="</div>"}I+="</div>",I+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>T("paste"))}finally{L.textContent="Deploy All",L.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
||||
<h3 id="exec-title">Terminal</h3>
|
||||
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
||||
@@ -1484,11 +1538,11 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<button id="exec-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let I=null,L=null,A=null;function T(){if(L){try{L.close()}catch{}L=null}if(I){try{I.dispose()}catch{}I=null}A=null,E.innerHTML=""}function $(y,S){if(T(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${S||y}`,w?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(A=new FitAddon.FitAddon,I.loadAddon(A)),I.open(E),A&&setTimeout(()=>A.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";L=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(y)}`),L.binaryType="arraybuffer",L.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),A){const B=A.proposeDimensions();B&&L.send(JSON.stringify({type:"resize",cols:B.cols,rows:B.rows}))}},L.onmessage=B=>{if(typeof B.data=="string"){try{const M=JSON.parse(B.data);if(M.type==="connected"){I.writeln(`\x1B[32mConnected (${M.shell})\x1B[0m\r
|
||||
`);return}if(M.type==="error"){I.writeln(`\x1B[31mError: ${M.message}\x1B[0m`);return}if(M.type==="exit"){I.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(B.data)}else I.write(new Uint8Array(B.data))},L.onclose=()=>{I&&I.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},L.onerror=()=>{I&&I.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},I.onData(B=>{L&&L.readyState===WebSocket.OPEN&&L.send(B)}),I.onResize(({cols:B,rows:M})=>{L&&L.readyState===WebSocket.OPEN&&L.send(JSON.stringify({type:"resize",cols:B,rows:M}))});const x=()=>{A&&A.fit()};window.addEventListener("resize",x),w._resizeHandler=x}N?.addEventListener("click",()=>{T(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",y=>{y.target===w&&(T(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=$})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
</div>`);const b=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let S=null,T=null,P=null;function L(){if(T){try{T.close()}catch{}T=null}if(S){try{S.dispose()}catch{}S=null}P=null,E.innerHTML=""}function H(g,I){if(L(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${I||g}`,b?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(E),P&&setTimeout(()=>P.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";T=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),T.binaryType="arraybuffer",T.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const $=P.proposeDimensions();$&&T.send(JSON.stringify({type:"resize",cols:$.cols,rows:$.rows}))}},T.onmessage=$=>{if(typeof $.data=="string"){try{const C=JSON.parse($.data);if(C.type==="connected"){S.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
|
||||
`);return}if(C.type==="error"){S.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){S.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write($.data)}else S.write(new Uint8Array($.data))},T.onclose=()=>{S&&S.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},T.onerror=()=>{S&&S.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},S.onData($=>{T&&T.readyState===WebSocket.OPEN&&T.send($)}),S.onResize(({cols:$,rows:C})=>{T&&T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:"resize",cols:$,rows:C}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),b._resizeHandler=x}N?.addEventListener("click",()=>{L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=H})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<h3>\u{1F4DC} Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1524,7 +1578,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),L=document.getElementById("audit-clear-btn"),A=document.getElementById("audit-filter"),T=document.getElementById("audit-log-container"),$=document.getElementById("audit-load-more");let y=0;const S=50;async function k(x){try{x||(y=0,T.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const B=A.value;let M=`/api/v1/audit-logs?limit=${S}&offset=${y}`;B&&(M+=`&action=${encodeURIComponent(B)}`);const P=await(await fetch(M)).json(),j=P.success&&P.entries?P.entries:[];if(j.length===0&&!x){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',$.style.display="none";return}let C="";x||(C='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',C+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const D of j){const b=D.outcome==="success";C+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',C+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(D.timestamp)}</td>`,C+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(D.ip||"-")}</td>`,C+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(D.action||"-")}</td>`,C+=`<td style="padding: 6px;">${escapeHtml(D.resource||"-")}</td>`,C+=`<td style="padding: 6px;"><span style="color: ${b?"var(--ok-fg)":"var(--bad-fg)"};">${b?"\u2713":"\u2717"}</span></td>`,C+="</tr>",D.details&&Object.keys(D.details).length>0&&(C+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(D.details,null,2))}</pre></td></tr>`)}if(!x)C+="</table>",T.innerHTML=C;else{const D=T.querySelector("table");D&&D.insertAdjacentHTML("beforeend",C)}y+=j.length,$.style.display=j.length>=S?"":"none",T.querySelectorAll(".audit-row").forEach(D=>{D.dataset.wired||(D.dataset.wired="true",D.addEventListener("click",()=>{const b=D.nextElementSibling;b&&b.classList.contains("audit-detail")&&(b.style.display=b.style.display==="none"?"":"none")}))})}catch(B){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(B.message)}</div>`}}E?.addEventListener("click",()=>{w?.classList.add("show"),k(!1)}),wireModal(w,N),I?.addEventListener("click",()=>k(!1)),A?.addEventListener("change",()=>k(!1)),$?.addEventListener("click",()=>k(!0)),L?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const B=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();B.success?k(!1):showNotification("Error: "+(B.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const w=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
</div>`);const b=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),T=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),L=document.getElementById("audit-log-container"),H=document.getElementById("audit-load-more");let g=0;const I=50;async function k(x){try{x||(g=0,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const $=P.value;let C=`/api/v1/audit-logs?limit=${I}&offset=${g}`;$&&(C+=`&action=${encodeURIComponent($)}`);const M=await(await fetch(C)).json(),j=M.success&&M.entries?M.entries:[];if(j.length===0&&!x){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',H.style.display="none";return}let B="";x||(B='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',B+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const A of j){const w=A.outcome==="success";B+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',B+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(A.timestamp)}</td>`,B+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(A.ip||"-")}</td>`,B+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(A.action||"-")}</td>`,B+=`<td style="padding: 6px;">${escapeHtml(A.resource||"-")}</td>`,B+=`<td style="padding: 6px;"><span style="color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"\u2713":"\u2717"}</span></td>`,B+="</tr>",A.details&&Object.keys(A.details).length>0&&(B+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(A.details,null,2))}</pre></td></tr>`)}if(!x)B+="</table>",L.innerHTML=B;else{const A=L.querySelector("table");A&&A.insertAdjacentHTML("beforeend",B)}g+=j.length,H.style.display=j.length>=I?"":"none",L.querySelectorAll(".audit-row").forEach(A=>{A.dataset.wired||(A.dataset.wired="true",A.addEventListener("click",()=>{const w=A.nextElementSibling;w&&w.classList.contains("audit-detail")&&(w.style.display=w.style.display==="none"?"":"none")}))})}catch($){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml($.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),k(!1)}),wireModal(b,N),S?.addEventListener("click",()=>k(!1)),P?.addEventListener("change",()=>k(!1)),H?.addEventListener("click",()=>k(!0)),T?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const $=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();$.success?k(!1):showNotification("Error: "+($.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
<label for="weather-location-input">Location:</label>
|
||||
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
||||
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
||||
@@ -1535,23 +1589,23 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",N="weather-zip",I="weather-geo",L="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function A(){return safeGet(L)||"imperial"}function T(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const $={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},y={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},S=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(C){return S[Math.round(C/22.5)%16]}async function x(C){const D=safeGet(I);if(D)try{const g=JSON.parse(D);if(g.query===C)return g}catch{}const b=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!b.ok)throw new Error("Geocoding failed");const H=await b.json();if(!H.results||!H.results.length)throw new Error("Location not found");const f=H.results[0],p={query:C,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(I,JSON.stringify(p)),p}function B(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function M(C){try{const D=await x(C),b=A(),H=b==="metric"?"celsius":"fahrenheit",f=b==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${D.lat}&longitude=${D.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${H}&wind_speed_unit=${f}`,g=await fetch(p);if(!g.ok)throw new Error("Weather fetch failed");const u=(await g.json()).current,r=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:$[r]||"Unknown",icon:y[r]||"\u{1F324}\uFE0F",locationStr:B(D),windSpeed:Math.round(u.wind_speed_10m),windDir:k(u.wind_direction_10m),unit:b}}catch(D){return console.warn("Weather fetch failed:",D),null}}async function R(){const C=T();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const D=safeGet(E);if(!D){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const b=await M(D);if(b){const H=b.unit==="metric"?"\xB0C":"\xB0F",f=b.unit==="metric"?"km/h":"mph";C.location.textContent=b.locationStr,C.temp.textContent=`${b.temp}${H}`,C.condition.textContent=b.condition,C.wind.textContent=`Wind: ${b.windSpeed} ${f} ${b.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(b.icon)}</span>`}}catch(b){w.logError("[Weather] Update Error",b,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const P=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const C=A(),D=P.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);D&&(D.checked=!0),P.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{P.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=j.value.trim();if(C){safeGet(E)!==C&&safeSet(I,""),safeSet(E,C);const b=P.querySelector('input[name="weather-unit-radio"]:checked'),H=b?b.value:"imperial",f=A();safeSet(L,H),f!==H&&safeSet(I,""),P.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(P),document.addEventListener("keydown",C=>{C.keyLine truncated
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",N="weather-zip",S="weather-geo",T="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function P(){return safeGet(T)||"imperial"}function L(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const H={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},I=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(B){return I[Math.round(B/22.5)%16]}async function x(B){const A=safeGet(S);if(A)try{const y=JSON.parse(A);if(y.query===B)return y}catch{}const w=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(B)}&count=1&language=en&format=json`);if(!w.ok)throw new Error("Geocoding failed");const z=await w.json();if(!z.results||!z.results.length)throw new Error("Location not found");const f=z.results[0],p={query:B,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(S,JSON.stringify(p)),p}function $(B){return B.countryCode==="US"&&B.state?`${B.city}, ${B.state}`:B.country?`${B.city}, ${B.country}`:B.city}async function C(B){try{const A=await x(B),w=P(),z=w==="metric"?"celsius":"fahrenheit",f=w==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${A.lat}&longitude=${A.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${z}&wind_speed_unit=${f}`,y=await fetch(p);if(!y.ok)throw new Error("Weather fetch failed");const m=(await y.json()).current,r=m.weather_code;return{temp:Math.round(m.temperature_2m),condition:H[r]||"Unknown",icon:g[r]||"\u{1F324}\uFE0F",locationStr:$(A),windSpeed:Math.round(m.wind_speed_10m),windDir:k(m.wind_direction_10m),unit:w}}catch(A){return console.warn("Weather fetch failed:",A),null}}async function R(){const B=L();if(!B.icon||!B.temp||!B.condition||!B.location||!B.wind){console.warn("Weather widget elements not found");return}const A=safeGet(E);if(!A){B.location.textContent="Set Location",B.temp.textContent="--\xB0",B.condition.textContent="Click \u2699\uFE0F to configure",B.wind.textContent="--",B.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const w=await C(A);if(w){const z=w.unit==="metric"?"\xB0C":"\xB0F",f=w.unit==="metric"?"km/h":"mph";B.location.textContent=w.locationStr,B.temp.textContent=`${w.temp}${z}`,B.condition.textContent=w.condition,B.wind.textContent=`Wind: ${w.windSpeed} ${f} ${w.windDir}`,B.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(w.icon)}</span>`}}catch(w){b.logError("[Weather] Update Error",w,{function:"updateWeather"}),B.location.textContent="Weather Error",B.temp.textContent="Error",B.condition.textContent="Failed to load",B.wind.textContent="--"}}const M=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const B=P(),A=M.querySelector(`input[name="weather-unit-radio"][value="${B}"]`);A&&(A.checked=!0),M.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const B=j.value.trim();if(B){safeGet(E)!==B&&safeSet(S,""),safeSet(E,B);const w=M.querySelector('input[name="weather-unit-radio"]:checked'),z=w?w.value:"imperial",f=P();safeSet(T,z),f!==z&&safeSet(S,""),M.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",B=>{B.keyLine truncated
|
||||
<circle cx="${d}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${U}
|
||||
${q}
|
||||
<line x1="${d}" y1="${l}" x2="${d+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${d}" y1="${l}" x2="${d+38*Math.cos(O*Math.PI/180)}" y2="${l+38*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${d}" y1="${l}" x2="${d+42*Math.cos(z*Math.PI/180)}" y2="${l+42*Math.sin(z*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<line x1="${d}" y1="${l}" x2="${d+42*Math.cos(D*Math.PI/180)}" y2="${l+42*Math.sin(D*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${d}" cy="${l}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${M(a)}</span></div></div>`,S="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(A!=="default"?" "+A:"");switch(w.className!==i&&(w.className=i),A){case"lcd":C(a);break;case"lcd-blue":C(a);break;case"lcd-amber":C(a);break;case"lcd-retro":C(a);break;case"lcd-taxi":C(a);break;case"flip":D(a);break;case"binary":H(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==T&&(T=e,B(e)),n!==0&&(T=-1)}function g(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),g()},e)}document.addEventListener("visibilitychange",()=>{y="",R(),p(),g()}),p(),g();const v=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let u='<div class="clock-style-grid">';v.forEach(a=>{u+=`<label class="clock-style-option">
|
||||
</svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${C(a)}</span></div></div>`,I="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":B(a);break;case"lcd-blue":B(a);break;case"lcd-amber":B(a);break;case"lcd-retro":B(a);break;case"lcd-taxi":B(a);break;case"flip":A(a);break;case"binary":z(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==L&&(L=e,$(e)),n!==0&&(L=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",R(),p(),y()}),p(),y();const v=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let m='<div class="clock-style-grid">';v.forEach(a=>{m+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${a.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span>
|
||||
</label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
</label>`}),m+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="max-width: 420px;">
|
||||
<h3>Clock Settings</h3>
|
||||
<div style="margin-bottom: 16px;">
|
||||
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
||||
${u}
|
||||
${m}
|
||||
</div>
|
||||
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
||||
@@ -1575,8 +1629,8 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function m(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{m(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),A=e,y="",R(),p(),g(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{A=a.value,y="",R(),p()})})})(),(function(){async function w(){try{const T=await(await fetch("/api/v1/health-checks/status")).json();if(!T.success||!T.status)return;for(const[$,y]of Object.entries(T.status)){const S=document.getElementById("uptime-"+$),k=document.getElementById("uptime-bar-"+$);if(!S)continue;const x=y.uptime?.["24h"];if(x!=null){const B=x.toFixed(1);S.textContent=`${B}% uptime`,S.className="uptime-chip",x>=99.9?S.classList.add("excellent"):x>=99?S.classList.add("good"):x>=95?S.classList.add("degraded"):S.classList.add("poor"),k&&(k.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function N(){try{const T=await(await fetch("/api/v1/updates/available")).json();if(!T.success||(document.querySelectorAll(".update-available-badge").forEach($=>$.classList.remove("visible")),!T.updates?.length))return;for(const $ of T.updates){const y=window.APPS||[];for(const S of y)if(S.containerId===$.containerId||S.id===$.containerName||S.name===$.containerName){if(E.has(S.id))break;const k=document.getElementById("update-badge-"+S.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
|
||||
${$.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(S.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function I(){setTimeout(()=>{w(),N()},5e3),setInterval(()=>{w(),N()},6e4)}const L=window.refreshAll;L&&(window.refreshAll=async function(){try{await L(),setTimeout(w,1e3)}catch(A){console.warn("[Card Badges] Error in refreshAll hook:",A.message)}}),I()})(),(function(){var w=null,E=null,N={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},L=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],A=document.getElementById("theme");if(!A)return;var T=document.getElementById("theme-label");function $(t){if(I[t])return I[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function y(){T&&(T.textContent=$(window.getActiveTheme()))}A.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),y()}),y();function S(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};L.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+$(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+k()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</
Line truncated
|
||||
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function u(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),P=e,g="",R(),p(),y(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",R(),p()})})})(),(function(){async function b(){try{const L=await(await fetch("/api/v1/health-checks/status")).json();if(!L.success||!L.status)return;for(const[H,g]of Object.entries(L.status)){const I=document.getElementById("uptime-"+H),k=document.getElementById("uptime-bar-"+H);if(!I)continue;const x=g.uptime?.["24h"];if(x!=null){const $=x.toFixed(1);I.textContent=`${$}% uptime`,I.className="uptime-chip",x>=99.9?I.classList.add("excellent"):x>=99?I.classList.add("good"):x>=95?I.classList.add("degraded"):I.classList.add("poor"),k&&(k.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function N(){try{const L=await(await fetch("/api/v1/updates/available")).json();if(!L.success||(document.querySelectorAll(".update-available-badge").forEach(H=>H.classList.remove("visible")),!L.updates?.length))return;for(const H of L.updates){const g=window.APPS||[];for(const I of g)if(I.containerId===H.containerId||I.id===H.containerName||I.name===H.containerName){if(E.has(I.id))break;const k=document.getElementById("update-badge-"+I.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
|
||||
${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(I.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function S(){setTimeout(()=>{b(),N()},5e3),setInterval(()=>{b(),N()},6e4)}const T=window.refreshAll;T&&(window.refreshAll=async function(){try{await T(),setTimeout(b,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),S()})(),(function(){var b=null,E=null,N={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},T=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var L=document.getElementById("theme-label");function H(t){if(S[t])return S[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function g(){L&&(L.textContent=H(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),g()}),g();function I(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};T.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+H(t)+"</option>"}).join("")}var x='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+k()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
@@ -1613,13 +1667,13 @@ ${$.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),L=document.getElementById("license-error"),A=document.getElementById("license-success"),T=document.getElementById("license-badge-icon"),$=document.getElementById("license-badge-text"),y=document.getElementById("license-badge"),S=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let B=null;function M(){L.style.display="none",A.style.display="none"}function R(g){M(),L.textContent=g,L.style.display="block"}function P(g){M(),A.textContent=g,A.style.display="block"}function j(g){if(B=g,g.active){y.style.background="rgba(46,204,113,0.15)",y.style.color="var(--ok-fg)",T.textContent="\u2605",$.textContent="Premium Active";const r=g.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(g.expiresAt).toLocaleDateString()}</strong> (${g.daysRemaining} days remaining)</div>`;S.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${g.code||"***"}</code></div>
|
||||
</div>`);const b=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),T=document.getElementById("license-error"),P=document.getElementById("license-success"),L=document.getElementById("license-badge-icon"),H=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),I=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let $=null;function C(){T.style.display="none",P.style.display="none"}function R(y){C(),T.textContent=y,T.style.display="block"}function M(y){C(),P.textContent=y,P.style.display="block"}function j(y){if($=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",L.textContent="\u2605",H.textContent="Premium Active";const r=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;I.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
|
||||
${r}
|
||||
`,x.style.display="none",N.style.display="none",I.style.display=""}else y.style.background="rgba(149,165,166,0.15)",y.style.color="var(--muted)",T.textContent="\u2606",$.textContent=g.expired?"License Expired":"Free Tier",S.innerHTML=g.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",N.style.display="",I.style.display="none";const v=g.premiumFeatures||{},u=new Set(g.features||[]);k.innerHTML=Object.entries(v).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${u.has(r)?"\u2705":"\u{1F512}"}</span>
|
||||
`,x.style.display="none",N.style.display="none",S.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",L.textContent="\u2606",H.textContent=y.expired?"License Expired":"Free Tier",I.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",N.style.display="",S.style.display="none";const v=y.premiumFeatures||{},m=new Set(y.features||[]);k.innerHTML=Object.entries(v).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${m.has(r)?"\u2705":"\u{1F512}"}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
||||
</div>
|
||||
</div>`).join("")}async function C(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),H(v.license))}catch(g){console.warn("Failed to load license status:",g.message)}}async function D(){const g=E.value.trim();if(!g){R("Please enter a license code.");return}M(),N.disabled=!0,N.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:g})})).json();u.success?(P(u.message),E.value="",j(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),H(u.license)):R(u.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function b(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(P(v.message),await C(),showNotification("License deactivated.","info",3e3),H({active:!1})):R(v.error||"Deactivation failed")}catch(g){R("Network error: "+g.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function H(g){const v=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(g.active?"premium":"free"),g.active)if(u.textContent="\u2605",r.textContent="PREMIUM",g.lifetime)c.textContent="\xB7 LIFETIME";else{const s=g.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else u.textContent="\u2606",r.textContent=g.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){M(),C(),w.classList.add("show")}E.addEventListener("input",function(){let g=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(g.length>this._prevLength&&(g=g.replace(/-/g,""),g.length>2&&!g.startsWith("DC")&&(g="DC"+g),g.startsWith("DC")&&g.length>2)){const v=["DC"],u=g.substring(2);for(let r=0;r<u.length;r+=5)v.push(u.substring(r,r+5));g=v.join("-")}this._prevLength=g.length,this.value=g}),N.addEventListener("click",D),I.addEventListener("click",b),E.addEventListener("keydown",g=>{g.key==="Enter"&&D()}),wireModal(w,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(g){try{return(await(await fetch(`/api/v1/license/feature/${g}`)).json()).available}catch{return!1}},C().then(g=>{B&&H(B)})})();
|
||||
</div>`).join("")}async function B(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),z(v.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function A(){const y=E.value.trim();if(!y){R("Please enter a license code.");return}C(),N.disabled=!0,N.textContent="Activating...";try{const m=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();m.success?(M(m.message),E.value="",j(m.license),showNotification("License activated! Premium features unlocked.","success",5e3),z(m.license)):R(m.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function w(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(M(v.message),await B(),showNotification("License deactivated.","info",3e3),z({active:!1})):R(v.error||"Deactivation failed")}catch(y){R("Network error: "+y.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function z(y){const v=document.getElementById("license-status-topbar"),m=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(m.textContent="\u2605",r.textContent="PREMIUM",y.lifetime)c.textContent="\xB7 LIFETIME";else{const s=y.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else m.textContent="\u2606",r.textContent=y.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){C(),B(),b.classList.add("show")}E.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const v=["DC"],m=y.substring(2);for(let r=0;r<m.length;r+=5)v.push(m.substring(r,r+5));y=v.join("-")}this._prevLength=y.length,this.value=y}),N.addEventListener("click",A),S.addEventListener("click",w),E.addEventListener("keydown",y=>{y.key==="Enter"&&A()}),wireModal(b,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},B().then(y=>{$&&z($)})})();
|
||||
@@ -164,6 +164,7 @@
|
||||
<div class="tools-section-items">
|
||||
<button id="manage-tokens" aria-label="Manage API tokens">🔑 Tokens</button>
|
||||
<button id="backup-restore-btn" aria-label="Backup and restore">💾 Backup</button>
|
||||
<button id="snapshot-btn" aria-label="Container snapshots">💾 Snapshots</button>
|
||||
<button id="license-btn" aria-label="License management">🔑 License</button>
|
||||
<button id="api-docs-btn" aria-label="API documentation">📖 API</button>
|
||||
<button id="help-errors-btn" aria-label="Troubleshooting guide">❓ Help</button>
|
||||
@@ -247,6 +248,29 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Service Filter Bar -->
|
||||
<div id="service-filter-bar" style="display: flex; gap: 12px; align-items: center; margin-bottom: 16px; padding: 12px 16px; background: var(--card-base); border: 1px solid var(--border); border-radius: var(--radius); flex-wrap: wrap;">
|
||||
<input type="text" id="service-filter-search" placeholder="🔍 Filter services..." style="flex: 1; min-width: 180px; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;" />
|
||||
<select id="service-filter-status" style="padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="all">All Status</option>
|
||||
<option value="on">🟢 Online</option>
|
||||
<option value="off">🔴 Offline</option>
|
||||
</select>
|
||||
<button id="batch-operations-btn" class="btn-sm" style="padding: 8px 12px;">☰ Batch Operations</button>
|
||||
<span id="service-filter-count" style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;"></span>
|
||||
</div>
|
||||
|
||||
<!-- Batch Operations Bar (hidden by default) -->
|
||||
<div id="batch-action-bar" style="display: none; margin-bottom: 16px; padding: 12px 16px; background: var(--accent-bg, #1a3a5c); border: 1px solid var(--accent, #3498db); border-radius: var(--radius);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<span id="batch-selected-count" style="font-weight: 500;">0 selected</span>
|
||||
<button id="batch-start-btn" class="btn-sm" style="background: var(--ok-fg, #27ae60);">▶ Start All</button>
|
||||
<button id="batch-stop-btn" class="btn-sm" style="background: var(--bad-fg, #e74c3c);">⬛ Stop All</button>
|
||||
<button id="batch-restart-btn" class="btn-sm" style="background: #f39c12;">🔄 Restart All</button>
|
||||
<button id="batch-cancel-btn" class="btn-sm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- App/service grid -->
|
||||
<div id="cards" class="grid"></div>
|
||||
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// ========== BATCH CONTAINER OPERATIONS ==========
|
||||
(function() {
|
||||
const batchBtn = document.getElementById('batch-operations-btn');
|
||||
const batchBar = document.getElementById('batch-action-bar');
|
||||
const batchCount = document.getElementById('batch-selected-count');
|
||||
const startBtn = document.getElementById('batch-start-btn');
|
||||
const stopBtn = document.getElementById('batch-stop-btn');
|
||||
const restartBtn = document.getElementById('batch-restart-btn');
|
||||
const cancelBtn = document.getElementById('batch-cancel-btn');
|
||||
|
||||
let batchMode = false;
|
||||
let selectedContainers = new Set();
|
||||
|
||||
function enterBatchMode() {
|
||||
batchMode = true;
|
||||
selectedContainers.clear();
|
||||
batchBar.style.display = '';
|
||||
batchBtn.textContent = '✓ Exit Batch Mode';
|
||||
updateSelectedCount();
|
||||
|
||||
// Add checkboxes to all cards with containerId
|
||||
const cards = document.querySelectorAll('#cards .card[data-app]');
|
||||
cards.forEach(card => {
|
||||
const containerId = card.dataset.containerId;
|
||||
if (!containerId) return;
|
||||
|
||||
// Remove existing checkbox if any
|
||||
const existing = card.querySelector('.batch-checkbox');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.className = 'batch-checkbox';
|
||||
checkbox.dataset.containerId = containerId;
|
||||
checkbox.dataset.serviceName = card.querySelector('.name')?.textContent || containerId;
|
||||
checkbox.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;';
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
e.stopPropagation();
|
||||
if (checkbox.checked) {
|
||||
selectedContainers.add(containerId);
|
||||
} else {
|
||||
selectedContainers.delete(containerId);
|
||||
}
|
||||
updateSelectedCount();
|
||||
});
|
||||
card.style.position = 'relative';
|
||||
card.insertBefore(checkbox, card.firstChild);
|
||||
});
|
||||
}
|
||||
|
||||
function exitBatchMode() {
|
||||
batchMode = false;
|
||||
selectedContainers.clear();
|
||||
batchBar.style.display = 'none';
|
||||
batchBtn.textContent = '☰ Batch Operations';
|
||||
|
||||
// Remove all checkboxes
|
||||
document.querySelectorAll('.batch-checkbox').forEach(cb => cb.remove());
|
||||
}
|
||||
|
||||
function updateSelectedCount() {
|
||||
const count = selectedContainers.size;
|
||||
batchCount.textContent = `${count} selected`;
|
||||
startBtn.disabled = count === 0;
|
||||
stopBtn.disabled = count === 0;
|
||||
restartBtn.disabled = count === 0;
|
||||
}
|
||||
|
||||
async function batchAction(action) {
|
||||
if (selectedContainers.size === 0) return;
|
||||
|
||||
const containers = Array.from(selectedContainers);
|
||||
const actionLabel = { start: 'Starting', stop: 'Stopping', restart: 'Restarting' }[action];
|
||||
|
||||
if (!confirm(`${actionLabel} ${containers.length} container(s)? This cannot be undone.`)) return;
|
||||
|
||||
const btns = [startBtn, stopBtn, restartBtn];
|
||||
btns.forEach(b => { b.disabled = true; b.textContent = '...'; });
|
||||
|
||||
let success = 0;
|
||||
let failed = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const containerId of containers) {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/containers/${encodeURIComponent(containerId)}/${action}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (res.ok) {
|
||||
success++;
|
||||
} else {
|
||||
failed++;
|
||||
const data = await res.json().catch(() => ({}));
|
||||
errors.push(`${containerId}: ${data.error || res.statusText}`);
|
||||
}
|
||||
} catch (e) {
|
||||
failed++;
|
||||
errors.push(`${containerId}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore buttons
|
||||
btns[0].textContent = '▶ Start All';
|
||||
btns[1].textContent = '⬛ Stop All';
|
||||
btns[2].textContent = '🔄 Restart All';
|
||||
updateSelectedCount();
|
||||
|
||||
// Show results
|
||||
if (failed === 0) {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${actionLabel} completed: ${success} container(s)`, 'success');
|
||||
}
|
||||
} else {
|
||||
if (typeof showNotification === 'function') {
|
||||
showNotification(`${actionLabel}: ${success} succeeded, ${failed} failed`, 'warning');
|
||||
}
|
||||
console.error('Batch operation errors:', errors);
|
||||
}
|
||||
|
||||
// Refresh dashboard after a short delay
|
||||
setTimeout(() => {
|
||||
if (typeof refreshAll === 'function') refreshAll();
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
batchBtn?.addEventListener('click', () => {
|
||||
if (batchMode) {
|
||||
exitBatchMode();
|
||||
} else {
|
||||
enterBatchMode();
|
||||
}
|
||||
});
|
||||
|
||||
startBtn?.addEventListener('click', () => batchAction('start'));
|
||||
stopBtn?.addEventListener('click', () => batchAction('stop'));
|
||||
restartBtn?.addEventListener('click', () => batchAction('restart'));
|
||||
cancelBtn?.addEventListener('click', exitBatchMode);
|
||||
})();
|
||||
@@ -0,0 +1,57 @@
|
||||
// ========== SERVICE FILTER ==========
|
||||
(function() {
|
||||
const searchInput = document.getElementById('service-filter-search');
|
||||
const statusSelect = document.getElementById('service-filter-status');
|
||||
const countSpan = document.getElementById('service-filter-count');
|
||||
|
||||
function updateFilter() {
|
||||
const query = searchInput.value.toLowerCase().trim();
|
||||
const statusFilter = statusSelect.value; // 'all', 'on', or 'off'
|
||||
|
||||
const cards = document.querySelectorAll('#cards .card');
|
||||
let visibleCount = 0;
|
||||
|
||||
cards.forEach(card => {
|
||||
const name = card.querySelector('.name')?.textContent?.toLowerCase() || '';
|
||||
const app = card.dataset.app?.toLowerCase() || '';
|
||||
const status = card.dataset.status || 'off'; // 'on' or 'off'
|
||||
|
||||
const matchesSearch = !query || name.includes(query) || app.includes(query);
|
||||
const matchesStatus = statusFilter === 'all' || status === statusFilter;
|
||||
|
||||
if (matchesSearch && matchesStatus) {
|
||||
card.style.display = '';
|
||||
visibleCount++;
|
||||
} else {
|
||||
card.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
if (countSpan) {
|
||||
const total = cards.length;
|
||||
countSpan.textContent = `${visibleCount} of ${total} services`;
|
||||
}
|
||||
}
|
||||
|
||||
// Debounce helper
|
||||
function debounce(fn, delay) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => fn.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
searchInput?.addEventListener('input', debounce(updateFilter, 200));
|
||||
statusSelect?.addEventListener('change', updateFilter);
|
||||
|
||||
// Initial count on page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => setTimeout(updateFilter, 500));
|
||||
} else {
|
||||
setTimeout(updateFilter, 500);
|
||||
}
|
||||
|
||||
// Expose for external triggers
|
||||
window.refreshServiceFilter = updateFilter;
|
||||
})();
|
||||
@@ -0,0 +1,180 @@
|
||||
// ========== CONTAINER SNAPSHOT / CHECKPOINT ==========
|
||||
(function() {
|
||||
// Inject modal HTML
|
||||
injectModal('snapshot-modal', `<div id="snapshot-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
||||
<h3>💾 Container Snapshots</h3>
|
||||
<p class="modal-subtitle">
|
||||
Create and manage Docker container checkpoints for instant state recovery.
|
||||
</p>
|
||||
|
||||
<div id="snapshot-container-select-wrapper" style="margin-bottom: 16px;">
|
||||
<label style="font-size: 0.85rem; color: var(--muted);">Select Container:</label>
|
||||
<select id="snapshot-container-select" style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem;">
|
||||
<option value="">-- Select a container --</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-details" style="display: none; margin-bottom: 16px; padding: 12px; border: 1px solid var(--border); border-radius: 8px; background: var(--card-hover);">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 0.85rem;">
|
||||
<div><span style="color: var(--muted);">Image:</span> <span id="snapshot-image"></span></div>
|
||||
<div><span style="color: var(--muted);">Status:</span> <span id="snapshot-status"></span></div>
|
||||
<div><span style="color: var(--muted);">Created:</span> <span id="snapshot-created"></span></div>
|
||||
<div><span style="color: var(--muted);">Container ID:</span> <span id="snapshot-id" style="font-family: monospace;"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel-tabs" style="margin-bottom: 12px;">
|
||||
<button class="panel-tab active" data-panel="snapshot-create">Create Snapshot</button>
|
||||
<button class="panel-tab" data-panel="snapshot-list">Manage Snapshots</button>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-create" class="panel-section active">
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label style="font-size: 0.85rem; color: var(--muted);">Snapshot Name:</label>
|
||||
<input type="text" id="snapshot-name" placeholder="e.g., before-update-2024"
|
||||
style="width: 100%; padding: 8px 12px; margin-top: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 0.9rem; box-sizing: border-box;" />
|
||||
</div>
|
||||
<div style="margin-bottom: 12px;">
|
||||
<label class="checkbox-label" style="font-size: 0.85rem;">
|
||||
<input type="checkbox" id="snapshot-leave-running" checked />
|
||||
Leave container running after checkpoint (resume without restart)
|
||||
</label>
|
||||
</div>
|
||||
<button id="snapshot-create-btn" class="btn-accent-solid" style="width: 100%;">💾 Create Snapshot</button>
|
||||
<div id="snapshot-create-status" style="margin-top: 12px; text-align: center; font-size: 0.85rem;"></div>
|
||||
</div>
|
||||
|
||||
<div id="snapshot-list" class="panel-section" style="display: none;">
|
||||
<div id="snapshot-list-container" class="scroll-container" style="max-height: 300px;">
|
||||
<div class="panel-empty"><span class="empty-icon">💾</span>Select a container to view its snapshots</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="snapshot-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);
|
||||
|
||||
const modal = document.getElementById('snapshot-modal');
|
||||
const openBtn = document.getElementById('snapshot-btn');
|
||||
const closeBtn = document.getElementById('snapshot-close');
|
||||
const containerSelect = document.getElementById('snapshot-container-select');
|
||||
const detailsDiv = document.getElementById('snapshot-details');
|
||||
const createBtn = document.getElementById('snapshot-create-btn');
|
||||
const createStatus = document.getElementById('snapshot-create-status');
|
||||
|
||||
let currentContainerId = null;
|
||||
|
||||
async function loadContainers() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/containers');
|
||||
const data = await res.json();
|
||||
if (!data.success || !data.containers) return;
|
||||
|
||||
containerSelect.innerHTML = '<option value="">-- Select a container --</option>';
|
||||
for (const c of data.containers) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c.id;
|
||||
opt.textContent = `${c.name || c.id} (${c.image || 'unknown'})`;
|
||||
opt.dataset.name = c.name;
|
||||
opt.dataset.image = c.image;
|
||||
opt.dataset.status = c.status;
|
||||
opt.dataset.created = c.created;
|
||||
containerSelect.appendChild(opt);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load containers:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showContainerDetails(opt) {
|
||||
if (!opt || !opt.value) {
|
||||
detailsDiv.style.display = 'none';
|
||||
currentContainerId = null;
|
||||
return;
|
||||
}
|
||||
currentContainerId = opt.value;
|
||||
document.getElementById('snapshot-image').textContent = opt.dataset.image || '-';
|
||||
document.getElementById('snapshot-status').textContent = opt.dataset.status || '-';
|
||||
document.getElementById('snapshot-created').textContent = opt.dataset.created ? new Date(opt.dataset.created * 1000).toLocaleString() : '-';
|
||||
document.getElementById('snapshot-id').textContent = opt.value.substring(0, 12);
|
||||
detailsDiv.style.display = '';
|
||||
}
|
||||
|
||||
async function createSnapshot() {
|
||||
if (!currentContainerId) {
|
||||
createStatus.textContent = 'Please select a container first';
|
||||
createStatus.style.color = 'var(--bad-fg)';
|
||||
return;
|
||||
}
|
||||
const name = document.getElementById('snapshot-name').value.trim();
|
||||
if (!name) {
|
||||
createStatus.textContent = 'Please enter a snapshot name';
|
||||
createStatus.style.color = 'var(--bad-fg)';
|
||||
return;
|
||||
}
|
||||
const leaveRunning = document.getElementById('snapshot-leave-running').checked;
|
||||
|
||||
createBtn.disabled = true;
|
||||
createBtn.textContent = 'Creating...';
|
||||
createStatus.textContent = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/containers/${encodeURIComponent(currentContainerId)}/checkpoint`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, leaveRunning })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
createStatus.textContent = `✓ Snapshot "${name}" created successfully`;
|
||||
createStatus.style.color = 'var(--ok-fg)';
|
||||
document.getElementById('snapshot-name').value = '';
|
||||
} else {
|
||||
createStatus.textContent = `✗ Failed: ${data.error || 'Unknown error'}`;
|
||||
createStatus.style.color = 'var(--bad-fg)';
|
||||
}
|
||||
} catch (e) {
|
||||
createStatus.textContent = `✗ Error: ${e.message}`;
|
||||
createStatus.style.color = 'var(--bad-fg)';
|
||||
} finally {
|
||||
createBtn.disabled = false;
|
||||
createBtn.textContent = '💾 Create Snapshot';
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
modal.classList.add('show');
|
||||
loadContainers();
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modal.classList.remove('show');
|
||||
detailsDiv.style.display = 'none';
|
||||
currentContainerId = null;
|
||||
containerSelect.selectedIndex = 0;
|
||||
}
|
||||
|
||||
openBtn?.addEventListener('click', openModal);
|
||||
closeBtn?.addEventListener('click', closeModal);
|
||||
wireModal(modal, closeBtn);
|
||||
|
||||
containerSelect?.addEventListener('change', (e) => {
|
||||
const opt = containerSelect.options[containerSelect.selectedIndex];
|
||||
showContainerDetails(opt);
|
||||
});
|
||||
|
||||
createBtn?.addEventListener('click', createSnapshot);
|
||||
|
||||
// Tab switching
|
||||
modal?.querySelectorAll('.panel-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
modal.querySelectorAll('.panel-tab').forEach(t => t.classList.remove('active'));
|
||||
modal.querySelectorAll('.panel-section').forEach(s => s.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
modal.querySelector(`#${tab.dataset.panel}`).classList.add('active');
|
||||
});
|
||||
});
|
||||
})();
|
||||
Reference in new issue
Block a user