DC-048: multi-user bootstrap + admin invites (opt-in)
Implements the user-store + invite-store + admin routes. The whole system is opt-in via siteConfig.authProviders.email.enabled = true; single-user TOTP-only installs see zero behavior change. Backend: - src/security/user-store.js: users + allowlist + bootstrap sentinel, atomic writes, last-admin protection, defensive dataDir resolver. - src/security/invite-store.js: single-use tokens (SHA-256 hashed on disk), TTL, auto-prune, defensive dataDir resolver. - routes/auth/admin.js: /me, /admin/users (CRUD), /admin/allowlist, /admin/invites (CRUD), public /invites/:token (peek + accept). - routes/auth/index.js: wires userStore, gates admin router on email auth being enabled. - src/auth/providers/email.js: verify() enforces allowlist, creates user record, tags req.user; default-enabled flipped to opt-in. - src/auth/providers/totp.js: bootstraps system@totp.local admin on first verify so current DNS2 operator shows in /admin/users. - src/security/audit-logger.js: middleware adds userId/userEmail/ userRole/viaProvider to log details when req.user is tagged. - PUBLIC_ROUTES + CSRF allowlists updated for invite redemption. Frontend: - status/js/admin.js: modal overlay with users list (role-edit, delete), invite form (email/role/TTL), copy-link button, outstanding-invites list with revoke. Exports window.AdminPanel. - status/js/core/init.js: calls AdminPanel.attachTrigger so the Admin button only appears when /me returns isAdmin=true. Tests: 35 new tests across 3 files (user-store, invite-store, auth multistore integration). Full suite: 1298/1298 passing. Docs: BACKLOG.md marks DC-048 done. CHANGELOG.md [Unreleased] section gets the DC-048 entry.
This commit is contained in:
23 files changed
+2964
-325
No files matched your search
@@ -30,6 +30,10 @@ const bundles = {
|
||||
JS('totp-recovery.js'),
|
||||
JS('service-credentials.js'),
|
||||
JS('totp-settings.js'),
|
||||
// DC-048 admin panel — modal-overlay UI for user/invite management.
|
||||
// Renders the "Admin" trigger button into the top bar; only visible
|
||||
// when /api/v1/auth/me returns isAdmin=true.
|
||||
JS('admin.js'),
|
||||
JS('core', 'credentials.js'),
|
||||
JS('core', 'grid.js'),
|
||||
JS('core', 'dns.js'),
|
||||
|
||||
Vendored
+127
-93
@@ -1,4 +1,4 @@
|
||||
(function(a){"use strict";class y{constructor(){this.errors=[],this.maxErrors=50}logError(v,i,s={}){const m={timestamp:new Date().toISOString(),context:v,message:i instanceof Error?i.message:i,stack:i instanceof Error?i.stack:null,metadata:s};this.errors.push(m),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${v}:`,i,s)}recoverFromError(v,i){switch(this.classifyError(v)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",v,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",v),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",v),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",v,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",v),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",v,{currentStep:i}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(v){const i=v.message||v.toString();return i.includes("element")&&i.includes("not found")?"ELEMENT_NOT_FOUND":i.includes("storage")||i.includes("quota")?"STORAGE_UNAVAILABLE":i.includes("driver")||i.includes("undefined")?"DRIVER_NOT_LOADED":i.includes("invalid")||i.includes("validation")?"INVALID_TOOLTIP":i.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const v={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(i=>{v.byContext[i.context]=(v.byContext[i.context]||0)+1;const s=this.classifyError({message:i.message});v.byType[s]=(v.byType[s]||0)+1}),v}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const v=document.createElement("div");return v.id="onboarding-fallback",v.style.cssText=`
|
||||
(function(l){"use strict";class n{constructor(){this.errors=[],this.maxErrors=50}logError(b,f,u={}){const v={timestamp:new Date().toISOString(),context:b,message:f instanceof Error?f.message:f,stack:f instanceof Error?f.stack:null,metadata:u};this.errors.push(v),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${b}:`,f,u)}recoverFromError(b,f){switch(this.classifyError(b)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Target element not found, skipping to next step"};case"STORAGE_UNAVAILABLE":return this.logError("Storage Unavailable",b),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",b),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",b,{currentStep:f}),{action:"SKIP_STEP",nextStep:f+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",b),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",b,{currentStep:f}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(b){const f=b.message||b.toString();return f.includes("element")&&f.includes("not found")?"ELEMENT_NOT_FOUND":f.includes("storage")||f.includes("quota")?"STORAGE_UNAVAILABLE":f.includes("driver")||f.includes("undefined")?"DRIVER_NOT_LOADED":f.includes("invalid")||f.includes("validation")?"INVALID_TOOLTIP":f.includes("theme")?"THEME_DETECTION_FAILED":"UNKNOWN"}getErrors(){return[...this.errors]}clearErrors(){this.errors=[]}getStatistics(){const b={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(f=>{b.byContext[f.context]=(b.byContext[f.context]||0)+1;const u=this.classifyError({message:f.message});b.byType[u]=(b.byType[u]||0)+1}),b}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const b=document.createElement("div");return b.id="onboarding-fallback",b.style.cssText=`
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
@@ -10,44 +10,78 @@
|
||||
z-index: 9999;
|
||||
max-width: 300px;
|
||||
font-size: 14px;
|
||||
`,v.innerHTML=`
|
||||
`,b.innerHTML=`
|
||||
<strong>Welcome to DashCaddy!</strong><br>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px;">
|
||||
The interactive tour is unavailable, but you can explore the dashboard freely.
|
||||
Check the documentation for help getting started.
|
||||
</p>
|
||||
`,document.body.appendChild(v),setTimeout(()=>{v.parentNode&&v.parentNode.removeChild(v)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const v={data:{},getItem(i){return this.data[i]||null},setItem(i,s){this.data[i]=s},removeItem(i){delete this.data[i]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),v}sendToErrorTracking(v){}}a.ErrorHandler=y,console.log("[ErrorHandler] Module loaded")})(window);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 v=await fetch("/api/v1/config");if(v.ok){const i=await v.json();if(i.tld&&(SITE.tld=i.tld.startsWith(".")?i.tld:"."+i.tld),i.dns&&(SITE.dnsIp=i.dns.ip||"",SITE.dnsPort=i.dns.port||DC.DEFAULTS.DNS_PORT),i.dnsServers&&typeof i.dnsServers=="object")for(const[m,t]of Object.entries(i.dnsServers))m!=="__proto__"&&m!=="constructor"&&m!=="prototype"&&(SITE.dnsServers[m]=t);i.configurationType&&(SITE.configurationType=i.configurationType),i.domain&&(SITE.domain=i.domain),i.defaults&&(SITE.defaults=i.defaults),i.routingMode&&(SITE.routingMode=i.routingMode),SITE.onboardingCompleted=i.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const s=document.getElementById("manage-tokens");s&&(s.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(v=>v.textContent=SITE.tld);const y=document.getElementById("edit-tld-suffix");y&&(y.textContent=SITE.tld);const p=document.getElementById("external-proxy-ip");p&&SITE.dnsIp&&(p.value=SITE.dnsIp,p.placeholder=SITE.dnsIp)})();function buildDomain(a){return a+SITE.tld}function buildServiceUrl(a){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+a:SITE.configurationType==="public"&&SITE.domain?"https://"+a+"."+SITE.domain:"https://"+buildDomain(a)}function getDnsServerAddr(a){const y=SITE.dnsServers[a];return y?`${y.ip}:${y.port}`:buildDomain(a)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[a,y]of Object.entries(SITE.dnsServers))if(y.ip===SITE.dnsIp)return a;return null}function renderDnsCards(){const a=document.querySelector(".top");if(!a)return;const y=Object.keys(SITE.dnsServers);if(!y.length)return;const p='<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>',v=a.firstElementChild;y.forEach(i=>{const s=escapeHtml(i),m=escapeHtml((SITE.dnsServers[i].name||i).toUpperCase()),t=document.createElement("div");t.className="card",t.setAttribute("data-app",i),t.setAttribute("data-status","off"),t.innerHTML=`<span id="${s}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${p}</div><span class="name">${m}</span><span class="spacer"></span><span id="${s}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${s}-time" class="response-time">--</span></div><div class="health-row" id="health-${s}"><span id="uptime-${s}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${s}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${s}-restart" class="restart-btn">Restart</button><button id="${s}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${s}-open">Open</button><button id="${s}-logs" class="logs-btn">Logs</button><button id="${s}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,a.insertBefore(t,v)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const a=await fetch("/api/v1/csrf-token");if(!a.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await a.jsLine truncated
|
||||
`,document.body.appendChild(b),setTimeout(()=>{b.parentNode&&b.parentNode.removeChild(b)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const b={data:{},getItem(f){return this.data[f]||null},setItem(f,u){this.data[f]=u},removeItem(f){delete this.data[f]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),b}sendToErrorTracking(b){}}l.ErrorHandler=n,console.log("[ErrorHandler] Module loaded")})(window);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 b=await fetch("/api/v1/config");if(b.ok){const f=await b.json();if(f.tld&&(SITE.tld=f.tld.startsWith(".")?f.tld:"."+f.tld),f.dns&&(SITE.dnsIp=f.dns.ip||"",SITE.dnsPort=f.dns.port||DC.DEFAULTS.DNS_PORT),f.dnsServers&&typeof f.dnsServers=="object")for(const[v,s]of Object.entries(f.dnsServers))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(SITE.dnsServers[v]=s);f.configurationType&&(SITE.configurationType=f.configurationType),f.domain&&(SITE.domain=f.domain),f.defaults&&(SITE.defaults=f.defaults),f.routingMode&&(SITE.routingMode=f.routingMode),SITE.onboardingCompleted=f.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const u=document.getElementById("manage-tokens");u&&(u.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(b=>b.textContent=SITE.tld);const n=document.getElementById("edit-tld-suffix");n&&(n.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(l){return l+SITE.tld}function buildServiceUrl(l){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+l:SITE.configurationType==="public"&&SITE.domain?"https://"+l+"."+SITE.domain:"https://"+buildDomain(l)}function getDnsServerAddr(l){const n=SITE.dnsServers[l];return n?`${n.ip}:${n.port}`:buildDomain(l)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[l,n]of Object.entries(SITE.dnsServers))if(n.ip===SITE.dnsIp)return l;return null}function renderDnsCards(){const l=document.querySelector(".top");if(!l)return;const n=Object.keys(SITE.dnsServers);if(!n.length)return;const g='<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>',b=l.firstElementChild;n.forEach(f=>{const u=escapeHtml(f),v=escapeHtml((SITE.dnsServers[f].name||f).toUpperCase()),s=document.createElement("div");s.className="card",s.setAttribute("data-app",f),s.setAttribute("data-status","off"),s.innerHTML=`<span id="${u}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${g}</div><span class="name">${v}</span><span class="spacer"></span><span id="${u}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${u}-time" class="response-time">--</span></div><div class="health-row" id="health-${u}"><span id="uptime-${u}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${u}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${u}-restart" class="restart-btn">Restart</button><button id="${u}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${u}-open">Open</button><button id="${u}-logs" class="logs-btn">Logs</button><button id="${u}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,l.insertBefore(s,b)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const l=await fetch("/api/v1/csrf-token");if(!l.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await l.jsLine truncated
|
||||
position: fixed; top: 20px; right: 20px;
|
||||
background: ${s.bg}; color: ${s.fg};
|
||||
background: ${u.bg}; color: ${u.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(m),p>0&&setTimeout(()=>m.remove(),p)}function timeAgo(a){const y=Date.now()-new Date(a).getTime();return y<6e4?"just now":y<36e5?Math.floor(y/6e4)+"m ago":y<864e5?Math.floor(y/36e5)+"h ago":Math.floor(y/864e5)+"d ago"}function safeGet(a,y=null){try{const p=localStorage.getItem(a);return p!==null?p:y}catch{return y}}function safeSet(a,y){try{localStorage.setItem(a,y)}catch{}}function safeRemove(a){try{localStorage.removeItem(a)}catch{}}function safeSessionGet(a,y=null){try{const p=sessionStorage.getItem(a);return p!==null?p:y}catch{return y}}function safeSessionSet(a,y){try{sessionStorage.setItem(a,y)}catch{}}function safeGetJSON(a,y=null){try{const p=localStorage.getItem(a);return p?JSON.parse(p):y}catch{return y}}function escapeHtml(a){return String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(a,y){document.getElementById(a)||document.body.insertAdjacentHTML("beforeend",y)}const DC_BUS={_handlers:{},on(a,y){var p;((p=this._handlers)[a]||(p[a]=[])).push(y)},off(a,y){this._handlers[a]=this._handlers[a]?.filter(p=>p!==y)},emit(a,y){this._handlers[a]?.forEach(p=>p(y))}},AppState={_apps:[],getApps(){return this._apps},setApps(a){this._apps=a,window.APPS=a,DC_BUS.emit("apps:changed",a)},findApp(a){return this._apps.find(y=>y.id===a)},addApp(a){this._apps.push(a),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(a){const y=this._apps.findIndex(p=>p.id===a);return y>-1&&(this._apps.splice(y,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),y>-1},updateApp(a,y){const p=this._apps.find(v=>v.id===a);if(p){for(const[v,i]of Object.entries(y))v!=="__proto__"&&v!=="constructor"&&v!=="prototype"&&(p[v]=i);DC_BUS.emit("apps:changed",this._apps)}return p}};(function(){function a(){const v=document.createElement("div");return v.className="skeleton-card",v.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>',v}function y(v){const i=document.getElementById("cards");if(!(!i||i.querySelector(".card"))){v=v||6;for(let s=0;s<v;s++){const m=a();i.appendChild(m),setTimeout(function(){m.classList.add("loaded")},s*60)}}}function p(){const v=document.getElementById("cards");if(v){var i=v.querySelectorAll(".skeleton-card");i.length&&(i.forEach(function(s,m){setTimeout(function(){s.style.opacity="0",s.style.transform="translateY(-10px)"},m*25)}),setTimeout(function(){i.forEach(function(s){s.parentNode&&s.remove()})},i.length*25+300))}}window.SkeletonLoader={show:y,hide:p}})(),(function(){var a="theme",y="user-themes",p="custom-theme",v=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],i=v.slice(),s=["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"],m=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],t=["fg-muted","hover","card-hover","base","success","error","warning"],h={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(v),g>0&&setTimeout(()=>v.remove(),g)}function timeAgo(l){const n=Date.now()-new Date(l).getTime();return n<6e4?"just now":n<36e5?Math.floor(n/6e4)+"m ago":n<864e5?Math.floor(n/36e5)+"h ago":Math.floor(n/864e5)+"d ago"}function safeGet(l,n=null){try{const g=localStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSet(l,n){try{localStorage.setItem(l,n)}catch{}}function safeRemove(l){try{localStorage.removeItem(l)}catch{}}function safeSessionGet(l,n=null){try{const g=sessionStorage.getItem(l);return g!==null?g:n}catch{return n}}function safeSessionSet(l,n){try{sessionStorage.setItem(l,n)}catch{}}function safeGetJSON(l,n=null){try{const g=localStorage.getItem(l);return g?JSON.parse(g):n}catch{return n}}function escapeHtml(l){return String(l??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(l,n){document.getElementById(l)||document.body.insertAdjacentHTML("beforeend",n)}const DC_BUS={_handlers:{},on(l,n){var g;((g=this._handlers)[l]||(g[l]=[])).push(n)},off(l,n){this._handlers[l]=this._handlers[l]?.filter(g=>g!==n)},emit(l,n){this._handlers[l]?.forEach(g=>g(n))}},AppState={_apps:[],getApps(){return this._apps},setApps(l){this._apps=l,window.APPS=l,DC_BUS.emit("apps:changed",l)},findApp(l){return this._apps.find(n=>n.id===l)},addApp(l){this._apps.push(l),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(l){const n=this._apps.findIndex(g=>g.id===l);return n>-1&&(this._apps.splice(n,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),n>-1},updateApp(l,n){const g=this._apps.find(b=>b.id===l);if(g){for(const[b,f]of Object.entries(n))b!=="__proto__"&&b!=="constructor"&&b!=="prototype"&&(g[b]=f);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function l(){const b=document.createElement("div");return b.className="skeleton-card",b.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>',b}function n(b){const f=document.getElementById("cards");if(!(!f||f.querySelector(".card"))){b=b||6;for(let u=0;u<b;u++){const v=l();f.appendChild(v),setTimeout(function(){v.classList.add("loaded")},u*60)}}}function g(){const b=document.getElementById("cards");if(b){var f=b.querySelectorAll(".skeleton-card");f.length&&(f.forEach(function(u,v){setTimeout(function(){u.style.opacity="0",u.style.transform="translateY(-10px)"},v*25)}),setTimeout(function(){f.forEach(function(u){u.parentNode&&u.remove()})},f.length*25+300))}}window.SkeletonLoader={show:n,hide:g}})(),(function(){var l="theme",n="user-themes",g="custom-theme",b=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],f=b.slice(),u=["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"],v=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],s=["fg-muted","hover","card-hover","base","success","error","warning"],y={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(`+w.r+","+w.g+","+w.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+w.r+","+w.g+","+w.b+`, .05), transparent 55%),
|
||||
radial-gradient(1200px 800px at 10% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .08), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% 10%, rgba(`+x.r+","+x.g+","+x.b+`, .05), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`:":root."+C+` body {
|
||||
`:":root."+E+` body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 8% -12%, rgba(`+w.r+","+w.g+","+w.b+`, .10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+w.r+","+w.g+","+w.b+`, .07), transparent 55%),
|
||||
radial-gradient(1200px 900px at 8% -12%, rgba(`+x.r+","+x.g+","+x.b+`, .10), transparent 60%),
|
||||
radial-gradient(1000px 700px at 110% -10%, rgba(`+x.r+","+x.g+","+x.b+`, .07), transparent 55%),
|
||||
var(--bg);
|
||||
}
|
||||
`}function f(C,B){var $=B.lightBg||B.bg&&g(B.bg)>.4;return $?":root."+C+` button:hover {
|
||||
`}function i(E,B){var $=B.lightBg||B.bg&&d(B.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."+C+` 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 n(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function u(){s.forEach(function(C){document.documentElement.style.removeProperty("--"+C)})}function b(C,B){var $=C.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),v.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(y,{}),w=$,I=2;x[$]&&$!==B;)$=w+"-"+I++;return $}function d(C){var B=document.getElementById("user-theme-styles");B&&B.remove(),i.length=v.length,Object.keys(h).forEach(function(k){v.indexOf(k)===-1&&delete h[k]});var $=C||safeGetJSON(y,{}),x=Object.keys($);if(x=x.filter(function(k){return v.indexOf(k)===-1}),!!x.length){var w="";x.forEach(function(k){var S=$[k];i.indexOf(k)===-1&&i.push(k);var A={};s.forEach(function(D){S[D]&&(A[D]=S[D])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var O=e(A);t.forEach(function(D){!A[D]&&O[D]&&(A[D]=O[D])}),h[k]=A,w+=":root."+k+` {
|
||||
`,s.forEach(function(D){A[D]&&(w+=" --"+D+": "+A[D]+`;
|
||||
`)}),w+=`}
|
||||
`,w+=r(k,A),w+=f(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=w,document.head.appendChild(I)}}function E(){secureFetch("/api/v1/themes").then(function(C){return C.json()}).then(function(C){if(!(!C.success||!C.themes)){var B=C.themes,$=safeGetJSON(y,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(y,JSON.stringify(B)),d(B);var x=safeGet(a);x&&i.indexOf(x)!==-1&&L(x)}}}).catch(function(){})}function T(){var C=safeGetJSON(p);if(C){var B=C.name||"Custom",$=b(B),x={name:B};s.forEach(function(k){C[k]&&(x[k]=C[k])});var w=safeGetJSON(y,{});w[$]=x,safeSet(y,JSON.stringify(w)),safeGet(a)==="custom"&&safeSet(a,$),safeRemove(p);var I={};s.forEach(function(k){x[k]&&(I[k]=x[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(C){document.documentElement.classList.add("theme-transitioning"),i.forEach(function(w){w!=="dark"&&document.documentElement.classList.remove(w)}),u(),C!=="dark"&&document.documentElement.classList.add(C),safeSet(a,C);var B=h[C],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var x=B&&B.lightBg;!x&&B&&B.bg&&(x=g(B.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),d();var P=safeGet(a);P==="red"&&(P="black",safeSet(a,"black")),P&&P!=="dark"&&i.indexOf(P)===-1&&(P=null),L(P||n()),E(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(C){safeGet(a)||L(C.matches?"dark":"light")}),window.THEMES=i,window.BUILTIN_THEMES=v,window.THEME_COLORS=h,window.THEME_PROPS=s,window.BASE_PROPS=m,window.DERIVED_PROPS=t,window.USER_THEMES_KEY=y,window.applyTheme=L,window.clearCustomProperties=u,window.injectUserThemeStyles=d,window.syncThemesFromServer=E,window.slugifyThemeName=b,window.getActiveTheme=function(){return safeGet(a)||n()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=o,window.blendColors=l})(),(function(){function a(){const m=document.querySelector(".totp-card");if(!m)return;const h=getComputedStyle(m).backgroundColor.match(/\d+/g);if(!h)return;const c=(.299*+h[0]+.587*+h[1]+.114*+h[2])/255,o=m.querySelector(".totp-logo-dark"),l=m.querySelector(".totp-logo-light");o&&(o.style.display=c>.5?"none":""),l&&(l.style.display=c>.5?"":"none")}function y(){const m=document.getElementById("totp-overlay");if(m){m.classList.add("show"),setTimeout(a,50);const t=m.querySelector(".totp-digits input");t&&setTimeout(()=>t.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function p(){const m=document.getElementById("totp-overlay");m&&m.classList.remove("show")}const v=document.getElementById("totp-digits");if(v){const m=v.querySelectorAll("input");m.forEach((t,h)=>{t.addEventListener("input",c=>{const o=c.target.value.replace(/\D/g,"");c.target.value=o.slice(0,1),o&&h<m.length-1&&m[h+1].focus();const l=Array.from(m).map(g=>g.value).join("");l.length===6&&i(l)}),t.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&h>0&&(m[h-1].focus(),m[h-1].value="")}),t.addEventListener("paste",c=>{c.preventDefault();const o=(c.clipboardData.getData("text")||"").replace(/\D/g,"");o.length>=6&&(m.forEach((l,g)=>{l.value=o[g]||""}),m[5].focus(),i(o.slice(0,6)))})})}async function i(m){const t=document.getElementById("totp-error");t.textContent="Verifying...",t.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:m})})).json();if(c.success){t.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),p();const o=safeSessionGet("totp_redirect");if(o){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=o;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{t.textContent=c.error||"Invalid code",t.className="totp-error";const o=document.querySelectorAll("#totp-digits input");o.forEach(l=>{l.value=""}),o[0]?.focus()}}catch{t.textContent="Connection error",t.className="totp-error"}}const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const m=s.get("return");if(m)try{const t=new URL(m,window.location.origin),h=t.hostname,c=t.origin===window.location.origin,o=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,l=h.endsWith(o)||h===o.substring(1);(c||l)&&safeSessionSet("totp_redirect",m)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=y})(),(function(){"use strict";async function a(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function y(m){const t=document.getElementById("totp-recovery-link");t&&(t.style.display=m?"":"none")}function p(
Line truncated
|
||||
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function a(){u.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function h(E,B){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),b.indexOf($)!==-1&&($=$+"-custom");for(var C=safeGetJSON(n,{}),x=$,I=2;C[$]&&$!==B;)$=x+"-"+I++;return $}function p(E){var B=document.getElementById("user-theme-styles");B&&B.remove(),f.length=b.length,Object.keys(y).forEach(function(k){b.indexOf(k)===-1&&delete y[k]});var $=E||safeGetJSON(n,{}),C=Object.keys($);if(C=C.filter(function(k){return b.indexOf(k)===-1}),!!C.length){var x="";C.forEach(function(k){var S=$[k];f.indexOf(k)===-1&&f.push(k);var A={};u.forEach(function(O){S[O]&&(A[O]=S[O])}),A["card-bg"]=S["card-base"]||S.bg,S.lightBg&&(A.lightBg=!0);var D=e(A);s.forEach(function(O){!A[O]&&D[O]&&(A[O]=D[O])}),y[k]=A,x+=":root."+k+` {
|
||||
`,u.forEach(function(O){A[O]&&(x+=" --"+O+": "+A[O]+`;
|
||||
`)}),x+=`}
|
||||
`,x+=o(k,A),x+=i(k,A)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=x,document.head.appendChild(I)}}function w(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var B=E.themes,$=safeGetJSON(n,{});if(JSON.stringify(B)!==JSON.stringify($)){safeSet(n,JSON.stringify(B)),p(B);var C=safeGet(l);C&&f.indexOf(C)!==-1&&L(C)}}}).catch(function(){})}function T(){var E=safeGetJSON(g);if(E){var B=E.name||"Custom",$=h(B),C={name:B};u.forEach(function(k){E[k]&&(C[k]=E[k])});var x=safeGetJSON(n,{});x[$]=C,safeSet(n,JSON.stringify(x)),safeGet(l)==="custom"&&safeSet(l,$),safeRemove(g);var I={};u.forEach(function(k){C[k]&&(I[k]=C[k])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,colors:I})}).catch(function(){})}}function L(E){document.documentElement.classList.add("theme-transitioning"),f.forEach(function(x){x!=="dark"&&document.documentElement.classList.remove(x)}),a(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(l,E);var B=y[E],$=document.querySelector('meta[name="theme-color"]');$&&B&&$.setAttribute("content",B.bg);var C=B&&B.lightBg;!C&&B&&B.bg&&(C=d(B.bg)>.4),C?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}T(),p();var P=safeGet(l);P==="red"&&(P="black",safeSet(l,"black")),P&&P!=="dark"&&f.indexOf(P)===-1&&(P=null),L(P||t()),w(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(l)||L(E.matches?"dark":"light")}),window.THEMES=f,window.BUILTIN_THEMES=b,window.THEME_COLORS=y,window.THEME_PROPS=u,window.BASE_PROPS=v,window.DERIVED_PROPS=s,window.USER_THEMES_KEY=n,window.applyTheme=L,window.clearCustomProperties=a,window.injectUserThemeStyles=p,window.syncThemesFromServer=w,window.slugifyThemeName=h,window.getActiveTheme=function(){return safeGet(l)||t()},window.deriveExtendedColors=e,window.hexToRgb=c,window.rgbToHex=r,window.blendColors=m})(),(function(){let l=null;async function n(){if(l)return l;try{const y=await fetch("/api/v1/auth/login/methods",{cache:"no-store"});if(!y.ok)throw new Error(`methods HTTP ${y.status}`);const c=await y.json();return l=Array.isArray(c.providers)?c.providers:[],l}catch(y){return console.warn("[auth-gate] methods fetch failed; falling back to TOTP-only",y),[]}}function g(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(!r)return;const m=r.innerHTML;r.dataset.originalBody||(r.dataset.originalBody=m);const d=y.map(e=>{const o=e.config&&(e.config.label||e.name)||e.name;return`<button class="provider-btn" data-provider="${e.name}"
|
||||
style="margin: 6px 4px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem;">
|
||||
Sign in with ${o}
|
||||
</button>`}).join(`
|
||||
`);r.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<p class="subtitle">Choose how to sign in</p>
|
||||
<div id="auth-gate-providers" style="margin: 14px 0 10px;">${d}</div>
|
||||
<div id="auth-gate-message" style="margin-top: 8px; color: var(--muted); font-size: 0.8rem;"></div>
|
||||
`,c.classList.add("show"),r.querySelectorAll(".provider-btn").forEach(e=>{e.addEventListener("click",()=>{const o=e.dataset.provider,i=y.find(t=>t.name===o);f(i)})})}function b(){const y=document.getElementById("totp-overlay");if(!y)return;const c=y.querySelector(".totp-card");!c||!c.dataset.originalBody||(c.innerHTML=c.dataset.originalBody,window.location.reload())}function f(y){const c=document.getElementById("totp-overlay");if(!c)return;const r=c.querySelector(".totp-card");if(r){if(y.name==="totp"){window.location.reload();return}if(y.name==="email"){r.innerHTML=`
|
||||
<img class="totp-logo totp-logo-dark" src="/assets/dashcaddy-logo-dark.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<img class="totp-logo totp-logo-light" src="/assets/dashcaddy-logo-light.png" alt="DashCaddy" onerror="this.style.display='none'">
|
||||
<p class="subtitle">Sign in with email</p>
|
||||
<p style="margin: 6px 0 14px; font-size: 0.85rem; color: var(--muted);">
|
||||
We'll email you a one-time sign-in link.
|
||||
</p>
|
||||
<input id="auth-gate-email-input" type="email" inputmode="email" autocomplete="email"
|
||||
placeholder="you@example.com"
|
||||
style="width: 100%; padding: 10px 12px; font-size: 1rem; background: var(--input-bg); color: var(--text); border: 1px solid var(--border); border-radius: 6px; box-sizing: border-box;">
|
||||
<button id="auth-gate-email-submit" class="provider-btn"
|
||||
style="margin-top: 14px; padding: 10px 16px; background: var(--accent); color: white; border: 0; border-radius: 6px; cursor: pointer; font-size: 0.95rem; width: 100%;">
|
||||
Send sign-in link
|
||||
</button>
|
||||
<div id="auth-gate-email-status" style="margin-top: 12px; font-size: 0.85rem; line-height: 1.4;"></div>
|
||||
<div style="margin-top: 16px;">
|
||||
<a href="#" id="auth-gate-back" style="color: var(--muted); font-size: 0.8rem;">\u2190 Back</a>
|
||||
</div>
|
||||
`,c.classList.add("show");const m=r.querySelector("#auth-gate-email-input"),d=r.querySelector("#auth-gate-email-submit"),e=r.querySelector("#auth-gate-email-status"),o=r.querySelector("#auth-gate-back");d.addEventListener("click",async()=>{const i=(m.value||"").trim();if(!i||!i.includes("@")){e.textContent="Enter a valid email address.",e.style.color="var(--error, #d33)";return}d.disabled=!0,e.textContent="Sending\u2026",e.style.color="";try{const a=await(await fetch("/api/v1/auth/login/email/initiate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:i})})).json();if(a.success){const h=a.deliveredVia||"email",p=a.maskedEmail||i;h==="dev-console"?e.innerHTML=`
|
||||
Check the server logs for your one-time link.
|
||||
(Dev mode: no SMTP configured. In production this would email <strong>${p}</strong>.)`:e.innerHTML=`Sign-in link sent to <strong>${p}</strong>.
|
||||
Check your inbox (and spam folder).`,e.style.color="var(--success, #2a7)"}else e.textContent=a.error||"Could not send link.",e.style.color="var(--error, #d33)",d.disabled=!1}catch{e.textContent="Connection error. Try again.",e.style.color="var(--error, #d33)",d.disabled=!1}}),m.addEventListener("keydown",i=>{i.key==="Enter"&&d.click()}),o.addEventListener("click",i=>{i.preventDefault(),g(providers)});return}console.warn("[auth-gate] unknown provider",y.name),window.location.reload()}}async function u(){if(!document.getElementById("totp-overlay"))return;const c=await n();if(c.length===0){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}if(c.length===1&&c[0].name==="totp"){v(c[0]);return}g(c)}function v(y){const c=l&&l.find(e=>e.name==="email");if(!c){typeof window._showTotpOverlay=="function"&&window._showTotpOverlay();return}const r=document.getElementById("totp-overlay"),m=r.querySelector(".totp-card");if(!m)return;if(m.dataset.originalBody||(m.dataset.originalBody=m.innerHTML),!m.querySelector("#auth-gate-email-alt")){const e=document.createElement("div");e.id="auth-gate-email-alt",e.style.cssText="margin-top: 18px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 0.85rem;",e.innerHTML=`<a href="#" id="auth-gate-email-alt-link"
|
||||
style="color: var(--accent); text-decoration: none;">
|
||||
Or sign in with email instead \u2192
|
||||
</a>`,m.appendChild(e),e.querySelector("#auth-gate-email-alt-link").addEventListener("click",o=>{o.preventDefault(),f(c)})}r.classList.add("show")}window.__dc_049_handled=!0;const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const y=s.get("return");if(y)try{if(new URL(y,window.location.origin).origin===window.location.origin)try{sessionStorage.setItem("totp_redirect",y)}catch{}}catch{}window.history.replaceState({},"",window.location.pathname),setTimeout(u,0)}window._showAuthGate=u,window._authGateMethods=n})(),(function(){function l(){const v=document.querySelector(".totp-card");if(!v)return;const y=getComputedStyle(v).backgroundColor.match(/\d+/g);if(!y)return;const c=(.299*+y[0]+.587*+y[1]+.114*+y[2])/255,r=v.querySelector(".totp-logo-dark"),m=v.querySelector(".totp-logo-light");r&&(r.style.display=c>.5?"none":""),m&&(m.style.display=c>.5?"":"none")}function n(){const v=document.getElementById("totp-overlay");if(v){v.classList.add("show"),setTimeout(l,50);const s=v.querySelector(".totp-digits input");s&&setTimeout(()=>s.focus(),100)}typeof window._refreshRecoveryLink=="function"&&window._refreshRecoveryLink()}function g(){const v=document.getElementById("totp-overlay");v&&v.classList.remove("show")}const b=document.getElementById("totp-digits");if(b){const v=b.querySelectorAll("input");v.forEach((s,y)=>{s.addEventListener("input",c=>{const r=c.target.value.replace(/\D/g,"");c.target.value=r.slice(0,1),r&&y<v.length-1&&v[y+1].focus();const m=Array.from(v).map(d=>d.value).join("");m.length===6&&f(m)}),s.addEventListener("keydown",c=>{c.key==="Backspace"&&!c.target.value&&y>0&&(v[y-1].focus(),v[y-1].value="")}),s.addEventListener("paste",c=>{c.preventDefault();const r=(c.clipboardData.getData("text")||"").replace(/\D/g,"");r.length>=6&&(v.forEach((m,d)=>{m.value=r[d]||""}),v[5].focus(),f(r.slice(0,6)))})})}async function f(v){const s=document.getElementById("totp-error");s.textContent="Verifying...",s.className="totp-error verifying";try{const c=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:v})})).json();if(c.success){s.textContent="",c.csrfToken&&(csrfToken=c.csrfToken),g();const r=safeSessionGet("totp_redirect");if(r){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=r;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{s.textContent=c.error||"Invalid code",s.className="totp-error";const r=document.querySelectorAll("#totp-digits input");r.forEach(m=>{m.value=""}),r[0]?.focus()}}catch{s.textContent="Connection error",s.className="totp-error"}}if(!!!window.__dc_049_handled&&urlParams.get("auth")==="required"){const v=urlParams.get("return");if(v)try{const s=new URL(v,window.location.origin),y=s.hostname,c=s.origin===window.location.origin,r=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,m=y.endsWith(r)||y===r.substring(1);(c||m)&&safeSessionSet("totp_redirect",v)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=n})(),(function(){"use strict";async function l(){try{return await(await fetch("/api/v1/totp/recovery-info",{cache:"no-store"})).json()}catch{return{success:!1,status:"unknown",hint:"Could not contact server"}}}function n(v){const s=document.getElementById("totp-recovery-link");s&&(s.style.display=v?"":"none")}function g(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="");const s=document.getElementById("totp-recovery-status"),y=document.getElementById("totp-recovery-import"),c=document.getElementById("totp-recovery-verify");y&&(y.style.display=""),c&&(c.style.display="none"),document.getElementById("totp-recovery-error").textContent="",document.getElementById("totp-recovery-confirm-error").textContent="",document.getElementById("totp-recovery-secret").value="",document.getElementById("totp-recovery-code").value="",l().then(r=>{s.textContent=r.hint||"",r.status==="healthy"?s.style.borderColor="var(--ok-fg, #7ef2ff)":r.status==="unreadable"?(s.style.borderColor="var(--bad-fg, #ff9aa3)",s.style.background="color-mix(in srgb, var(--bad-fg) 6%, transparent)"):r.status==="not_configured"?s.style.borderColor="var(--muted)":s.style.borderColor="var(--border)"}),setTimeout(()=>{document.getElementById("totp-recovery-secret")?.focus()},100)}function b(){const v=document.getElementById("totp-recovery-panel");v&&(v.style.display="none")}async function f(){const v=document.getElementById("totp-recovery-secret").value.trim(),s=document.getElementById("totp-recovery-error");if(s.textContent="",!v){s.textContent="Paste your Base32 key first";return}if(!/^[A-Za-z2-7\s]+=*$/.test(v)){s.textContent="Invalid Base32 format \u2014 should be letters A-Z and digits 2-7 only";return}try{const y=await fetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:v})}),c=await y.json();if(!y.ok||!c.success){s.textContent=c.Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
||||
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
||||
|
||||
@@ -145,7 +179,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const y=document.getElementById("service-creds-modal");let p=null;const v=["sonarr","radarr","prowlarr","overseerr"],i=["sonarr","radarr"];function s(o){return o.externalUrl||o.url||""}function m(o){const l=document.getElementById("svc-creds-error");l.textContent=o,l.style.display=""}function t(){const o=document.getElementById("svc-creds-error");o.textContent="",o.style.display="none"}window.openServiceCredsModal=async function(o){p=o,t();const l=document.getElementById("svc-creds-title"),g=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),r=document.getElementById("svc-creds-apikey"),f=document.getElementById("svc-creds-basic"),n=document.getElementById("svc-creds-quality");l.textContent=o.name+" Credentials";const u=!!o.isExternal,b=v.includes(o.id)||v.includes(o.appTemplate),d=i.includes(o.id)||i.includes(o.appTemplate);e.style.display=u?"":"none",r.style.display=b?"":"none",n.style.display=d?"":"none",f.style.display=u?"none":"";const E=document.getElementById("svc-quality-select");E.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",u?(g.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${o.name}`):b?g.textContent="API key bypasses the app login screen automatically.":g.textContent="Credentials are injected automatically when accessing this service.",await h(o),y.classList.add("show")};async function h(o){const l=document.getElementById("svc-creds-dot"),g=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let r=!1;if(o.isExternal){try{const u=await(await fetch(`/api/v1/seedhost-creds?serviceId=${o.id}`)).json();u.success?(document.getElementById("svc-seedhost-user").value=u.username||"",u.hasCredentials&&(r=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const u=await(await fetch(`/api/v1/services/${o.id}/credentials`)).json();u.success&&(u.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",r=!0):document.getElementById("svc-apikey-input").value="",u.hasBasicAuth&&!o.isExternal?(document.getElementById("svc-basic-user").value=u.username||"",r=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const f=o.id||o.appTemplate;if(i.includes(f)&&await c(o),r){l.style.background="var(--ok-fg, #74dfc4)",g.style.color="var(--ok-fg, #74dfc4)",g.textContent="Credentials stored",e.style.display="";const n=document.getElementById(`creds-btn-${o.id}`);n&&n.classList.add("has-creds")}else l.style.background="var(--muted)",g.style.color="var(--muted)",g.textContent="No credentials stored",e.style.display="none"}async function c(o){const l=document.getElementById("svc-quality-select"),g=document.getElementById("svc-quality-status"),e=o.id||o.appTemplate,r=s(o);if(!r){l.innerHTML='<option value="">-- No service URL --</option>';return}l.innerHTML='<option value="">Loading...</option>',g.textContent="";try{const f=new URLSearchParams({service:e,url:r}),u=await(await fetch(`/api/v1/arr/quality-profiles?${f}`)).json();if(!u.success||!u.profiles?.length){l.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}l.innerHTML="";for(const b of u.profiles){const d=document.createElement("option");d.value=b.id,d.textContent=b.name,l.appendChild(d)}if(u.storedProfileId&&(l.value=String(u.storedProfileId)),!l.value){const b=u.profiles.find(d=>/720/i.test(d.name));b&&(l.value=String(b.id))}!l.value&&u.profiles.length&&(l.value=String(u.profiles[0].id)),g.innerHTML=`<span style="color: var(--ok-fg);">${u.profiles.length} profiles loaded</span>`}catch(f){l.innerHTML='<option value="">-- Failed to load --</option>',g.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${f.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!p)return;const o=p.id||p.appTemplate,l=s(p),e=document.getElementById("svc-apikey-input")?.value.trim(),r=document.getElementById("svc-quality-select"),f=document.getElementById("svc-quality-status");if(!l){f.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){f.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}r.innerHTML='<option value="">Fetching...</option>',f.textContent="";try{const n=new URLSearchParams({service:o,url:l,apiKey:e}),b=await(await fetch(`/api/v1/arr/quality-profiles?${n}`)).json();if(!b.success){r.innerHTML='<option value="">-- Error --</option>',f.innerHTML=`<span style="color: var(--error, #c62828);">${b.error||"Failed to feLine truncated
|
||||
</div>`);const n=document.getElementById("service-creds-modal");let g=null;const b=["sonarr","radarr","prowlarr","overseerr"],f=["sonarr","radarr"];function u(r){return r.externalUrl||r.url||""}function v(r){const m=document.getElementById("svc-creds-error");m.textContent=r,m.style.display=""}function s(){const r=document.getElementById("svc-creds-error");r.textContent="",r.style.display="none"}window.openServiceCredsModal=async function(r){g=r,s();const m=document.getElementById("svc-creds-title"),d=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),o=document.getElementById("svc-creds-apikey"),i=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");m.textContent=r.name+" Credentials";const a=!!r.isExternal,h=b.includes(r.id)||b.includes(r.appTemplate),p=f.includes(r.id)||f.includes(r.appTemplate);e.style.display=a?"":"none",o.style.display=h?"":"none",t.style.display=p?"":"none",i.style.display=a?"none":"";const w=document.getElementById("svc-quality-select");w.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",a?(d.textContent="Seedhost credentials auto-login past the HTTP prompt. API key bypasses the app login.",document.getElementById("svc-seedhost-pass").placeholder=`Password for ${r.name}`):h?d.textContent="API key bypasses the app login screen automatically.":d.textContent="Credentials are injected automatically when accessing this service.",await y(r),n.classList.add("show")};async function y(r){const m=document.getElementById("svc-creds-dot"),d=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let o=!1;if(r.isExternal){try{const a=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();a.success?(document.getElementById("svc-seedhost-user").value=a.username||"",a.hasCredentials&&(o=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const a=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();a.success&&(a.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",o=!0):document.getElementById("svc-apikey-input").value="",a.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=a.username||"",o=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const i=r.id||r.appTemplate;if(f.includes(i)&&await c(r),o){m.style.background="var(--ok-fg, #74dfc4)",d.style.color="var(--ok-fg, #74dfc4)",d.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${r.id}`);t&&t.classList.add("has-creds")}else m.style.background="var(--muted)",d.style.color="var(--muted)",d.textContent="No credentials stored",e.style.display="none"}async function c(r){const m=document.getElementById("svc-quality-select"),d=document.getElementById("svc-quality-status"),e=r.id||r.appTemplate,o=u(r);if(!o){m.innerHTML='<option value="">-- No service URL --</option>';return}m.innerHTML='<option value="">Loading...</option>',d.textContent="";try{const i=new URLSearchParams({service:e,url:o}),a=await(await fetch(`/api/v1/arr/quality-profiles?${i}`)).json();if(!a.success||!a.profiles?.length){m.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}m.innerHTML="";for(const h of a.profiles){const p=document.createElement("option");p.value=h.id,p.textContent=h.name,m.appendChild(p)}if(a.storedProfileId&&(m.value=String(a.storedProfileId)),!m.value){const h=a.profiles.find(p=>/720/i.test(p.name));h&&(m.value=String(h.id))}!m.value&&a.profiles.length&&(m.value=String(a.profiles[0].id)),d.innerHTML=`<span style="color: var(--ok-fg);">${a.profiles.length} profiles loaded</span>`}catch(i){m.innerHTML='<option value="">-- Failed to load --</option>',d.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${i.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!g)return;const r=g.id||g.appTemplate,m=u(g),e=document.getElementById("svc-apikey-input")?.value.trim(),o=document.getElementById("svc-quality-select"),i=document.getElementById("svc-quality-status");if(!m){i.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){i.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}o.innerHTML='<option value="">Fetching...</option>',i.textContent="";try{const t=new URLSearchParams({service:r,url:m,apiKey:e}),h=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!h.success){o.innerHTML='<option value="">-- Error --</option>',i.innerHTML=`<span style="color: var(--error, #c62828);">${h.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>
|
||||
|
||||
@@ -254,7 +288,7 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);async function y(){try{const s=await(await fetch("/api/v1/totp/config")).json();if(!s.success)return;const{enabled:m,sessionDuration:t,isSetUp:h}=s.config,c=document.getElementById("totp-status-dot"),o=document.getElementById("totp-status-text"),l=document.getElementById("totp-status-banner"),g=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),r=document.getElementById("totp-duration-section"),f=document.getElementById("totp-disable-section");if(m&&h){c.style.background="var(--ok-fg, #7ef2ff)",l.style.borderColor="var(--ok-fg, #7ef2ff)",l.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",o.textContent="TOTP is active",o.style.color="var(--ok-fg, #7ef2ff)",g.style.display="block";const n=document.getElementById("totp-setup-btn");n&&(n.textContent="Generate New Secret"),e.style.display="none",r.style.display="block",f.style.display="block",document.getElementById("totp-duration-select").value=t}else c.style.background="var(--muted)",l.style.borderColor="var(--border)",l.style.background="transparent",o.textContent="TOTP is not configured",o.style.color="var(--muted)",g.style.display="block",e.style.display="none",r.style.display="none",f.style.display="none";v(m&&h,t)}catch(i){console.warn("Failed to load TOTP settings:",i)}}const p={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function v(i,s){const m=document.getElementById("auth-card"),t=document.getElementById("auth-pill"),h=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");m&&(i?(m.setAttribute("data-status","on"),t.className="badge on",t.textContent="YES",h.className="dot ok at-bl",c.textContent="Session: "+(p[s]||s)):(m.setAttribute("data-status","off"),t.className="badge off",t.textContent="NO",h.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();s.success&&(document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.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(i){a.logError("[TOTP] Setup Failed",i,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const i=document.getElementById("totp-import-key").value.trim(),s=document.getElementById("totp-import-error");if(s.textContent="",!i){s.textContent="Paste a Base32 secret key first";return}try{const t=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:i})})).json();t.success?(s.textContent="",document.getElementById("totp-qr-image").src=t.qrCode,document.getElementById("totp-manual-key").textContent=t.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()):s.textContent=t.error||t.message||"Import failed"}catch{s.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const i=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(i).then(()=>{const s=document.getElementById("totp-copy-key");s.textContent="\u2705",setTimeout(()=>{s.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const i=document.getElementById("totp-manual-key").textContent.trim();if(!i)return;const s={service:"DashCaddy",type:"totp-secret",secret:i,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},m=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),t=URL.createObjectURL(m),h=document.createElement("a");h.href=t,h.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(h),h.click(),document.body.removeChild(h),URL.revokeObjectURL(t);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const i=doLine truncated
|
||||
</div>`);async function n(){try{const u=await(await fetch("/api/v1/totp/config")).json();if(!u.success)return;const{enabled:v,sessionDuration:s,isSetUp:y}=u.config,c=document.getElementById("totp-status-dot"),r=document.getElementById("totp-status-text"),m=document.getElementById("totp-status-banner"),d=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),o=document.getElementById("totp-duration-section"),i=document.getElementById("totp-disable-section");if(v&&y){c.style.background="var(--ok-fg, #7ef2ff)",m.style.borderColor="var(--ok-fg, #7ef2ff)",m.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",r.textContent="TOTP is active",r.style.color="var(--ok-fg, #7ef2ff)",d.style.display="block";const t=document.getElementById("totp-setup-btn");t&&(t.textContent="Generate New Secret"),e.style.display="none",o.style.display="block",i.style.display="block",document.getElementById("totp-duration-select").value=s}else c.style.background="var(--muted)",m.style.borderColor="var(--border)",m.style.background="transparent",r.textContent="TOTP is not configured",r.style.color="var(--muted)",d.style.display="block",e.style.display="none",o.style.display="none",i.style.display="none";b(v&&y,s)}catch(f){console.warn("Failed to load TOTP settings:",f)}}const g={"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 b(f,u){const v=document.getElementById("auth-card"),s=document.getElementById("auth-pill"),y=document.getElementById("auth-dot"),c=document.getElementById("auth-status-text");v&&(f?(v.setAttribute("data-status","on"),s.className="badge on",s.textContent="YES",y.className="dot ok at-bl",c.textContent="Session: "+(g[u]||u)):(v.setAttribute("data-status","off"),s.className="badge off",s.textContent="NO",y.className="dot bad at-bl",c.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const u=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();u.success&&(document.getElementById("totp-qr-image").src=u.qrCode,document.getElementById("totp-manual-key").textContent=u.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(f){l.logError("[TOTP] Setup Failed",f,{function:"setupTOTP"})}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const f=document.getElementById("totp-import-key").value.trim(),u=document.getElementById("totp-import-error");if(u.textContent="",!f){u.textContent="Paste a Base32 secret key first";return}try{const s=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:f})})).json();s.success?(u.textContent="",document.getElementById("totp-qr-image").src=s.qrCode,document.getElementById("totp-manual-key").textContent=s.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()):u.textContent=s.error||s.message||"Import failed"}catch{u.textContent="Connection error \u2014 try refreshing the page"}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(f).then(()=>{const u=document.getElementById("totp-copy-key");u.textContent="\u2705",setTimeout(()=>{u.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-download-backup")?.addEventListener("click",()=>{const f=document.getElementById("totp-manual-key").textContent.trim();if(!f)return;const u={service:"DashCaddy",type:"totp-secret",secret:f,issuer:"DashCaddy",algorithm:"SHA1",digits:6,period:30,issued:new Date().toISOString(),recovery_url:`${window.location.origin}/ (login screen \u2192 "Lost access?")`,note:'Keep this file somewhere safe. Anyone with this secret can generate your login codes. Use it ONLY to recover TOTP access via the "Lost access?" link on the DashCaddy login screen.'},v=new Blob([JSON.stringify(u,null,2)],{type:"application/json"}),s=URL.createObjectURL(v),y=document.createElement("a");y.href=s,y.download=`dashcaddy-totp-backup-${new Date().toISOString().slice(0,10)}.json`,document.body.appendChild(y),y.click(),document.body.removeChild(y),URL.revokeObjectURL(s);const c=document.getElementById("totp-download-backup");c.textContent="\u2705 Saved",setTimeout(()=>{c.textContent="\u2B07 Download"},2e3)}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const f=doLine 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>
|
||||
@@ -272,40 +306,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);function a(){return Object.keys(SITE.dnsServers||{})}function y(n){return(SITE.dnsServers||{})[n]?.name||n.toUpperCase()}function p(){const n=document.getElementById("dns-cred-sections");if(!n)return;n.innerHTML="";const u=a();if(u.length===0){n.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const b of u)n.insertAdjacentHTML("beforeend",`
|
||||
`);function l(){return Object.keys(SITE.dnsServers||{})}function n(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function g(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const a=l();if(a.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const h of a)t.insertAdjacentHTML("beforeend",`
|
||||
<div class="token-section">
|
||||
<h4 class="token-section-title">${y(b)}</h4>
|
||||
<h4 class="token-section-title">${n(h)}</h4>
|
||||
<div class="token-grid">
|
||||
<div class="token-field">
|
||||
<label for="${b}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${b}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${h}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
||||
<input type="text" id="${h}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${b}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${b}-readonly-token">\u{1F441}</button>
|
||||
<input type="password" id="${h}-readonly-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${h}-readonly-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-field">
|
||||
<label for="${b}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${b}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<label for="${h}-admin-username">\u{1F527} Admin:</label>
|
||||
<input type="text" id="${h}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
||||
<div class="token-input-row">
|
||||
<input type="password" id="${b}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${b}-admin-token">\u{1F441}</button>
|
||||
<input type="password" id="${h}-admin-token" placeholder="Password" autocomplete="off" />
|
||||
<button type="button" class="token-toggle" data-target="${h}-admin-token">\u{1F441}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="token-status" id="${b}-token-status"></div>
|
||||
<div class="token-status" id="${h}-token-status"></div>
|
||||
</div>
|
||||
`)}function v(){let n=safeSessionGet("dashcaddy-encryption-key");if(n)return n;const u=safeGet("dashcaddy-encryption-key");if(u)return safeSessionSet("dashcaddy-encryption-key",u),safeRemove("dashcaddy-encryption-key"),u;const b=new Uint8Array(32);return crypto.getRandomValues(b),n=Array.from(b,d=>d.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",n),n}const i=v();function s(n,u){if(!n)return"";const b=crypto.getRandomValues(new Uint8Array(8)),d=Array.from(b,L=>L.toString(16).padStart(2,"0")).join(""),E=new TextEncoder().encode(u+d);let T="";for(let L=0;L<n.length;L++){const P=n.charCodeAt(L)^E[L%E.length]^b[L%b.length]^L*31+17&255;T+=String.fromCharCode(P)}return d+":"+btoa(T)}function m(n,u){if(!n)return"";try{if(n.indexOf(":")===16){const T=n.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(n.substring(17)),C=new TextEncoder().encode(u+T);let B="";for(let $=0;$<P.length;$++){const x=P.charCodeAt($)^C[$%C.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(x)}return B}const d=atob(n);let E="";for(let T=0;T<d.length;T++){const L=d.charCodeAt(T)^u.charCodeAt(T%u.length);E+=String.fromCharCode(L)}return E}catch{return""}}function t(n,u,b){const d=safeGet(`${n}-${u}-${b}-enc`);return m(d,i)}function h(n,u,b,d){const E=`${n}-${u}-${b}-enc`;d?safeSet(E,s(d,i)):safeRemove(E)}function c(n,u){return t(n,u,"token")}function o(n,u){return t(n,u,"username")}function l(n,u,b){h(n,u,"token",b)}function g(n,u,b){h(n,u,"username",b)}function e(){const n={};for(const u of a())n[u]={readonly:{username:o(u,"readonly"),token:c(u,"readonly")},admin:{username:o(u,"admin"),token:c(u,"admin")}};return n}function r(){a().forEach(n=>{["readonly","admin"].forEach(u=>{["token","username"].forEach(b=>{safeRemove(`${n}-${u}-${b}-enc`)})}),safeRemove(`${n}-token-enc`),safeRemove(`${n}-username-enc`)})}function f(n){const u=c(n,"readonly"),b=o(n,"readonly"),d=c(n,"admin"),E=o(n,"admin"),T=m(safeGet(`${n}-token-enc`),i),L=m(safeGet(`${n}-username-enc`),i);return{username:E||b||L,token:d||u||T,readonlyToken:u||T,readonlyUsername:b||L,adminToken:d||T,adminUsername:E||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{p();const n=document.getElementById("token-management-modal"),u=e();a().forEach(b=>{const d=u[b];document.getElementById(`${b}-readonly-username`).value=d.readonly.username,document.getElementById(`${b}-readonly-token`).value=d.readonly.token,document.getElementById(`${b}-admin-username`).value=d.admin.username,document.getElementById(`${b}-admin-token`).value=d.admin.token,document.getElementById(`${b}-token-status`).textContent=""}),n.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",n=>{const u=n.target.closest(".token-toggle");if(u){const b=u.dataset.target,d=document.getElementById(b);d.type==="password"?(d.type="text",u.textContent="\u{1F648}"):(d.type="password",u.textContent="\u{1F441}");return}n.target.id==="token-management-modal"&&n.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const n=a();n.forEach(d=>{g(d,"readonly",document.getElementById(`${d}-readonly-username`).value.trim()),l(d,"readonly",document.getElementById(`${d}-readonly-token`).value.trim()),g(d,"admin",document.getElementById(`${d}-admin-username`).value.trim()),l(d,"admin",document.getElementById(`${d}-admin-token`).value.trim())});const u={};let b=!1;if(n.forEach(d=>{const E={},T=document.getElementById(`${d}-readonly-username`).value.trim(),L=document.getElementById(`${d}-readonly-token`).value.trim(),P=document.getElementById(`${d}-admin-username`).value.trim(),C=document.getElementById(`${d}-admin-token`).value.trim();T&&L&&(E.readonly={username:T,password:L},b=!0),P&&C&&(E.admin={username:P,password:C},b=!0),Object.keys(E).length>0&&(u[d]=E)}),b){n.forEach(d=>{u[d]&&(document.getElementById(`${d}-token-status`).textContent="Verifying...",document.getElementById(`${d}-token-status`).className="token-status")});try{const E=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:u})})).json();E.results?n.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!u[T]){L.textContent="";return}const P=E.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):E.success?n.forEach(T=>{u[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):n.forEach(T=>{u[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(E.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(d){console.erro
Line truncated
|
||||
`)}function b(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const a=safeGet("dashcaddy-encryption-key");if(a)return safeSessionSet("dashcaddy-encryption-key",a),safeRemove("dashcaddy-encryption-key"),a;const h=new Uint8Array(32);return crypto.getRandomValues(h),t=Array.from(h,p=>p.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const f=b();function u(t,a){if(!t)return"";const h=crypto.getRandomValues(new Uint8Array(8)),p=Array.from(h,L=>L.toString(16).padStart(2,"0")).join(""),w=new TextEncoder().encode(a+p);let T="";for(let L=0;L<t.length;L++){const P=t.charCodeAt(L)^w[L%w.length]^h[L%h.length]^L*31+17&255;T+=String.fromCharCode(P)}return p+":"+btoa(T)}function v(t,a){if(!t)return"";try{if(t.indexOf(":")===16){const T=t.substring(0,16),L=new Uint8Array(T.match(/.{2}/g).map($=>parseInt($,16))),P=atob(t.substring(17)),E=new TextEncoder().encode(a+T);let B="";for(let $=0;$<P.length;$++){const C=P.charCodeAt($)^E[$%E.length]^L[$%L.length]^$*31+17&255;B+=String.fromCharCode(C)}return B}const p=atob(t);let w="";for(let T=0;T<p.length;T++){const L=p.charCodeAt(T)^a.charCodeAt(T%a.length);w+=String.fromCharCode(L)}return w}catch{return""}}function s(t,a,h){const p=safeGet(`${t}-${a}-${h}-enc`);return v(p,f)}function y(t,a,h,p){const w=`${t}-${a}-${h}-enc`;p?safeSet(w,u(p,f)):safeRemove(w)}function c(t,a){return s(t,a,"token")}function r(t,a){return s(t,a,"username")}function m(t,a,h){y(t,a,"token",h)}function d(t,a,h){y(t,a,"username",h)}function e(){const t={};for(const a of l())t[a]={readonly:{username:r(a,"readonly"),token:c(a,"readonly")},admin:{username:r(a,"admin"),token:c(a,"admin")}};return t}function o(){l().forEach(t=>{["readonly","admin"].forEach(a=>{["token","username"].forEach(h=>{safeRemove(`${t}-${a}-${h}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function i(t){const a=c(t,"readonly"),h=r(t,"readonly"),p=c(t,"admin"),w=r(t,"admin"),T=v(safeGet(`${t}-token-enc`),f),L=v(safeGet(`${t}-username-enc`),f);return{username:w||h||L,token:p||a||T,readonlyToken:a||T,readonlyUsername:h||L,adminToken:p||T,adminUsername:w||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const t=document.getElementById("token-management-modal"),a=e();l().forEach(h=>{const p=a[h];document.getElementById(`${h}-readonly-username`).value=p.readonly.username,document.getElementById(`${h}-readonly-token`).value=p.readonly.token,document.getElementById(`${h}-admin-username`).value=p.admin.username,document.getElementById(`${h}-admin-token`).value=p.admin.token,document.getElementById(`${h}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const a=t.target.closest(".token-toggle");if(a){const h=a.dataset.target,p=document.getElementById(h);p.type==="password"?(p.type="text",a.textContent="\u{1F648}"):(p.type="password",a.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=l();t.forEach(p=>{d(p,"readonly",document.getElementById(`${p}-readonly-username`).value.trim()),m(p,"readonly",document.getElementById(`${p}-readonly-token`).value.trim()),d(p,"admin",document.getElementById(`${p}-admin-username`).value.trim()),m(p,"admin",document.getElementById(`${p}-admin-token`).value.trim())});const a={};let h=!1;if(t.forEach(p=>{const w={},T=document.getElementById(`${p}-readonly-username`).value.trim(),L=document.getElementById(`${p}-readonly-token`).value.trim(),P=document.getElementById(`${p}-admin-username`).value.trim(),E=document.getElementById(`${p}-admin-token`).value.trim();T&&L&&(w.readonly={username:T,password:L},h=!0),P&&E&&(w.admin={username:P,password:E},h=!0),Object.keys(w).length>0&&(a[p]=w)}),h){t.forEach(p=>{a[p]&&(document.getElementById(`${p}-token-status`).textContent="Verifying...",document.getElementById(`${p}-token-status`).className="token-status")});try{const w=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:a})})).json();w.results?t.forEach(T=>{const L=document.getElementById(`${T}-token-status`);if(!a[T]){L.textContent="";return}const P=w.results[T];P?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):P?.partial?(L.textContent="\u2713 "+P.partial,L.className="token-status success"):(L.textContent="\u2717 "+(P?.error||"Login failed"),L.className="token-status error")}):w.success?t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2713 Saved",document.getElementById(`${T}-token-status`).className="token-status success")}):t.forEach(T=>{a[T]&&(document.getElementById(`${T}-token-status`).textContent="\u2717 "+(w.error||"Failed"),document.getElementById(`${T}-token-status`).className="token-status error")})}catch(p){console.erro
Line truncated
|
||||
|
||||
Current: ${g.currentVersion}
|
||||
New: ${g.updateVersion}
|
||||
Current: ${d.currentVersion}
|
||||
New: ${d.updateVersion}
|
||||
|
||||
`+(g.updateTitle?`${g.updateTitle}
|
||||
`+(d.updateTitle?`${d.updateTitle}
|
||||
|
||||
`:"")+`The DNS server will restart during the update.
|
||||
Proceed?`)){c.textContent=o,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const f=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(h)}`,{method:"POST"})).json();if(!f.success)throw new Error(f.error||"Update failed");if(f.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${f.newVersion}`;const n=f.downloadLink?`
|
||||
Download: ${f.downloadLink}`:"",u=f.instructionsLink?`
|
||||
Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} update requires manual installation. Current: ${f.previousVersion} \u2192 ${f.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${t.toUpperCase()} updated successfully! ${f.previousVersion} \u2192 ${f.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(l){console.error("DNS update error:",l),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${t.toUpperCase()}: ${l.message}`,"error"),setTimeout(()=>{c.textContent=o,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",t=>{const h=t.target.closest('[id$="-update"]');if(!h)return;const c=h.id.replace("-update","");SITE.dnsServers[c]&&y(c,SITE.dnsServers[c]?.ip)}),injectModal("dns-settings-modal",`
|
||||
Proceed?`)){c.textContent=r,c.disabled=!1,c.title="Update DNS server";return}c.textContent="\u{1F504}",c.title="Updating...";const i=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(y)}`,{method:"POST"})).json();if(!i.success)throw new Error(i.error||"Update failed");if(i.manualUpdateRequired){c.textContent="\u2B06\uFE0F",c.title=`Update available: ${i.newVersion}`;const t=i.downloadLink?`
|
||||
Download: ${i.downloadLink}`:"",a=i.instructionsLink?`
|
||||
Instructions: ${i.instructionsLink}`:"";showNotification(`${s.toUpperCase()} update requires manual installation. Current: ${i.previousVersion} \u2192 ${i.newVersion}. Please update manually on the host machine.`,"warning",8e3),c.disabled=!1;return}c.textContent="\u2705",c.title="Updated successfully!",showNotification(`${s.toUpperCase()} updated successfully! ${i.previousVersion} \u2192 ${i.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server",window.refreshAll()},1e4)}catch(m){console.error("DNS update error:",m),c.textContent="\u274C",c.title="Update failed",showNotification(`Failed to update ${s.toUpperCase()}: ${m.message}`,"error"),setTimeout(()=>{c.textContent=r,c.disabled=!1,c.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-update"]');if(!y)return;const c=y.id.replace("-update","");SITE.dnsServers[c]&&n(c,SITE.dnsServers[c]?.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>
|
||||
@@ -332,7 +366,7 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
|
||||
<button id="dns-settings-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let p=null;function v(t){p=t;const h=SITE.dnsServers[t]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(h.name||t).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=h.ip||"",document.getElementById("dns-edit-port").value=h.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=h.name||"",c.classList.add("show")}async function i(){if(!p)return;const t=document.getElementById("dns-edit-ip").value.trim(),h=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!t){showNotification("Server IP is required","warning");return}const o={dnsServers:{}};o.dnsServers[p]={ip:t,port:String(h)},c&&(o.dnsServers[p].name=c);try{const g=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).json();g.success?(SITE.dnsServers[p]=o.dnsServers[p],showNotification(`${p.toUpperCase()} settings saved`,"success"),m(),window.refreshAll()):showNotification(g.error||"Failed to save settings","error")}catch(l){showNotification("Failed to save: "+l.message,"error")}}async function s(){if(p&&confirm(`Remove ${p.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const h=await(await secureFetch("/api/v1/config")).json();h.dnsServers&&delete h.dnsServers[p];const o=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:h.dnsServers||{}})})).json();if(o.success){delete SITE.dnsServers[p];const l=document.querySelector(`.top [data-app="${p}"]`);l&&l.remove(),showNotification(`${p.toUpperCase()} removed from dashboard`,"success"),m()}else showNotification(o.error||"Failed to remove","error")}catch(t){showNotification("Failed to remove: "+t.message,"error")}}function m(){closeModal("dns-settings-modal"),p=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",m),document.getElementById("dns-settings-save")?.addEventListener("click",i),document.getElementById("dns-settings-delete")?.addEventListener("click",s),document.getElementById("dns-settings-modal")?.addEventListener("click",t=>{t.target.id==="dns-settings-modal"&&m()}),document.querySelector(".top")?.addEventListener("click",t=>{const h=t.target.closest('[id$="-settings"]');if(!h)return;const c=h.id.replace("-settings","");SITE.dnsServers[c]&&(t.stopPropagation(),v(c))}),document.getElementById("refresh")?.addEventListener("click",window.refreshAll)})(),(function(){injectModal("logs-modal",`
|
||||
</div>`);let g=null;function b(s){g=s;const y=SITE.dnsServers[s]||{},c=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(y.name||s).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=y.ip||"",document.getElementById("dns-edit-port").value=y.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=y.name||"",c.classList.add("show")}async function f(){if(!g)return;const s=document.getElementById("dns-edit-ip").value.trim(),y=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,c=document.getElementById("dns-edit-name").value.trim();if(!s){showNotification("Server IP is required","warning");return}const r={dnsServers:{}};r.dnsServers[g]={ip:s,port:String(y)},c&&(r.dnsServers[g].name=c);try{const d=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)})).json();d.success?(SITE.dnsServers[g]=r.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),v(),window.refreshAll()):showNotification(d.error||"Failed to save settings","error")}catch(m){showNotification("Failed to save: "+m.message,"error")}}async function u(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const y=await(await secureFetch("/api/v1/config")).json();y.dnsServers&&delete y.dnsServers[g];const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:y.dnsServers||{}})})).json();if(r.success){delete SITE.dnsServers[g];const m=document.querySelector(`.top [data-app="${g}"]`);m&&m.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),v()}else showNotification(r.error||"Failed to remove","error")}catch(s){showNotification("Failed to remove: "+s.message,"error")}}function v(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",v),document.getElementById("dns-settings-save")?.addEventListener("click",f),document.getElementById("dns-settings-delete")?.addEventListener("click",u),document.getElementById("dns-settings-modal")?.addEventListener("click",s=>{s.target.id==="dns-settings-modal"&&v()}),document.querySelector(".top")?.addEventListener("click",s=>{const y=s.target.closest('[id$="-settings"]');if(!y)return;const c=y.id.replace("-settings","");SITE.dnsServers[c]&&(s.stopPropagation(),b(c))}),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">
|
||||
@@ -356,66 +390,66 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let a=null,y=null,p=!1,v=null,i=null,s=!1,m=null,t=null,h=!1,c=null,o=!1;async function l(x,w=25){try{const I=getDnsServerAddr(x),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${w}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.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 w=document.createElement("div");if(w.className="log-entry",w.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 w.style.gridTemplateColumns="1fr",w.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,w;const I=g(x.rcode),k=x.rcode==="Refused"||x.rcode==="REFUSED";return w.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; ${k?"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>
|
||||
`,w}async function r(){if(h){await B();return}if(s){await T();return}if(p||!a)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await l(a,x);if(I.error){w.innerHTML=`
|
||||
</div>`);let l=null,n=null,g=!1,b=null,f=null,u=!1,v=null,s=null,y=!1,c=null,r=!1;async function m(C,x=25){try{const I=getDnsServerAddr(C),k=await fetch(`/api/v1/dns/logs?server=${I}&limit=${x}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,server:S.server}:{error:S.error||"Failed to fetch logs"}}else return k.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${k.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function d(C){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"}[C]||"var(--fg)"}function e(C){const x=document.createElement("div");if(x.className="log-entry",x.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;",C.parsed===!1)return x.style.gridTemplateColumns="1fr",x.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(C.raw)}</span>`,x;const I=d(C.rcode),k=C.rcode==="Refused"||C.rcode==="REFUSED";return x.innerHTML=`
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(C.timestamp)}</span>
|
||||
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(C.client)}">${escapeHtml(C.client)}</span>
|
||||
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${k?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(C.domain)}">${escapeHtml(C.domain)}</span>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(C.type)}</span>
|
||||
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(C.rcode)}</span>
|
||||
`,x}async function o(){if(y){await B();return}if(u){await T();return}if(g||!l)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await m(l,C);if(I.error){x.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}w.innerHTML=`
|
||||
</div>`;return}x.innerHTML=`
|
||||
<div style="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 8px 10px; background: var(--card-base); border-bottom: 2px solid var(--border); font-size: 0.75rem; font-weight: 600; color: var(--muted); position: sticky; top: 0;">
|
||||
<span>Time</span>
|
||||
<span>Client</span>
|
||||
<span>Domain</span>
|
||||
<span>Type</span>
|
||||
<span>Status</span>
|
||||
</div>`,I.logs&&I.logs.length>0?I.logs.forEach(k=>{const S=e(k);w.appendChild(S)}):w.innerHTML+=`
|
||||
</div>`,I.logs&&I.logs.length>0?I.logs.forEach(k=>{const S=e(k);x.appendChild(S)}):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No DNS queries logged yet
|
||||
</div>`}catch(I){w.innerHTML=`
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function f(x){a=x,p=!1,s=!1;const w=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${x.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),w.classList.add("show"),r(),y=setInterval(r,DC.POLL.LOGS)}function n(){document.getElementById("logs-modal").classList.remove("show"),y&&(clearInterval(y),y=null),b(),a=null,s=!1,v=null,i=null,h=!1,m=null,t=null,p=!1}function u(x){c&&b();const w=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");y&&(clearInterval(y),y=null);try{c=new EventSource(`/api/v1/logs/stream/${x}`),o=!0,w.classList.add("active"),w.textContent="\u{1F534} Live",w.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const O=JSON.parse(A.data);if(O.error){console.error("Stream error:",O.error),b();return}const D=document.createElement("div");D.className="log-entry",D.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=(O.stream||"stdout")==="stderr",N=R?"var(--bad-fg)":"var(--fg)",F=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(D.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${F}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div>
|
||||
`,k.appendChild(D),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},c.onerror=A=>{console.error("EventSource error:",A),b()}}catch(S){console.error("Failed to start streaming:",S),b()}}function b(){c&&(c.close(),c=null),o=!1;const x=document.getElementById("logs-stream"),w=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"),w&&(w.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),s&&v&&!y&&(y=setInterval(T,DC.POLL.LOGS))}async function d(x,w=100){try{const I=`/api/v1/logs/container/${x}?tail=${w}×tamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function E(x){const w=document.createElement("div");w.className="log-entry",w.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)",k=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 w.innerHTML=`
|
||||
</div>`}}function i(C){l=C,g=!1,u=!1;const x=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),k=document.getElementById("logs-pause"),S=document.getElementById("logs-stream");I.textContent=`${C.toUpperCase()} DNS Logs`,k.textContent="\u23F8\uFE0F Pause",k.classList.remove("paused"),S&&(S.style.display="none"),x.classList.add("show"),o(),n=setInterval(o,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),n&&(clearInterval(n),n=null),h(),l=null,u=!1,b=null,f=null,y=!1,v=null,s=null,g=!1}function a(C){c&&h();const x=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),k=document.getElementById("logs-content");n&&(clearInterval(n),n=null);try{c=new EventSource(`/api/v1/logs/stream/${C}`),r=!0,x.classList.add("active"),x.textContent="\u{1F534} Live",x.title="Streaming - click to stop",I.style.display="none";const S=document.getElementById("logs-title");S.textContent.includes("\u{1F534}")||(S.innerHTML=S.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),c.onmessage=A=>{try{const D=JSON.parse(A.data);if(D.error){console.error("Stream error:",D.error),h();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",N=R?"var(--bad-fg)":"var(--fg)",_=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${N}; 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;">${_}</div>
|
||||
<div style="flex: 1; color: ${N}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
||||
`,k.appendChild(O),k.scrollTop=k.scrollHeight;k.children.length>500;)k.removeChild(k.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},c.onerror=A=>{console.error("EventSource error:",A),h()}}catch(S){console.error("Failed to start streaming:",S),h()}}function h(){c&&(c.close(),c=null),r=!1;const C=document.getElementById("logs-stream"),x=document.getElementById("logs-pause"),I=document.getElementById("logs-title");C&&(C.classList.remove("active"),C.textContent="\u{1F4E1} Live",C.title="Enable real-time streaming"),x&&(x.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),u&&b&&!n&&(n=setInterval(T,DC.POLL.LOGS))}async function p(C,x=100){try{const I=`/api/v1/logs/container/${C}?tail=${x}×tamps=true`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,containerName:S.containerName,containerId:S.containerId}:{error:S.error||"Failed to fetch container logs"}}else return{error:`HTTP ${k.status}: ${k.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function w(C){const x=document.createElement("div");x.className="log-entry",x.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=C.stream==="stderr"?"var(--bad-fg)":"var(--fg)",k=C.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 x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${k}</div>
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
|
||||
`,w}async function T(){if(p||!v||!s)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await d(v,x);if(I.error){w.innerHTML=`
|
||||
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(C.text)}</div>
|
||||
`,x}async function T(){if(g||!b||!u)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await p(b,C);if(I.error){x.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}w.innerHTML=`
|
||||
</div>`;return}x.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-shrink: 0; width: 80px;">Stream</span>
|
||||
<span style="flex: 1;">Log Output</span>
|
||||
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=E(k);w.appendChild(S)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
|
||||
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=w(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available for this container
|
||||
</div>`}catch(I){w.innerHTML=`
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function L(x,w){v=x,i=w,s=!0,h=!1,p=!1,b();const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),y=setInterval(T,DC.POLL.LOGS)}async function P(x,w=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${w}`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function C(x){const w=document.createElement("div");w.className="log-entry",w.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 k="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(k="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(k="DEBUG",S="var(--muted)");const O=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${k}</span>`;return w.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${O}</div>
|
||||
</div>`}}function L(C,x){b=C,f=x,u=!0,y=!1,g=!1,h();const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Container Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display=""),I.classList.add("show"),T(),n=setInterval(T,DC.POLL.LOGS)}async function P(C,x=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(C)}&tail=${x}`,k=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(k.ok){const S=await k.json();return S.success&&S.logs?{logs:S.logs,count:S.count,logPath:S.logPath,totalLines:S.totalLines}:{error:S.error||"Failed to fetch file logs"}}else return{error:(await k.json().catch(()=>({}))).error||`HTTP ${k.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(C){const x=document.createElement("div");x.className="log-entry",x.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=C.text;let k="INFO",S="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(k="ERROR",S="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(k="WARN",S="#f39c12"):I.match(/DEBUG/i)&&(k="DEBUG",S="var(--muted)");const D=`<span style="background: ${S==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${S}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${k}</span>`;return x.innerHTML=`
|
||||
<div style="flex-shrink: 0;">${D}</div>
|
||||
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
|
||||
`,w}async function B(){if(p||!m||!h)return;const x=parseInt(document.getElementById("log-lines").value),w=document.getElementById("logs-content");try{const I=await P(m,x);if(I.error){w.innerHTML=`
|
||||
`,x}async function B(){if(g||!v||!y)return;const C=parseInt(document.getElementById("log-lines").value),x=document.getElementById("logs-content");try{const I=await P(v,C);if(I.error){x.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}w.innerHTML=`
|
||||
</div>`;return}x.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(k=>{const S=C(k);w.appendChild(S)}),w.scrollTop=w.scrollHeight):w.innerHTML+=`
|
||||
</div>`,I.logs&&I.logs.length>0?(I.logs.forEach(k=>{const S=E(k);x.appendChild(S)}),x.scrollTop=x.scrollHeight):x.innerHTML+=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
||||
No logs available in this file
|
||||
</div>`}catch(I){w.innerHTML=`
|
||||
</div>`}catch(I){x.innerHTML=`
|
||||
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
||||
Failed to fetch logs: ${escapeHtml(I.message)}
|
||||
</div>`}}function $(x,w){m=x,t=w,h=!0,s=!1,p=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${w} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),y=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const w=x.target.closest('[id$="-logs"]');if(!w)return;const I=w.id.replace("-logs","");SITE.dnsServers[I]&&f(I)}),document.getElementById("logs-close")?.addEventListener("click",n),document.getElementById("logs-pause")?.addEventListener("click",()=>{p=!p;const x=document.getElementById("logs-pause");p?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),r())}),document.getElementById("log-lines")?.addEventListener("change",()=>{p||r()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!s||!v||(o?b():u(v))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&n()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&n()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=f})(),(function(){injectModal("service-edit-modal",`
|
||||
</div>`}}function $(C,x){v=C,s=x,y=!0,u=!1,g=!1;const I=document.getElementById("logs-modal"),k=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),A=document.getElementById("logs-stream");k.textContent=`\u{1F4CB} ${x} - Application Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),A&&(A.style.display="none"),I.classList.add("show"),B(),n=setInterval(B,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",C=>{const x=C.target.closest('[id$="-logs"]');if(!x)return;const I=x.id.replace("-logs","");SITE.dnsServers[I]&&i(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const C=document.getElementById("logs-pause");g?(C.textContent="\u25B6\uFE0F Resume",C.classList.add("paused")):(C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),o())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||o()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!u||!b||(r?h():a(b))}),document.getElementById("logs-modal")?.addEventListener("click",C=>{C.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",C=>{C.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=i})(),(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>
|
||||
@@ -771,44 +805,44 @@ Instructions: ${f.instructionsLink}`:"";showNotification(`${t.toUpperCase()} upd
|
||||
<button id="add-service-create" class="btn-accent">Create Service</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`)})(),(function(){async function a(s){try{const m=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(s)}`);if(!m.ok)throw new Error(`Failed to load CAs: ${m.status}`);const t=await m.json();if(t.success){const h=document.getElementById("existing-ca-select");return h.innerHTML="",t.cas.length===0?h.innerHTML='<option value="">No CAs found in Caddyfile</option>':(h.innerHTML='<option value="">Select existing CA...</option>',t.cas.forEach(c=>{const o=document.createElement("option");typeof c=="object"?(o.value=c.id,o.textContent=c.displayName||c.name):(o.value=c,o.textContent=c),h.appendChild(o)})),t.data.cas}else throw new Error(t.message)}catch(m){console.error("Error loading CAs:",m);const t=document.getElementById("existing-ca-select");return t.innerHTML='<option value="">Error loading CAs</option>',[]}}function y(s){const{subdomain:m,port:t,ip:h,sslType:c,caName:o,existingCa:l,enableAuth:g,enableCors:e,customHeaders:r,upstreamPath:f,healthCheck:n,timeout:u,tailscaleOnly:b}=s;let d=`${buildDomain(m)} {
|
||||
`;switch(b&&(d+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,d+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),c){case"letsencrypt":break;case"caddy-managed":d+=` tls internal
|
||||
`;break;case"existing-ca":l&&(d+=` tls {
|
||||
ca ${l}
|
||||
</div>`)})(),(function(){async function l(u){try{const v=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(u)}`);if(!v.ok)throw new Error(`Failed to load CAs: ${v.status}`);const s=await v.json();if(s.success){const y=document.getElementById("existing-ca-select");return y.innerHTML="",s.cas.length===0?y.innerHTML='<option value="">No CAs found in Caddyfile</option>':(y.innerHTML='<option value="">Select existing CA...</option>',s.cas.forEach(c=>{const r=document.createElement("option");typeof c=="object"?(r.value=c.id,r.textContent=c.displayName||c.name):(r.value=c,r.textContent=c),y.appendChild(r)})),s.data.cas}else throw new Error(s.message)}catch(v){console.error("Error loading CAs:",v);const s=document.getElementById("existing-ca-select");return s.innerHTML='<option value="">Error loading CAs</option>',[]}}function n(u){const{subdomain:v,port:s,ip:y,sslType:c,caName:r,existingCa:m,enableAuth:d,enableCors:e,customHeaders:o,upstreamPath:i,healthCheck:t,timeout:a,tailscaleOnly:h}=u;let p=`${buildDomain(v)} {
|
||||
`;switch(h&&(p+=` @blocked not remote_ip 100.64.0.0/10
|
||||
`,p+=` respond @blocked "Access denied. Tailscale connection required." 403
|
||||
`),c){case"letsencrypt":break;case"caddy-managed":p+=` tls internal
|
||||
`;break;case"existing-ca":m&&(p+=` tls {
|
||||
ca ${m}
|
||||
}
|
||||
`);break;case"custom-ca":o&&(d+=` tls {
|
||||
ca ${o}
|
||||
`);break;case"custom-ca":r&&(p+=` tls {
|
||||
ca ${r}
|
||||
}
|
||||
`);break}if(g&&(d+=` basicauth {
|
||||
`);break}if(d&&(p+=` basicauth {
|
||||
admin $2a$14$hashed_password_here
|
||||
}
|
||||
`),e&&(d+=` header {
|
||||
`,d+=` Access-Control-Allow-Origin "*"
|
||||
`,d+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,d+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,d+=` }
|
||||
`),r)try{const E=JSON.parse(r);d+=` header {
|
||||
`,Object.entries(E).forEach(([T,L])=>{d+=` ${T} "${L}"
|
||||
`}),d+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return n&&(d+=` health_uri ${n}
|
||||
`),d+=` reverse_proxy ${h}:${t} {
|
||||
`,f&&f!=="/"&&(d+=` rewrite ${f}
|
||||
`),u&&u!==30&&(d+=` transport http {
|
||||
`,d+=` dial_timeout ${u}s
|
||||
`,d+=` response_header_timeout ${u}s
|
||||
`,d+=` }
|
||||
`),d+=` }
|
||||
`,d+=`}
|
||||
`,d}async function p(s,m,t=DC.DEFAULTS.TTL){const h=window.getToken(getPrimaryDnsId(),"admin");if(!h)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(s),o=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:m,ttl:t,token:h,server:SITE.dnsIp})});if(!o.ok){const g=await o.text();throw new Error(`DNS API Error: ${o.status} - ${g}`)}const l=await o.json();if(!l.success)throw new Error(`DNS Error: ${l.error||"Unknown error"}`);return l}async function v(s){const m={id:s.subdomain,name:s.name,logo:s.logo||`/assets/${s.subdomain}.png`};s.category&&(m.category=s.category),s.containerId&&(m.containerId=s.containerId);try{const t=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});if(!t.ok){const h=await t.json();throw new Error(h.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),m}catch(t){throw console.error("Failed to add service to config:",t),t}}async function i(s){const m=document.getElementById("service-subdomain-input").value.trim(),t=document.getElementById("service-ip-input").value.trim()||"localhost",h=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(m),upstream:`${t}:${h}`,config:s})}),o=await c.json();if(!c.ok||!o.success)throw new Error(o.error||`Caddy API Error: ${c.status}`);return o}window.loadExistingCAs=a,window.generateCaddyConfig=y,window.createDnsRecord=p,window.addServiceToConfig=v,window.addToCaddyfile=i})(),(function(){let a=null;function y(t){a=t;const h=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${t.name}`,document.getElementById("edit-service-name").value=t.name,document.getElementById("edit-service-url-display").textContent=t.url||buildServiceUrl(t.id),document.getElementById("edit-service-logo-preview").src=t.logo||`/assets/${t.id}.png`,document.getElementById("edit-subdomain").value=t.id,document.getElementById("edit-port").value=t.port||"",document.getElementById("edit-ip").value=t.ip||"localhost",document.getElementById("edit-tailscale-only").checked=t.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=t.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=t.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),h.classList.add("show")}function p(){closeModal("service-edit-modal"),a=null}async function v(){if(!a)return;const t=document.getElementById("edit-subdomain").value.trim().toLowerCase(),h=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),o=document.getElementById("edit-ip").value.trim()||"localhost",l=document.getElementById("edit-tailscale-only").checked,g=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!t){showNotification("Subdomain is required","warning");return}const r=a.id,f=[];if(t!==r&&f.push("subdomain"),h&&h!==a.name&&f.push("name"),c&&c!==String(a.port)&&f.push("port"),o!==a.ip&&f.push("ip"),l!==(a.tailscaleOnly||!1)&&f.push("tailscale"),g&&g!==a.logo&&f.push("logo"),e!==(a.category||"")&&f.push("category"),f.length===0){p();return}const n=document.getElementById("service-edit-save");n.textContent="Saving...",n.disabled=!0;try{const b=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:r,newSubdomain:t,name:h||a.name,port:c||a.port,ip:o,tailscaleOnly:l,logo:g||void 0,category:e})})).json();if(!b.success)throw new Error(b.error||"Failed to update service");const d=window.APPS.findIndex(E=>E.id===r);d!==-1&&(window.APPS[d]={...window.APPS[d],id:t,name:h||window.APPS[d].name,port:c||window.APPS[d].port,ip:o,tailscaleOnly:l,logo:g||window.APPS[d].logo,category:e||void 0}),p(),window.buildGrid(),window.refreshAll()}catch(u){console.error("Error saving service changes:",u),showNotification(`Error saving changes: ${u.message}`,"error")}finally{n.textContent="Save Changes",n.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async t=>{const h=t.target.files[0];if(!h)return;if(!h.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async o=>{const l=o.target.result;if(document.getElementById("edit-service-logo-preview").src=l,document.getElementById("edit-logo-url").value=l,a)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${a.id}.png`,data:l})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
Line truncated
|
||||
`),e&&(p+=` header {
|
||||
`,p+=` Access-Control-Allow-Origin "*"
|
||||
`,p+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
|
||||
`,p+=` Access-Control-Allow-Headers "Content-Type, Authorization"
|
||||
`,p+=` }
|
||||
`),o)try{const w=JSON.parse(o);p+=` header {
|
||||
`,Object.entries(w).forEach(([T,L])=>{p+=` ${T} "${L}"
|
||||
`}),p+=` }
|
||||
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(p+=` health_uri ${t}
|
||||
`),p+=` reverse_proxy ${y}:${s} {
|
||||
`,i&&i!=="/"&&(p+=` rewrite ${i}
|
||||
`),a&&a!==30&&(p+=` transport http {
|
||||
`,p+=` dial_timeout ${a}s
|
||||
`,p+=` response_header_timeout ${a}s
|
||||
`,p+=` }
|
||||
`),p+=` }
|
||||
`,p+=`}
|
||||
`,p}async function g(u,v,s=DC.DEFAULTS.TTL){const y=window.getToken(getPrimaryDnsId(),"admin");if(!y)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const c=buildDomain(u),r=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:c,ip:v,ttl:s,token:y,server:SITE.dnsIp})});if(!r.ok){const d=await r.text();throw new Error(`DNS API Error: ${r.status} - ${d}`)}const m=await r.json();if(!m.success)throw new Error(`DNS Error: ${m.error||"Unknown error"}`);return m}async function b(u){const v={id:u.subdomain,name:u.name,logo:u.logo||`/assets/${u.subdomain}.png`};u.category&&(v.category=u.category),u.containerId&&(v.containerId=u.containerId);try{const s=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)});if(!s.ok){const y=await s.json();throw new Error(y.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),v}catch(s){throw console.error("Failed to add service to config:",s),s}}async function f(u){const v=document.getElementById("service-subdomain-input").value.trim(),s=document.getElementById("service-ip-input").value.trim()||"localhost",y=document.getElementById("service-port-input").value.trim()||"80",c=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(v),upstream:`${s}:${y}`,config:u})}),r=await c.json();if(!c.ok||!r.success)throw new Error(r.error||`Caddy API Error: ${c.status}`);return r}window.loadExistingCAs=l,window.generateCaddyConfig=n,window.createDnsRecord=g,window.addServiceToConfig=b,window.addToCaddyfile=f})(),(function(){let l=null;function n(s){l=s;const y=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${s.name}`,document.getElementById("edit-service-name").value=s.name,document.getElementById("edit-service-url-display").textContent=s.url||buildServiceUrl(s.id),document.getElementById("edit-service-logo-preview").src=s.logo||`/assets/${s.id}.png`,document.getElementById("edit-subdomain").value=s.id,document.getElementById("edit-port").value=s.port||"",document.getElementById("edit-ip").value=s.ip||"localhost",document.getElementById("edit-tailscale-only").checked=s.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=s.logo||"";const c=document.getElementById("edit-service-category");c&&(c.dataset.current=s.category||"",typeof window.populateCategorySelects=="function"&&window.populateCategorySelects()),y.classList.add("show")}function g(){closeModal("service-edit-modal"),l=null}async function b(){if(!l)return;const s=document.getElementById("edit-subdomain").value.trim().toLowerCase(),y=document.getElementById("edit-service-name").value.trim(),c=document.getElementById("edit-port").value.trim(),r=document.getElementById("edit-ip").value.trim()||"localhost",m=document.getElementById("edit-tailscale-only").checked,d=document.getElementById("edit-logo-url").value.trim(),e=document.getElementById("edit-service-category")?.value||"";if(!s){showNotification("Subdomain is required","warning");return}const o=l.id,i=[];if(s!==o&&i.push("subdomain"),y&&y!==l.name&&i.push("name"),c&&c!==String(l.port)&&i.push("port"),r!==l.ip&&i.push("ip"),m!==(l.tailscaleOnly||!1)&&i.push("tailscale"),d&&d!==l.logo&&i.push("logo"),e!==(l.category||"")&&i.push("category"),i.length===0){g();return}const t=document.getElementById("service-edit-save");t.textContent="Saving...",t.disabled=!0;try{const h=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:o,newSubdomain:s,name:y||l.name,port:c||l.port,ip:r,tailscaleOnly:m,logo:d||void 0,category:e})})).json();if(!h.success)throw new Error(h.error||"Failed to update service");const p=window.APPS.findIndex(w=>w.id===o);p!==-1&&(window.APPS[p]={...window.APPS[p],id:s,name:y||window.APPS[p].name,port:c||window.APPS[p].port,ip:r,tailscaleOnly:m,logo:d||window.APPS[p].logo,category:e||void 0}),g(),window.buildGrid(),window.refreshAll()}catch(a){console.error("Error saving service changes:",a),showNotification(`Error saving changes: ${a.message}`,"error")}finally{t.textContent="Save Changes",t.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async s=>{const y=s.target.files[0];if(!y)return;if(!y.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const c=new FileReader;c.onload=async r=>{const m=r.target.result;if(document.getElementById("edit-service-logo-preview").src=m,document.getElementById("edit-logo-url").value=m,l)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${l.id}.png`,data:m})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").val
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{o&&(o.textContent="\u{1F504}",o.disabled=!0,o.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${t}/update`,{method:"POST"})).json();if(e.success){const r=window.APPS.find(f=>f.id===c);r&&e.newContainerId&&(r.containerId=e.newContainerId),o&&(o.textContent="\u2705",o.title="Updated successfully!",setTimeout(()=>{o.textContent=l,o.disabled=!1,o.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${h} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(g){console.error("Update error:",g),o&&(o.textContent="\u274C",o.title="Update failed",setTimeout(()=>{o.textContent=l,o.disabled=!1,o.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${h}: ${g.message}`,"error")}}async function m(t,h){const c=window.APPS.find(d=>d.id===t),o=c?buildDomain(c.id):null,l=c?.containerId,g=await i(h||t,l,c?.containerId);if(g===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(g&&l)try{const d=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${d.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(d){console.error("App removal error:",d)}else if(g&&o){try{const d=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(o)}&type=A&ipAddress=${encodeURIComponent(d)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(d){e.dns=d.message}try{const E=await(await secureFetch(`/api/v1/site/${encodeURIComponent(o)}`,{method:"DELETE"})).json();e.caddy=E.success||E.error&&E.error.includes("not found")?"removed":E.error||"failed"}catch(d){e.caddy=d.message}}const r=window.APPS.findIndex(d=>d.id===t);r>-1&&(window.APPS.splice(r,1),e.dashboard=!0);try{const d=safeGetJSON("custom-apps",[]),E=d.findIndex(T=>T.id===t);E>-1&&(d.splice(E,1),safeSet("custom-apps",JSON.stringify(d)))}catch{}try{const E=await(await secureFetch(`/api/v1/services/${encodeURIComponent(t)}`,{method:"DELETE"})).json();e.service=E.success?"removed":E.error||"failed"}catch(d){e.service=d.message}window.buildGrid(),window.refreshAll();let f=!1,n=[];e.dashboard||(f=!0,n.push("\u2717 Failed to remove from dashboard"));const u=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],b=d=>!d||u.some(E=>d.toLowerCase().includes(E.toLowerCase()));e.container&&!b(e.container)&&(f=!0,n.push(`\u26A0 Container: ${e.container}`)),e.dns&&!b(e.dns)&&(f=!0,n.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!b(e.caddy)&&(f=!0,n.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!b(e.service)&&(f=!0,n.push(`\u26A0 Service File: ${e.service}`)),f&&showNotification(`Error deleting "${h||t}": ${n.join(", ")}`,"error",6e3)}window.openServiceEditModal=y,window.showDeleteModal=i,window.updateContainer=s,window.deleteService=m})(),(function(){function a(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function y(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function p(){const e=document.getElementById("service-subdomain-input").value||"subdomain",r=document.getElementById("service-ip-input").value||v.lan||"localhost",f=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,n=document.getElementById("ssl-type-select").value,u=document.getElementById("ca-name-input").value||"sami-ca",b=document.getElementById("existing-ca-select").value,d=document.getElementById("enable-auth").checked,E=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,C=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${r}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:f,ip:r,sslType:n,caName:u,existingCa:b,enableAuth:d,enableCors:E,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:C},w=window.generateCaddyConfig(x),I=document.getElementById("caddy-config-preview");I&&(I.value=w)}const v={localhost:"127.0.0.1",lan:"",tailscale:""};async function i(){try{const n=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(n.ok){const u=await n.json();u.lan&&(v.lan=u.lan),u.tailscale&&(v.tailscale=u.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),r=document.getElementById("quick-ip-tailscale");e&&(v.lan?(e.dataset.ip=v.lan,e.textContent=`LAN (${v.lan})`,e.title=`LAN IP: ${v.lan}`):e
Line truncated
|
||||
The service will be briefly unavailable.`))try{r&&(r.textContent="\u{1F504}",r.disabled=!0,r.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${s}/update`,{method:"POST"})).json();if(e.success){const o=window.APPS.find(i=>i.id===c);o&&e.newContainerId&&(o.containerId=e.newContainerId),r&&(r.textContent="\u2705",r.title="Updated successfully!",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${y} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(d){console.error("Update error:",d),r&&(r.textContent="\u274C",r.title="Update failed",setTimeout(()=>{r.textContent=m,r.disabled=!1,r.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${y}: ${d.message}`,"error")}}async function v(s,y){const c=window.APPS.find(p=>p.id===s),r=c?buildDomain(c.id):null,m=c?.containerId,d=await f(y||s,m,c?.containerId);if(d===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(d&&m)try{const p=new URLSearchParams({containerId:c.containerId,subdomain:c.id,ip:c.ip||"localhost",deleteContainer:"true"}),T=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(c.id)}?${p.toString()}`,{method:"DELETE"})).json();T.success?e={...e,...T.results,dashboard:!1}:console.error("App removal failed:",T.error)}catch(p){console.error("App removal error:",p)}else if(d&&r){try{const p=c?.ip||"localhost",T=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(r)}&type=A&ipAddress=${encodeURIComponent(p)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=T.success?"deleted":T.error||"failed"}catch(p){e.dns=p.message}try{const w=await(await secureFetch(`/api/v1/site/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.caddy=w.success||w.error&&w.error.includes("not found")?"removed":w.error||"failed"}catch(p){e.caddy=p.message}}const o=window.APPS.findIndex(p=>p.id===s);o>-1&&(window.APPS.splice(o,1),e.dashboard=!0);try{const p=safeGetJSON("custom-apps",[]),w=p.findIndex(T=>T.id===s);w>-1&&(p.splice(w,1),safeSet("custom-apps",JSON.stringify(p)))}catch{}try{const w=await(await secureFetch(`/api/v1/services/${encodeURIComponent(s)}`,{method:"DELETE"})).json();e.service=w.success?"removed":w.error||"failed"}catch(p){e.service=p.message}window.buildGrid(),window.refreshAll();let i=!1,t=[];e.dashboard||(i=!0,t.push("\u2717 Failed to remove from dashboard"));const a=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],h=p=>!p||a.some(w=>p.toLowerCase().includes(w.toLowerCase()));e.container&&!h(e.container)&&(i=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!h(e.dns)&&(i=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!h(e.caddy)&&(i=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!h(e.service)&&(i=!0,t.push(`\u26A0 Service File: ${e.service}`)),i&&showNotification(`Error deleting "${y||s}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=n,window.showDeleteModal=f,window.updateContainer=u,window.deleteService=v})(),(function(){function l(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function n(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",o=document.getElementById("service-ip-input").value||b.lan||"localhost",i=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,a=document.getElementById("ca-name-input").value||"sami-ca",h=document.getElementById("existing-ca-select").value,p=document.getElementById("enable-auth").checked,w=document.getElementById("enable-cors").checked,T=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",P=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,B=document.getElementById("dns-preview");B&&(B.textContent=`${buildDomain(e)} \u2192 ${o}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const C={subdomain:e,port:i,ip:o,sslType:t,caName:a,existingCa:h,enableAuth:p,enableCors:w,customHeaders:T,upstreamPath:L,healthCheck:P,timeout:E},x=window.generateCaddyConfig(C),I=document.getElementById("caddy-config-preview");I&&(I.value=x)}const b={localhost:"127.0.0.1",lan:"",tailscale:""};async function f(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const a=await t.json();a.lan&&(b.lan=a.lan),a.tailscale&&(b.tailscale=a.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),o=document.getElementById("quick-ip-tailscale");e&&(b.lan?(e.dataset.ip=b.lan,e.textContent=`LAN (${b.lan})`,e.title=`LAN IP: ${b.lan}`):e
Line truncated
|
||||
<span style="color: #4caf50;">\u2713 Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 6px;">${E.self?.hostname} (${E.self?.ip})</span>
|
||||
`,b.disabled=!1):E.installed?(u.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',b.disabled=!0):(u.innerHTML='<span style="color: var(--muted);">Not available</span>',b.disabled=!0)}catch{u.innerHTML='<span style="color: var(--muted);">Could not check</span>',b.disabled=!0}b.checked=!1,p()}function t(){const e=document.getElementById("service-type-local"),r=document.getElementById("service-type-external"),f=document.getElementById("local-service-config"),n=document.getElementById("external-service-config"),u=document.getElementById("tab-local"),b=document.getElementById("tab-external");function d(){e.checked?(f.style.display="grid",n.style.display="none",u&&(u.style.background="var(--accent)",u.style.color="var(--bg)"),b&&(b.style.background="transparent",b.style.color="var(--muted)")):(f.style.display="none",n.style.display="block",b&&(b.style.background="var(--accent)",b.style.color="var(--bg)"),u&&(u.style.background="transparent",u.style.color="var(--muted)"))}e?.addEventListener("change",d),r?.addEventListener("change",d)}function h(){const e=document.getElementById("service-name-input"),r=document.getElementById("service-subdomain-input"),f=document.getElementById("subdomain-preview");let n=!1;e?.addEventListener("input",()=>{const L=a(e.value);!n&&r&&(r.value=L),f&&(f.textContent=L?`\u2192 ${buildDomain(L)}`:""),p()}),r?.addEventListener("input",()=>{n=r.value!==a(e?.value||"");const L=r.value.trim()||a(e?.value||"");f&&(f.textContent=L?`\u2192 ${buildDomain(L)}`:""),p()});const u=document.getElementById("external-service-name"),b=document.getElementById("external-service-subdomain"),d=document.getElementById("external-subdomain-preview"),E=document.getElementById("external-domain-preview");let T=!1;u?.addEventListener("input",()=>{const L=a(u.value);!T&&b&&(b.value=L);const P=b?.value||L;d&&(d.textContent=P?`\u2192 ${buildDomain(P)}`:""),E&&(E.textContent=P?buildDomain(P):"")}),b?.addEventListener("input",()=>{T=b.value!==a(u?.value||"");const L=b.value.trim()||a(u?.value||"");d&&(d.textContent=L?`\u2192 ${buildDomain(L)}`:""),E&&(E.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),r=document.getElementById("external-service-url").value.trim(),f=(document.getElementById("external-service-subdomain").value.trim()||a(e)).toLowerCase(),n=document.getElementById("external-service-logo").value.trim(),u=document.getElementById("external-service-icon").value.trim(),b=document.getElementById("external-create-dns").checked,d=document.getElementById("external-create-caddy").checked,E=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!r){showNotification("Please fill in Name and External URL","warning");return}if(!f){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!r.startsWith("http://")&&!r.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const C=buildDomain(f);try{const B={dns:null,caddy:null,dashboard:!1};if(b)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:C,ip:E,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(d)try{const k={subdomain:f,externalUrl:r,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:f,name:e,url:`https://${C}`,externalUrl:r,logo:n||u||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const x=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],w=window.APPS.filter(k=>!x.includes(k.id));safeSet("custom-services",JSON.stringify(w));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),o();const I=[`External service "${e}" added!`];b&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),d&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${C}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
<span style="color: var(--muted); margin-left: 6px;">${w.self?.hostname} (${w.self?.ip})</span>
|
||||
`,h.disabled=!1):w.installed?(a.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',h.disabled=!0):(a.innerHTML='<span style="color: var(--muted);">Not available</span>',h.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check</span>',h.disabled=!0}h.checked=!1,g()}function s(){const e=document.getElementById("service-type-local"),o=document.getElementById("service-type-external"),i=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),a=document.getElementById("tab-local"),h=document.getElementById("tab-external");function p(){e.checked?(i.style.display="grid",t.style.display="none",a&&(a.style.background="var(--accent)",a.style.color="var(--bg)"),h&&(h.style.background="transparent",h.style.color="var(--muted)")):(i.style.display="none",t.style.display="block",h&&(h.style.background="var(--accent)",h.style.color="var(--bg)"),a&&(a.style.background="transparent",a.style.color="var(--muted)"))}e?.addEventListener("change",p),o?.addEventListener("change",p)}function y(){const e=document.getElementById("service-name-input"),o=document.getElementById("service-subdomain-input"),i=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const L=l(e.value);!t&&o&&(o.value=L),i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),o?.addEventListener("input",()=>{t=o.value!==l(e?.value||"");const L=o.value.trim()||l(e?.value||"");i&&(i.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const a=document.getElementById("external-service-name"),h=document.getElementById("external-service-subdomain"),p=document.getElementById("external-subdomain-preview"),w=document.getElementById("external-domain-preview");let T=!1;a?.addEventListener("input",()=>{const L=l(a.value);!T&&h&&(h.value=L);const P=h?.value||L;p&&(p.textContent=P?`\u2192 ${buildDomain(P)}`:""),w&&(w.textContent=P?buildDomain(P):"")}),h?.addEventListener("input",()=>{T=h.value!==l(a?.value||"");const L=h.value.trim()||l(a?.value||"");p&&(p.textContent=L?`\u2192 ${buildDomain(L)}`:""),w&&(w.textContent=L?buildDomain(L):"")})}async function c(){const e=document.getElementById("external-service-name").value.trim(),o=document.getElementById("external-service-url").value.trim(),i=(document.getElementById("external-service-subdomain").value.trim()||l(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),a=document.getElementById("external-service-icon").value.trim(),h=document.getElementById("external-create-dns").checked,p=document.getElementById("external-create-caddy").checked,w=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",T=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked,P=document.getElementById("external-service-category")?.value||"";if(!e||!o){showNotification("Please fill in Name and External URL","warning");return}if(!i){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!o.startsWith("http://")&&!o.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const E=buildDomain(i);try{const B={dns:null,caddy:null,dashboard:!1};if(h)if(window.getToken(getPrimaryDnsId(),"admin"))try{const A=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:E,ip:w,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();B.dns=A.success?"created":A.error||"failed"}catch(S){B.dns=S.message}else B.dns="no admin token (configure in \u{1F511} Tokens)";if(p)try{const k={subdomain:i,externalUrl:o,preserveHost:T,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},A=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();B.caddy=A.success?"created":A.error||"failed"}catch(k){B.caddy=k.message}const $={id:i,name:e,url:`https://${E}`,externalUrl:o,logo:t||a||"\u{1F310}",isExternal:!0,isCustom:!0};P&&($.category=P),window.APPS.push($),B.dashboard=!0;const C=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(k=>!C.includes(k.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(k){console.warn("Failed to save to services.json:",k)}window.buildGrid(),window.refreshAll(),r();const I=[`External service "${e}" added!`];h&&I.push(`DNS: ${B.dns==="created"?"\u2713":"\u26A0 "+B.dns}`),p&&I.push(`Caddy: ${B.caddy==="created"?"\u2713":"\u26A0 "+B.caddy}`),I.push(`Access at: https://${E}`),showNotification(I.join(" | "),"success",6e3)}catch(B){console.error("Failed to create external service:",B),showNotification(`F
Line truncated
|
||||
Vendored
+323
-212
@@ -90,44 +90,44 @@
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),P=document.getElementById("logo-preview-light"),w=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),O=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),b=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,j=null,H=null,R="left",x=S;N?.addEventListener("change",()=>{N.checked?(O.style.display="none",z.style.display="",T=null,j=null):(O.style.display="flex",z.style.display="none",H=null)});function D(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(n)}A?.addEventListener("change",n=>{D(n.target.files[0],e=>{T=e,E.src=e,w.textContent="New dark logo ready to save"})}),v?.addEventListener("change",n=>{D(n.target.files[0],e=>{j=e,P.src=e,w.textContent="New light logo ready to save"})}),L?.addEventListener("change",n=>{D(n.target.files[0],e=>{H=e,E.src=e,P.src=e,w.textContent="New logo ready to save (both themes)"})});function g(n){k.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function u(n){x=n||S,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function f(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(b.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,P.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(b.src=e.customLogo,M.src=e.customLogo,E.src=e.customLogo,P.src=e.customLogo),e.isDefault||(w.textContent="Using custom logo"),e.position&&(R=e.position,g(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{R=n.dataset.pos,g(R)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,j=null,H=null,A&&(A.value=""),v&&(v.value=""),L&&(L.value=""),N&&(N.checked=!1),O.style.display="flex",z.style.display="none",E.src=b.src,P.src=M.src;const n=b.src.includes("custom-logo")||M.src.includes("custom-logo");w.textContent=n?"Using custom logo":"Using default logos",g(R),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=B.value.trim()||S,e={position:R,dashboardTitle:n};N?.checked&&H?(e.dataDark=H,e.dataLight=H):(T&&(e.dataDark=T),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),r="?t="+Date.now();a.pathDark&&(b.src=a.pathDark+r,E.src=a.pathDark+r),a.pathLight&&(M.src=a.pathLight+r,P.src=a.pathLight+r),g(R),u(n),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
</div>`);const h=document.getElementById("logo-modal"),S=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),I=DC.NAME;let L=null,j=null,M=null,O="left",x=I;N?.addEventListener("change",()=>{N.checked?(R.style.display="none",z.style.display="",L=null,j=null):(R.style.display="flex",z.style.display="none",M=null)});function D(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(t)}P?.addEventListener("change",t=>{D(t.target.files[0],e=>{L=e,S.src=e,k.textContent="New dark logo ready to save"})}),g?.addEventListener("change",t=>{D(t.target.files[0],e=>{j=e,A.src=e,k.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{D(t.target.files[0],e=>{M=e,S.src=e,A.src=e,k.textContent="New logo ready to save (both themes)"})});function y(t){E.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){x=t||I,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function b(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,S.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,H.src=e.customLogo,S.src=e.customLogo,A.src=e.customLogo),e.isDefault||(k.textContent="Using custom logo"),e.position&&(O=e.position,y(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{O=t.dataset.pos,y(O)})}),document.getElementById("brand")?.addEventListener("click",()=>{L=null,j=null,M=null,P&&(P.value=""),g&&(g.value=""),T&&(T.value=""),N&&(N.checked=!1),R.style.display="flex",z.style.display="none",S.src=w.src,A.src=H.src;const t=w.src.includes("custom-logo")||H.src.includes("custom-logo");k.textContent=t?"Using custom logo":"Using default logos",y(O),$.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||I,e={position:O,dashboardTitle:t};N?.checked&&M?(e.dataDark=M,e.dataLight=M):(L&&(e.dataDark=L),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),d="?t="+Date.now();a.pathDark&&(w.src=a.pathDark+d,S.src=a.pathDark+d),a.pathLight&&(H.src=a.pathLight+d,A.src=a.pathLight+d),y(O),m(t),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(b.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",P.src="/assets/dashcaddy-logo-light.png",w.textContent="Using default logos",T=null,j=null,H=null,B.value=S,u(S),R="left",g("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),i=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const m=document.getElementById("favicon-preview"),p=document.getElementById("favicon-status"),d=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let i=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function $(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),m.src=e.customFavicon+"?t="+Date.now(),p.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}d?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),d.value="";return}const o=new FileReader;o.onload=a=>{i=a.target.result,m.src=i,p.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(i)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),m.src=e.path+"?t="+Date.now(),p.textContent="Using custom favicon",i=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),$(),f();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{h.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,E){const P=Intl.supportedValuesOf("timeZone"),w=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of P){const O=document.createElement("option");O.value=N,O.textContent=N.replace(/_/g," "),N===w&&(O.selected=!0),h.appendChild(O)}},(function(){let h="homelab",E=null;async function P(){try{const D=await fetch("/api/v1/config");if(D.ok&&(E=await D.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}P();const w=document.getElementById("setup-timezone");w&&window.populateTimezoneSelect(w);function N(x){document.querySelectorAll(".setup-step").forEach(g=>{g.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function O(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",f=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",S.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",L=null,j=null,M=null,$.value=I,m(I),O="left",y("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),d=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),d&&(d.textContent="Using DashCaddy favicon"),r=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const p=document.getElementById("favicon-preview"),v=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),l=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(l.rel="icon",l.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(l));async function f(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(l.href=e.customFavicon+"?t="+Date.now(),p.src=e.customFavicon+"?t="+Date.now(),v.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const o=new FileReader;o.onload=a=>{r=a.target.result,p.src=r,v.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(t.ok){const e=await t.json();l.href=e.path+"?t="+Date.now(),p.src=e.path+"?t="+Date.now(),v.textContent="Using custom favicon",r=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),f(),b();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{h.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,S){const A=Intl.supportedValuesOf("timeZone"),k=S||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of A){const R=document.createElement("option");R.value=N,R.textContent=N.replace(/_/g," "),N===k&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",S=null;async function A(){try{const D=await fetch("/api/v1/config");if(D.ok&&(S=await D.json(),S&&S.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function N(x){document.querySelectorAll(".setup-step").forEach(y=>{y.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function R(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const m=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",v=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>TLD:</strong> ${u}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${f}</div>
|
||||
<div><strong>DNS Server:</strong> ${m}:${p}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${u}, https://nextcloud${u}</div>
|
||||
<div><strong>TLD:</strong> ${m}</div>
|
||||
<div><strong>Certificate Authority:</strong> ${b}</div>
|
||||
<div><strong>DNS Server:</strong> ${p}:${v}</div>
|
||||
<div><strong>Example URLs:</strong> https://uptime${m}, https://nextcloud${m}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(h==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`
|
||||
`}else if(h==="simple"){const m=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Access Method:</strong> IP:Port only</div>
|
||||
<div><strong>Default IP:</strong> ${u}</div>
|
||||
<div><strong>Default IP:</strong> ${m}</div>
|
||||
<div><strong>SSL:</strong> None (HTTP only)</div>
|
||||
<div><strong>Example URLs:</strong> http://${u}:8080, http://${u}:3000</div>
|
||||
<div><strong>Example URLs:</strong> http://${m}:8080, http://${m}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(h==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"",m=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=m==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;D+=`
|
||||
`}else if(h==="public"){const m=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",v=p==="subdirectory"?`https://${m}/sonarr, https://${m}/grafana`:`https://sonarr.${m}, https://grafana.${m}`;D+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
<div><strong>Domain:</strong> ${u}</div>
|
||||
<div><strong>Domain:</strong> ${m}</div>
|
||||
<div><strong>SSL:</strong> Let's Encrypt</div>
|
||||
<div><strong>Email:</strong> ${f}</div>
|
||||
<div><strong>Routing:</strong> ${m==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${p}</div>
|
||||
<div><strong>Email:</strong> ${b}</div>
|
||||
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
||||
<div><strong>Example URLs:</strong> ${v}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}const g=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
|
||||
`}const y=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
|
||||
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${g.replace(/_/g," ")}</div>
|
||||
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${y.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const f=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:f,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function P(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";x.dns={provider:b,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const y=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",m=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${y}. Settings saved to: ${m}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(x){const D=x.target.value||".home",y=document.getElementById("tld-preview"),m=document.getElementById("tld-preview-2");y&&(y.textContent=D),m&&(m.textContent=D)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(x){x.preventDefault(),N("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",y=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!y){showNotification("Please enter a Certificate Authority name","warning");return}if(!m){showNotification("Please enter your DNS server IP address","warning");return}R()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(x){x.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-simple-next");I&&(I.onclick=function(x){x.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const L=document.getElementById("setup-public-back");L&&(L.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!y||!y.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const M=document.getElementById("setup-summary-back");M&&(M.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homela
Line truncated
|
||||
<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 P=null,w=null;const N=document.getElementById("app-selector-modal"),O=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return P=c.templates,w=c.categories,!0}catch(d){h.logError("[AppSelector] Fetch Templates",d,{function:"fetchApiTemplates"})}return!1}async function A(d){try{return await(await fetch(`/api/v1/apps/ports/${d}/check`)).json()}catch(c){return h.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(d){try{const i=await(await fetch(`/api/v1/apps/ports/${d}/suggest`)).json();if(i.success)return i.suggestedPort}catch(c){h.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return d}async function L(){if(O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!P&&!await z()){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}O.innerHTML="";const d={};for(const[i,$]of Object.entries(P)){const y=$.category||"Other";d[y]||(d[y]=[]),d[y].push({id:i,...$})}const c=w?Object.keys(w):Object.keys(d).sort();for(const i of c){const $=d[i];if(!$||$.length===0)continue;$.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const n=w?.[i]||{};y.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(i)}`,n.color&&(y.style.borderBottomColor=n.color),O.appendChild(y),$.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,r=a&&safeGet("widget-"+e.id+"-enabled")!=="false",t=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",s=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";o.innerHTML=`
|
||||
</div>`);const S="custom-apps";let A=null,k=null;const N=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function z(){try{const l=await(await fetch("/api/v1/apps/templates")).json();if(l.success)return A=l.templates,k=l.categories,!0}catch(s){h.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function P(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(l){return h.logError("[AppSelector] Check Port",l,{function:"checkPortAvailability"}),{available:!0}}}async function g(s){try{const r=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(r.success)return r.suggestedPort}catch(l){h.logError("[AppSelector] Get Suggested Port",l,{function:"getSuggestedPort"})}return s}async function T(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await z()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const s={};for(const[r,f]of Object.entries(A)){const u=f.category||"Other";s[u]||(s[u]=[]),s[u].push({id:r,...f})}const l=k?Object.keys(k):Object.keys(s).sort();for(const r of l){const f=s[r];if(!f||f.length===0)continue;f.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const t=k?.[r]||{};u.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(r)}`,t.color&&(u.style.borderBottomColor=t.color),R.appendChild(u),f.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,d=a&&safeGet("widget-"+e.id+"-enabled")!=="false",n=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${d?"#2ecc7130":"#e74c3c30"}; color: ${d?"#2ecc71":"#e74c3c"}; font-weight: 600;">${d?"ON":"OFF"}</div>`:"",i=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";o.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>
|
||||
${t}${s}
|
||||
`,a?o.onclick=()=>b(e,o):o.onclick=()=>M(e),O.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(O)}function b(d,c){const i="widget-"+d.id+"-enabled",y=!(safeGet(i)!=="false");safeSet(i,String(y));const n=d.widgetSelector;if(n){const o=document.querySelector(n);o&&(o.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${d.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function M(d){const c=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),$=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:d.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${d.name} container:
|
||||
${n}${i}
|
||||
`,a?o.onclick=()=>w(e,o):o.onclick=()=>H(e),R.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function w(s,l){const r="widget-"+s.id+"-enabled",u=!(safeGet(r)!=="false");safeSet(r,String(u));const t=s.widgetSelector;if(t){const o=document.querySelector(t);o&&(o.style.display=u?"":"none")}const e=l.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(`${s.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function H(s){const l=document.getElementById("app-deploy-modal"),r=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:s.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${s.name} container:
|
||||
|
||||
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.`)&&(d._useExisting=!0,d._existingContainer=V)}}catch{}i.textContent=`Deploy ${d.name}`;const r=d.subdomain||d.id.replace(/-/g,"");$.value=r;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const _=d.subpathSupport||"strip";_==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+d.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">ⓘ '+d.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${s}"]`),I=document.querySelector(`input[name="ssl-type"][value="${l}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,I?I.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",o.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,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(d.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,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 de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(d.id==="plex"||d.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="",d.docker?.volumes?.length){const _=d.mediaMount?.containerPath,W=d.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContainer=V)}}catch{}r.textContent=`Deploy ${s.name}`;const d=s.subdomain||s.id.replace(/-/g,"");f.value=d;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const _=s.subpathSupport||"strip";_==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+s.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">ⓘ '+s.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const i=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),c=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${i}"]`),B=document.querySelector(`input[name="ssl-type"][value="${c}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,B?B.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",o.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,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(s.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,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 de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(s.id==="plex"||s.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",s.docker?.volumes?.length){const _=s.mediaMount?.containerPath,W=s.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
||||
<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 le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=d.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await A(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
|
||||
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=s.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
||||
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
|
||||
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
|
||||
<span style="color: #4caf50;">Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
|
||||
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',o.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=$.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||d.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${n.value}:${te}`}y.textContent=K}$.oninput=ae,n.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(d)}async function k(d){const c=d.appTemplate,i=safeGetJSON(E,[]),$=c._useExisting&&c._existingContainer,y=i.find(n=>n.id===d.subdomain);if(!(y&&!$&&!confirm(`An app with subdomain "${d.subdomain}" already exists. Redeploy?`))){if(y){const n=i.indexOf(y);i.splice(n,1),safeSet(E,JSON.stringify(i))}if($)d.port=c._existingContainer.primaryPort;else{const n=d.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await A(n);if(!e.available){const o=await v(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',o.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=f.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}u.textContent=K}f.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),l.classList.add("show"),l.dataset.appTemplate=JSON.stringify(s)}async function E(s){const l=s.appTemplate,r=safeGetJSON(S,[]),f=l._useExisting&&l._existingContainer,u=r.find(t=>t.id===s.subdomain);if(!(u&&!f&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(u){const t=r.indexOf(u);r.splice(t,1),safeSet(S,JSON.stringify(r))}if(f)s.port=l._existingContainer.primaryPort;else{const t=s.port||l.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const o=await g(l.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
|
||||
Would you like to use port ${o} instead?`))d.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification($?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:d.subdomain,ip:d.ip,createDns:d.dnsType==="private",port:d.port||c.defaultPort||null,sslType:d.sslType,dnsType:d.dnsType,tailscaleOnly:d.tailscaleOnly||!1,mediaPath:d.mediaPath||null,plexClaimToken:d.plexClaimToken||null,customVolumes:d.customVolumes||null}};$&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!d.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(o.success){const a={id:d.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:o.containerId,url:o.url,ip:d.ip,appTemplate:c.id,tailscaleOnly:d.tailscaleOnly||!1};i.push(a),safeSet(E,JSON.stringify(i)),window.APPS&&!window.APPS.some(t=>t.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let r=o.usedExisting?`${c.name} configured with existing container!
|
||||
URL: ${o.url}`:`${c.name} deployed successfully!
|
||||
URL: ${o.url}`;o.warning&&(r+=`
|
||||
Would you like to use port ${o} instead?`))s.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${l.name} with existing container...`:`Deploying ${l.name}...`,"info",0);try{const t={appId:l.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||l.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};f&&(t.config.useExisting=!0,t.config.existingContainerId=l._existingContainer.id,t.config.existingPort=l._existingContainer.primaryPort,!s.port&&l._existingContainer.primaryPort&&(t.config.port=l._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(o.success){const a={id:s.subdomain,name:l.name,logo:`/assets/${l.id}.png`,containerId:o.containerId,url:o.url,ip:s.ip,appTemplate:l.id,tailscaleOnly:s.tailscaleOnly||!1};r.push(a),safeSet(S,JSON.stringify(r)),window.APPS&&!window.APPS.some(n=>n.id===l.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let d=o.usedExisting?`${l.name} configured with existing container!
|
||||
URL: ${o.url}`:`${l.name} deployed successfully!
|
||||
URL: ${o.url}`;o.warning&&(d+=`
|
||||
|
||||
\u26A0 Warning: ${o.warning}`),showNotification(r,"success",8e3),delete c._useExisting,delete c._existingContainer,o.url&&o.url.startsWith("https://")&&B(o.url,c.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const t=o.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${c.name}: ${t}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(n){h.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function B(d,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let i=0;const $=12,y=async()=>{i++;try{const n=await fetch(d,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<$?setTimeout(y,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(y,3e3)}function S(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(i=>i.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const T=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const d=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{i.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const $={appTemplate:d,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:i.length>0?i:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!$.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(d.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),k($)}),wireModal(T);const j=document.getElementById("folder-browser-modal"),H=document.getElementById("folder-browser-path"),R=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let g="",u=[],f=null;window.openFolderBrowser=function(d){f=d,u=d.value.split(",").map(c=>c.trim()).filter(c=>c),g="",p(),m(""),j.classList.add("show")};async function m(d){H.textContent=d||"Select a drive...",R.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(d)}`)).json();if(!i.success){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(i.error)}</div>`;return}g=i.path||"",H.textContent=g||"Select a drive...";let $="";i.parent&&i.parent!==i.path&&($+=`<div class="folder-item" data-path="${escapeHtml(i.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
\u26A0 Warning: ${o.warning}`),showNotification(d,"success",8e3),delete l._useExisting,delete l._existingContainer,o.url&&o.url.startsWith("https://")&&$(o.url,l.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const n=o.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${l.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${l.name}: ${t.message}`,"error",8e3)}}}async function $(s,l){showNotification(`\u23F3 Generating SSL certificate for ${l}...`,"warning",6e4);let r=0;const f=12,u=async()=>{r++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${l} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return r<f?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${l} 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 I(){safeGetJSON(S,[]).forEach(l=>{window.APPS.some(r=>r.id===l.id)||window.APPS.push(l)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const L=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(L.dataset.appTemplate),l=document.getElementById("deploy-media-path").value.trim(),r=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{r.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const f={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:l||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:r.length>0?r:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!l){showNotification("Please enter a media library path for this application","warning");return}L.classList.remove("show"),E(f)}),wireModal(L);const j=document.getElementById("folder-browser-modal"),M=document.getElementById("folder-browser-path"),O=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let y="",m=[],b=null;window.openFolderBrowser=function(s){b=s,m=s.value.split(",").map(l=>l.trim()).filter(l=>l),y="",v(),p(""),j.classList.add("show")};async function p(s){M.textContent=s||"Select a drive...",O.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const r=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!r.success){O.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(r.error)}</div>`;return}y=r.path||"",M.textContent=y||"Select a drive...";let f="";r.parent&&r.parent!==r.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(r.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
||||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||||
</div>`),i.items.length===0&&!i.parent?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':i.items.length===0?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':i.items.forEach(y=>{const n=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=u.includes(y.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";$+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${o}">
|
||||
<span style="font-size: 1.2rem;">${n}</span>
|
||||
<span style="flex: 1;">${escapeHtml(y.name)}</span>
|
||||
</div>`),r.items.length===0&&!r.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':r.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':r.items.forEach(u=>{const t=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(u.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<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; ${o}">
|
||||
<span style="font-size: 1.2rem;">${t}</span>
|
||||
<span style="flex: 1;">${escapeHtml(u.name)}</span>
|
||||
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
||||
</div>`}),R.innerHTML=$,R.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{m(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const n=u.includes(y.dataset.path);y.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function p(){if(u.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=u.map(d=>`
|
||||
</div>`}),O.innerHTML=f,O.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{p(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const t=m.includes(u.dataset.path);u.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(l){O.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(l.message)}</div>`}}function v(){if(m.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=m.map(s=>`
|
||||
<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(d)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(d)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
${escapeHtml(s)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
</span>
|
||||
`).join("")}window.removeSelectedFolder=function(d){u=u.filter(c=>c!==d),p(),m(g)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{g&&!u.includes(g)&&(u.push(g),p(),m(g))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{f&&(f.value=u.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
`).join("")}window.removeSelectedFolder=function(s){m=m.filter(l=>l!==s),v(),p(y)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{y&&!m.includes(y)&&(m.push(y),v(),p(y))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{b&&(b.value=m.join(", ")),j.classList.remove("show")}),I()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
||||
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
||||
|
||||
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let h=null,E=null,P=null,w=1,N=!1;const O=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(O,z);async function L(){try{const g=await fetch("/api/v1/recipes/templates"),u=await g.json();if(u.success)return h=u.templates,E=u.categories,!0;if(g.status===403)return N=!1,!1}catch(g){console.warn("Failed to fetch recipe templates:",g.message)}return!1}async function b(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(g){await b();let u;if(N&&h?u=h:u=M(),!u||u.length===0)return;const f=document.createElement("div");f.className="app-category-header",f.innerHTML="\u{1F9EA} Recipes",f.style.borderBottomColor="#8e44ad",g.appendChild(f);const m=Array.isArray(u)?u:Object.values(u);m.sort((p,d)=>(d.popularity||0)-(p.popularity||0));for(const p of m){const d=document.createElement("div");d.className="app-option",d.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;">${p.componentCount||p.components?.length||"?"} apps</div>`,i=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';d.innerHTML=`
|
||||
${i}
|
||||
<div class="app-option-icon">${escapeHtml(p.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(p.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(p.description||"")}</div>
|
||||
${c}
|
||||
`,d.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(p)},g.appendChild(d)}};function M(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(g){P=g,w=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${g.name}`,B(),S(),O.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(g=>{const u=parseInt(g.dataset.step);g.classList.toggle("active",u===w),g.classList.toggle("completed",u<w)});for(let g=1;g<=4;g++){const u=document.getElementById(`recipe-step-${g}`);u&&(u.style.display=g===w?"":"none")}A.style.display=w>1&&w<4?"":"none",w===4?(v.style.display="none",z.textContent="Close"):w===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const g=document.getElementById("recipe-component-list");g.innerHTML="";const u=P.components||[];for(const f of u){const m=document.createElement("div");m.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const p=f.required,d=f.internal;m.innerHTML=`
|
||||
<input type="checkbox" ${p?"checked disabled":"checked"} data-component-id="${f.id}"
|
||||
</div>`);let h=null,S=null,A=null,k=1,N=!1;const R=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(R,z);async function T(){try{const y=await fetch("/api/v1/recipes/templates"),m=await y.json();if(m.success)return h=m.templates,S=m.categories,!0;if(y.status===403)return N=!1,!1}catch(y){console.warn("Failed to fetch recipe templates:",y.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(y){await w();let m;if(N&&h?m=h:m=H(),!m||m.length===0)return;const b=document.createElement("div");b.className="app-category-header",b.innerHTML="\u{1F9EA} Recipes",b.style.borderBottomColor="#8e44ad",y.appendChild(b);const p=Array.isArray(m)?m:Object.values(m);p.sort((v,s)=>(s.popularity||0)-(v.popularity||0));for(const v of p){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const l=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${v.componentCount||v.components?.length||"?"} apps</div>`,r=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';s.innerHTML=`
|
||||
${r}
|
||||
<div class="app-option-icon">${escapeHtml(v.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(v.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(v.description||"")}</div>
|
||||
${l}
|
||||
`,s.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(v)},y.appendChild(s)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function E(y){A=y,k=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${y.name}`,$(),I(),R.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(y=>{const m=parseInt(y.dataset.step);y.classList.toggle("active",m===k),y.classList.toggle("completed",m<k)});for(let y=1;y<=4;y++){const m=document.getElementById(`recipe-step-${y}`);m&&(m.style.display=y===k?"":"none")}P.style.display=k>1&&k<4?"":"none",k===4?(g.style.display="none",z.textContent="Close"):k===3?(g.textContent="\u{1F680} Deploy",g.style.display="",z.textContent="Cancel"):(g.textContent="Next",g.style.display="",z.textContent="Cancel")}function I(){const y=document.getElementById("recipe-component-list");y.innerHTML="";const m=A.components||[];for(const b of m){const p=document.createElement("div");p.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const v=b.required,s=b.internal;p.innerHTML=`
|
||||
<input type="checkbox" ${v?"checked disabled":"checked"} data-component-id="${b.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(f.role||f.id)}</div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(b.role||b.id)}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">
|
||||
${f.templateRef?escapeHtml(f.templateRef):"Built-in"}
|
||||
${p?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${d?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
${b.templateRef?escapeHtml(b.templateRef):"Built-in"}
|
||||
${v?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
||||
${s?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
||||
</div>
|
||||
${f.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(f.note)}</div>`:""}
|
||||
${b.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(b.note)}</div>`:""}
|
||||
</div>
|
||||
`,g.appendChild(m)}}function T(){const g=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),f=P.sharedVolumes;if(f&&Object.keys(f).length>0){g.style.display="",u.innerHTML="";for(const[m,p]of Object.entries(f)){const d=document.createElement("div");d.style.cssText="display: grid; gap: 4px;",d.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(p.label||m)}</label>
|
||||
<input type="text" data-volume-key="${m}" value="${escapeHtml(p.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(p.defaultPath||"/path")}"
|
||||
`,y.appendChild(p)}}function L(){const y=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),b=A.sharedVolumes;if(b&&Object.keys(b).length>0){y.style.display="",m.innerHTML="";for(const[p,v]of Object.entries(b)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(v.label||p)}</label>
|
||||
<input type="text" data-volume-key="${p}" value="${escapeHtml(v.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(v.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(p.description||"")}</div>
|
||||
`,u.appendChild(d)}}else g.style.display="none"}function j(){const g=document.getElementById("recipe-review-content"),u=H(),f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};f.forEach(i=>{m[i.dataset.volumeKey]=i.value});const p=document.getElementById("recipe-timezone").value||"UTC",d=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;g.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(P.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(P.description||"")}</div>
|
||||
<div class="form-hint-sm">${escapeHtml(v.description||"")}</div>
|
||||
`,m.appendChild(s)}}else y.style.display="none"}function j(){const y=document.getElementById("recipe-review-content"),m=M(),b=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};b.forEach(r=>{p[r.dataset.volumeKey]=r.value});const v=document.getElementById("recipe-timezone").value||"UTC",s=document.getElementById("recipe-ip").value||"host.docker.internal",l=document.getElementById("recipe-tailscale").checked;y.innerHTML=`
|
||||
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
|
||||
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
|
||||
|
||||
<div style="margin-bottom: 12px;">
|
||||
<strong>Components (${u.length}):</strong>
|
||||
<strong>Components (${m.length}):</strong>
|
||||
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
||||
${u.map(i=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(i.role||i.id)}</strong> ${i.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
${m.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
||||
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
||||
</div>`).join("")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${Object.keys(m).length>0?`<div style="margin-bottom: 12px;">
|
||||
${Object.keys(p).length>0?`<div style="margin-bottom: 12px;">
|
||||
<strong>Volumes:</strong>
|
||||
${Object.entries(m).map(([i,$])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${i}: ${escapeHtml($)}</div>`).join("")}
|
||||
${Object.entries(p).map(([r,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${r}: ${escapeHtml(f)}</div>`).join("")}
|
||||
</div>`:""}
|
||||
|
||||
<div style="font-size: 0.85rem; color: var(--muted);">
|
||||
Timezone: ${escapeHtml(p)} • IP: ${escapeHtml(d)} ${c?"• Tailscale only":""}
|
||||
Timezone: ${escapeHtml(v)} • IP: ${escapeHtml(s)} ${l?"• Tailscale only":""}
|
||||
</div>
|
||||
|
||||
${P.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(P.network.name)}</code></div>`:""}
|
||||
`}function H(){const g=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;g.forEach(m=>{m.checked&&u.add(m.dataset.componentId)});const f=P.components||[];return f.filter(m=>m.required).forEach(m=>u.add(m.id)),f.filter(m=>u.has(m.id))}async function R(){const g=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",g.innerHTML="";const f=H();for(const c of f){const i=document.createElement("div");i.id=`recipe-progress-${c.id}`,i.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;",i.innerHTML=`
|
||||
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
|
||||
`}function M(){const y=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;y.forEach(p=>{p.checked&&m.add(p.dataset.componentId)});const b=A.components||[];return b.filter(p=>p.required).forEach(p=>m.add(p.id)),b.filter(p=>m.has(p.id))}async function O(){const y=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",y.innerHTML="";const b=M();for(const l of b){const r=document.createElement("div");r.id=`recipe-progress-${l.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
||||
<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 style="flex:1; font-weight: 500;">${escapeHtml(l.role||l.id)}</span>
|
||||
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
||||
`,g.appendChild(i)}const m=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};m.forEach(c=>{p[c.dataset.volumeKey]=c.value});const d={selectedComponents:f.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:p},componentOverrides:{}};for(const c of f)x(c.id,"deploying","Deploying...");try{const i=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:P.id,config:d})})).json();if(i.success){for(const $ of i.deployed||[])x($.id,"success",$.url?`Running \u2192 ${$.url}`:"Running");for(const $ of i.errors||[])x($.componentId,"error",$.error);u.style.display="",u.innerHTML=`
|
||||
`,y.appendChild(r)}const p=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};p.forEach(l=>{v[l.dataset.volumeKey]=l.value});const s={selectedComponents:b.map(l=>l.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:v},componentOverrides:{}};for(const l of b)x(l.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:s})})).json();if(r.success){for(const f of r.deployed||[])x(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of r.errors||[])x(f.componentId,"error",f.error);m.style.display="",m.innerHTML=`
|
||||
<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(i.message||"Deployed!")}</div>
|
||||
${i.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(r.message||"Deployed!")}</div>
|
||||
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
||||
<strong>Setup tips:</strong>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${i.setupInstructions.map($=>`<li>${escapeHtml($)}</li>`).join("")}</ul>
|
||||
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
|
||||
</div>`:""}
|
||||
</div>
|
||||
`,showNotification(`${P.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(i.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${i.error}`,"error",5e3)}catch(c){u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
||||
</div>`}}function x(g,u,f){const m=document.getElementById(`recipe-progress-${g}`);if(!m)return;const p=m.querySelector(".recipe-progress-icon"),d=m.querySelector(".recipe-progress-status");u==="deploying"?(p.textContent="\u23F3",d.style.color="var(--accent)"):u==="success"?(p.textContent="\u2705",d.style.color="var(--ok-fg)"):u==="error"&&(p.textContent="\u274C",d.style.color="var(--bad-fg)"),d.textContent=f}v.addEventListener("click",()=>{if(w===3){w=4,B(),R();return}w<3&&(w++,B(),w===2&&T(),w===3&&j())}),A.addEventListener("click",()=>{w>1&&w<4&&(w--,B())}),window.groupRecipeCards=function(){const g=document.querySelectorAll(".service-card[data-recipe-id]");if(g.length===0)return;const u={};g.forEach(f=>{const m=f.dataset.recipeId;u[m]||(u[m]=[]),u[m].push(f)});for(const[f,m]of Object.entries(u))m.length<2||m.forEach((p,d)=>{if(p.style.borderLeft="3px solid rgba(142,68,173,0.5)",d===0){let c=p.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=f.replace(/-/g," "),p.style.position="relative",p.appendChild(c))}})},window.manageRecipe=async function(g,u){const f=`/api/v1/recipes/${g}/${u}`,m=u==="remove"?"DELETE":"POST",p=u==="remove"?`/api/v1/recipes/${g}`:f;if(!(u==="remove"&&!confirm(`Remove the entire ${g} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(p,{method:m})).json();c.success?(showNotification(`Recipe ${u}: ${c.results?.filter(i=>i.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${c.error}`,"error",5e3)}catch(d){showNotification(`Network error: ${d.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=`
|
||||
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(l){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(l.message)}
|
||||
</div>`}}function x(y,m,b){const p=document.getElementById(`recipe-progress-${y}`);if(!p)return;const v=p.querySelector(".recipe-progress-icon"),s=p.querySelector(".recipe-progress-status");m==="deploying"?(v.textContent="\u23F3",s.style.color="var(--accent)"):m==="success"?(v.textContent="\u2705",s.style.color="var(--ok-fg)"):m==="error"&&(v.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=b}g.addEventListener("click",()=>{if(k===3){k=4,$(),O();return}k<3&&(k++,$(),k===2&&L(),k===3&&j())}),P.addEventListener("click",()=>{k>1&&k<4&&(k--,$())}),window.groupRecipeCards=function(){const y=document.querySelectorAll(".service-card[data-recipe-id]");if(y.length===0)return;const m={};y.forEach(b=>{const p=b.dataset.recipeId;m[p]||(m[p]=[]),m[p].push(b)});for(const[b,p]of Object.entries(m))p.length<2||p.forEach((v,s)=>{if(v.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let l=v.querySelector(".recipe-group-label");l||(l=document.createElement("div"),l.className="recipe-group-label",l.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;",l.textContent=b.replace(/-/g," "),v.style.position="relative",v.appendChild(l))}})},window.manageRecipe=async function(y,m){const b=`/api/v1/recipes/${y}/${m}`,p=m==="remove"?"DELETE":"POST",v=m==="remove"?`/api/v1/recipes/${y}`:b;if(!(m==="remove"&&!confirm(`Remove the entire ${y} recipe? This will delete all containers and configuration.`)))try{const l=await(await secureFetch(v,{method:p})).json();l.success?(showNotification(`Recipe ${m}: ${l.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${l.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=`
|
||||
.recipe-step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
.recipe-step-panel {
|
||||
min-height: 180px;
|
||||
}
|
||||
`,document.head.appendChild(D),b()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),E=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const P=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),w=await P.json();if(P.ok&&w.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3);else throw new Error(w.error||"Reload failed")}catch(P){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${P.message}`,"error"),setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),P=document.getElementById("view-error-logs"),w=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),O=document.getElementById("error-log-close");async function z(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=L.logs.map(b=>`
|
||||
`,document.head.appendChild(D),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),S=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await A.json();if(A.ok&&k.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(A){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{h.textContent=S,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),S=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),k=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function z(){S.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const T=await(await fetch("/api/v1/error-logs")).json();T.success&&T.logs?T.logs.length===0?S.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':S.innerHTML=T.logs.map(w=>`
|
||||
<div class="log-entry error">
|
||||
<span class="log-timestamp">${new Date(b.timestamp).toLocaleString()}</span>
|
||||
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
|
||||
<span class="log-level">ERROR</span>
|
||||
<div class="log-message">
|
||||
<strong>${escapeHtml(b.context)}</strong>: ${escapeHtml(b.error)}
|
||||
${b.details?`<br><small style="opacity: 0.7;">${escapeHtml(b.details)}</small>`:""}
|
||||
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
|
||||
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
|
||||
</div>
|
||||
</div>
|
||||
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}P?.addEventListener("click",()=>{h.classList.add("show"),z()}),w?.addEventListener("click",z),N?.addEventListener("click",A),wireModal(h,O)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
`).join(""):S.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){S.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}A?.addEventListener("click",()=>{h.classList.add("show"),z()}),k?.addEventListener("click",z),N?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
<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,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),P=document.getElementById("cl-log-content"),w=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),O=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),b=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),H=document.getElementById("cl-status"),R=document.getElementById("cl-created");let x=null,D=[],g=[],u=null,f=!1,m=null;function p(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function d(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const C=s.stream==="stderr"?"log-stderr":"log-stdout",I=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${C}" 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: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${I}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${d(s.text)}</span>
|
||||
</div>`);const h=document.getElementById("container-logs-modal"),S=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),k=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),E=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),I=document.getElementById("cl-line-count"),L=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),M=document.getElementById("cl-status"),O=document.getElementById("cl-created");let x=null,D=[],y=[],m=null,b=!1,p=null;function v(i){if(!i)return"-";const c=new Date(i);return isNaN(c.getTime())?i:c.toLocaleString()}function s(i){if(!i)return"";const c=document.createElement("div");return c.textContent=i,c.innerHTML}function l(i,c){const C=i.stream==="stderr"?"log-stderr":"log-stdout",B=i.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${C}" data-index="${c}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${c+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${i.stream==="stderr"?"#f59e0b":"#22c55e"};">${B}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(i.text)}</span>
|
||||
</div>
|
||||
`}function i(s,l=""){if(!s||s.length===0){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",T.textContent="0 filtered";return}if(D=s,g=l?s.filter(C=>C.text&&C.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,T.textContent=l?`${g.length} of ${s.length} shown`:`${s.length} shown`,g.length===0){P.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${d(l)}"</div>`;return}P.innerHTML=g.map((C,I)=>c(C,I)).join(""),P.scrollTop=P.scrollHeight}async function $(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],C=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(I=>{const F=document.createElement("option");F.value=I.id,F.textContent=`${I.name} (${I.image.split(":")[0]}) - ${I.status}`,F.dataset.name=I.name,F.dataset.image=I.image,F.dataset.status=I.status,F.dataset.created=I.created,E.appendChild(F)}),C&&E.querySelector(`option[value="${C}"]`)&&(E.value=C,y(C))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=E.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",H.textContent=l.dataset.status||"-",H.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",R.textContent=p(l.dataset.created))}async function n(){const s=E.value;if(!s){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}o(),x=s,y(s);const l=N.value,C=w.value.trim();P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const I=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(I);F.logs&&F.logs.length>0?i(F.logs,C):(P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",T.textContent="0 filtered")}catch(I){P.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${d(I.message)}</div>`}}function e(){const s=E.value;if(!s)return;o(),f=!0,z.textContent="\u23F9 Stop",M.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;u=new EventSource(l),u.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},u.onmessage=C=>{try{const I=JSON.parse(C.data);if(I.error){k.textContent="\u{1F534}",B.textContent=`Error: ${I.error}`;return}D.push(I),g.push(I),S.textContent=`${D.length} lines`,T.textContent=`${g.length} shown`;const F=w.value.trim();if(!F||I.text&&I.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(I,g.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",P.appendChild(U),P.scrollTop=P.scrollHeight}}catch(I){console.error("Error parsing log:",I)}},u.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",f=!1,z.textContent="\u25B6 Stream"},h._eventSource=u}function o(){u&&(u.close(),u=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),f=!1,z.textContent="\u25B6 Stream",M.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const s=E.querySelector(`option[value="${x}"]`)?.dataset.name||x,l=new Date().toISOString().replace(/[:.]/g,"-"),C=`${s}-logs-${l}.txt`,I=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
||||
`),F=new Blob([I],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}E?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),O?.addEventListener("click",()=>{n()}),z?.addEventListener("click",()=>{f?o():e()}),A?.addEventListener("click",()=>{a()}),v?.addEventListener("click",()=>{w.value="",i(D,"")}),w?.addEventListener("input",()=>{clearTimeout(m),m=setTimeout(()=>{i(D,w.value.trim())},300)}),w?.addEventListener("keydown",s=>{s.key==="Escape"&&(w.value="",i(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),$()});function t(){o(),h.classList.remove("show")}L?.addEventListener("click",t),b?.addEventListener("click",t),document.addEventListener("keydown",s=>{s.key==="Escape"&&h.classList.contains("show")&&t()}),h.addEventListener("click",s=>{s.target===h&&t()}),window.openContainerLogsModal=function(s,l){h.classList.add("show"),$().then(()=>{const C=Array.from(E.options).find(I=>I.value===s||I.dataset.name===l);C?(E.value=C.value,y(C.value),n()):s?(x=s,j.textContent=l||s,H.textContent="-",R.textContent="-",n()):P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
`}function r(i,c=""){if(!i||i.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',I.textContent="0 lines",L.textContent="0 filtered";return}if(D=i,y=c?i.filter(C=>C.text&&C.text.toLowerCase().includes(c.toLowerCase())):i,I.textContent=`${i.length} lines`,L.textContent=c?`${y.length} of ${i.length} shown`:`${i.length} shown`,y.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(c)}"</div>`;return}A.innerHTML=y.map((C,B)=>l(C,B)).join(""),A.scrollTop=A.scrollHeight}async function f(){try{const c=(await getJSON("/api/v1/logs/containers")).containers||[],C=S.value;S.innerHTML='<option value="">Select a container...</option>',c.forEach(B=>{const F=document.createElement("option");F.value=B.id,F.textContent=`${B.name} (${B.image.split(":")[0]}) - ${B.status}`,F.dataset.name=B.name,F.dataset.image=B.image,F.dataset.status=B.status,F.dataset.created=B.created,S.appendChild(F)}),C&&S.querySelector(`option[value="${C}"]`)&&(S.value=C,u(C))}catch(i){console.error("Failed to load containers:",i)}}function u(i){const c=S.querySelector(`option[value="${i}"]`);c&&(j.textContent=c.dataset.image||"-",M.textContent=c.dataset.status||"-",M.style.color=c.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",O.textContent=v(c.dataset.created))}async function t(){const i=S.value;if(!i){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}o(),x=i,u(i);const c=N.value,C=k.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const B=`/api/v1/logs/container/${i}${c!=="all"?`?tail=${c}`:""}`,F=await getJSON(B);F.logs&&F.logs.length>0?r(F.logs,C):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',I.textContent="0 lines",L.textContent="0 filtered")}catch(B){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(B.message)}</div>`}}function e(){const i=S.value;if(!i)return;o(),b=!0,z.textContent="\u23F9 Stop",H.style.display="flex",E.textContent="\u{1F7E2}",$.textContent="Connecting...";const c=`/api/v1/logs/stream/${i}`;m=new EventSource(c),m.onopen=()=>{E.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},m.onmessage=C=>{try{const B=JSON.parse(C.data);if(B.error){E.textContent="\u{1F534}",$.textContent=`Error: ${B.error}`;return}D.push(B),y.push(B),I.textContent=`${D.length} lines`,L.textContent=`${y.length} shown`;const F=k.value.trim();if(!F||B.text&&B.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=l(B,y.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",A.appendChild(U),A.scrollTop=A.scrollHeight}}catch(B){console.error("Error parsing log:",B)}},m.onerror=()=>{E.textContent="\u{1F534}",$.textContent="Disconnected",b=!1,z.textContent="\u25B6 Stream"},h._eventSource=m}function o(){m&&(m.close(),m=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),b=!1,z.textContent="\u25B6 Stream",H.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const i=S.querySelector(`option[value="${x}"]`)?.dataset.name||x,c=new Date().toISOString().replace(/[:.]/g,"-"),C=`${i}-logs-${c}.txt`,B=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
||||
`),F=new Blob([B],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}S?.addEventListener("change",()=>{t()}),N?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),z?.addEventListener("click",()=>{b?o():e()}),P?.addEventListener("click",()=>{a()}),g?.addEventListener("click",()=>{k.value="",r(D,"")}),k?.addEventListener("input",()=>{clearTimeout(p),p=setTimeout(()=>{r(D,k.value.trim())},300)}),k?.addEventListener("keydown",i=>{i.key==="Escape"&&(k.value="",r(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),f()});function n(){o(),h.classList.remove("show")}T?.addEventListener("click",n),w?.addEventListener("click",n),document.addEventListener("keydown",i=>{i.key==="Escape"&&h.classList.contains("show")&&n()}),h.addEventListener("click",i=>{i.target===h&&n()}),window.openContainerLogsModal=function(i,c){h.classList.add("show"),f().then(()=>{const C=Array.from(S.options).find(B=>B.value===i||B.dataset.name===c);C?(S.value=C.value,u(C.value),t()):i?(x=i,j.textContent=c||i,M.textContent="-",O.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
||||
<h3>\u{1F4BE} Container Snapshots</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="snapshot-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),P=document.getElementById("snapshot-close"),w=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),O=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let A=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;w.innerHTML='<option value="">-- Select a container --</option>';for(const T of S.containers){const j=document.createElement("option");j.value=T.id,j.textContent=`${T.name||T.id} (${T.image||"unknown"})`,j.dataset.name=T.name,j.dataset.image=T.image,j.dataset.status=T.status,j.dataset.created=T.created,w.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",A=null;return}A=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function b(){if(!A){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;O.disabled=!0,O.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(T){z.textContent=`\u2717 Error: ${T.message}`,z.style.color="var(--bad-fg)"}finally{O.disabled=!1,O.textContent="\u{1F4BE} Create Snapshot"}}function M(){h.classList.add("show"),v()}function k(){h.classList.remove("show"),N.style.display="none",A=null,w.selectedIndex=0}E?.addEventListener("click",M),P?.addEventListener("click",k),wireModal(h,P),w?.addEventListener("change",B=>{const S=w.options[w.selectedIndex];L(S)}),O?.addEventListener("click",b),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("snapshot-modal"),S=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),k=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let P=null;async function g(){try{const I=await(await fetch("/api/v1/containers")).json();if(!I.success||!I.containers)return;k.innerHTML='<option value="">-- Select a container --</option>';for(const L of I.containers){const j=document.createElement("option");j.value=L.id,j.textContent=`${L.name||L.id} (${L.image||"unknown"})`,j.dataset.name=L.name,j.dataset.image=L.image,j.dataset.status=L.status,j.dataset.created=L.created,k.appendChild(j)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){N.style.display="none",P=null;return}P=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),N.style.display=""}async function w(){if(!P){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const I=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:I})})).json();j.success?(z.textContent=`\u2713 Snapshot "${$}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(L){z.textContent=`\u2717 Error: ${L.message}`,z.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function H(){h.classList.add("show"),g()}function E(){h.classList.remove("show"),N.style.display="none",P=null,k.selectedIndex=0}S?.addEventListener("click",H),A?.addEventListener("click",E),wireModal(h,A),k?.addEventListener("change",$=>{const I=k.options[k.selectedIndex];T(I)}),R?.addEventListener("click",w),h?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(I=>I.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(I=>I.classList.remove("active")),$.classList.add("active"),h.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
||||
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
||||
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
||||
@@ -749,73 +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 h=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),P=document.getElementById("arr-setup-cancel"),w=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),O=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),b=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let S=null;const T={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 H(m){N.style.display=m==="detect"?"block":"none",O.style.display=m==="credentials"?"block":"none",z.style.display=m==="progress"?"block":"none",A.style.display=m==="results"?"block":"none"}function R(m){const p={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"}},d=p[m]||p.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, ${d.bg} 20%, transparent); color: ${d.bg};">${d.icon} ${d.text}</span>`}async function x(){H("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,i]of Object.entries(S.services)){const $=T[c]||"\u{1F4E6}",y=j[c]||c,n=i.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(i.source)}</span>`:"",e=i.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(i.version)}</span>`:"",o=(i.hasApiKey||i.hasToken)&&i.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";p+=`<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;">${$}</span>
|
||||
</div>`);const h=document.getElementById("arr-setup-modal"),S=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let I=null;const L={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function M(p){N.style.display=p==="detect"?"block":"none",R.style.display=p==="credentials"?"block":"none",z.style.display=p==="progress"?"block":"none",P.style.display=p==="results"?"block":"none"}function O(p){const v={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"}},s=v[p]||v.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function x(){M("detect"),g.style.display="none";try{if(I=await(await fetch("/api/v1/arr/smart-detect")).json(),!I.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(I.error)}</div>`,g.style.display="block";return}let v='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[l,r]of Object.entries(I.services)){const f=L[l]||"\u{1F4E6}",u=j[l]||l,t=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",e=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",o=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";v+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${f}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
|
||||
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
||||
${n} ${e} ${o}
|
||||
${t} ${e} ${o}
|
||||
</div>
|
||||
</div>
|
||||
${R(i.status)}
|
||||
</div>`}p+="</div>";const d=S.summary;p+=`<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(d.fullyConnected))}/${escapeHtml(String(d.totalDetected+(5-d.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(d.fullyConnected))} connected${d.needsApiKey>0?` · <strong>${escapeHtml(String(d.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,v.innerHTML=p,v.style.display="block",D(S),setTimeout(()=>{H("credentials")},800)}catch(m){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(m.message)}</div>`,v.style.display="block"}}function D(m){let p="";const d=m.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const n=d[y];if(!n||n.status==="not_found"&&!n.url)continue;const e=T[y],o=j[y],a=n.status==="connected";p+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
|
||||
${O(r.status)}
|
||||
</div>`}v+="</div>";const s=I.summary;v+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
${escapeHtml(String(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` · <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,g.innerHTML=v,g.style.display="block",D(I),setTimeout(()=>{M("credentials")},800)}catch(p){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(p.message)}</div>`,g.style.display="block"}}function D(p){let v="";const s=p.services,l=["radarr","sonarr","prowlarr"];for(const u of l){const t=s[u];if(!t||t.status==="not_found"&&!t.url)continue;const e=L[u],o=j[u],a=t.status==="connected";v+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"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;">${o}</span>
|
||||
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
<span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
|
||||
${a?'<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-${y}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${y}/"
|
||||
<input type="text" id="smart-${u}-url" value="${escapeHtml(t.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-${y}-key" placeholder="${a?"(saved)":"Paste API key"}"
|
||||
<input type="password" id="smart-${u}-key" placeholder="${a?"(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('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
||||
</div>`}const i=d.plex;if(i){const y=i.status==="connected";p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"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 r=s.plex;if(r){const u=r.status==="connected";v+=`<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>
|
||||
${R(i.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(i.source||"")}</span>
|
||||
${O(r.status)}
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
||||
</div>
|
||||
</div>`}const $=d.seerr;if($){const y=$.status==="connected";let n="";if($.configuredServices){const e=$.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
</div>`}const f=s.seerr;if(f){const u=f.status==="connected";let t="";if(f.configuredServices){const e=f.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
||||
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
||||
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
||||
${e.plex?"✓ Plex":"✗ Plex"}
|
||||
</div>`}p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`}v+=`<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>
|
||||
${R($.status)}
|
||||
${O(f.status)}
|
||||
</div>
|
||||
${n}
|
||||
</div>`}L.innerHTML=p}window.smartTestConnection=async function(m){const p=document.getElementById(`smart-${m}-url`),d=document.getElementById(`smart-${m}-key`),c=document.getElementById(`smart-${m}-status`),i=p?.value.trim(),$=d?.value.trim();if(!i||!$){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:m,url:i,apiKey:$})})).json();n.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(n.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(y.message)}</span>`}};async function g(){H("progress"),b.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 m={};for(const d of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${d}-url`)?.value.trim(),i=document.getElementById(`smart-${d}-key`)?.value.trim();i&&c?m[d]={apiKey:i,url:c}:i&&(m[d]={apiKey:i})}const p={services:Object.keys(m).length>0?m: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(p)})).json();let i="";for(const $ of c.steps||[]){const y=$.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',n=$.status==="success"?"var(--muted)":"var(--bad-fg)";i+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${y}
|
||||
<span>${escapeHtml($.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml($.details||"")}</span>
|
||||
</div>`}b.innerHTML=i,setTimeout(()=>u(c),500)}catch(d){b.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(d.message)}</div>`}}function u(m){H("results");const p=m.summary||{},d=p.failed===0&&p.succeeded>0,c=d?"var(--ok-fg)":"#f39c12",i=d?"✓":"⚠",$=d?"All Connected!":`${escapeHtml(String(p.succeeded))}/${escapeHtml(String(p.totalSteps))} Steps Succeeded`;let y=`<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};">${i}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${$}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(p.succeeded))} succeeded, ${escapeHtml(String(p.failed))} failed</div>
|
||||
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of m.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
||||
${e} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span>
|
||||
</div>`}y+="</div>",M.innerHTML=y,B.style.display=p.failed>0?"block":"none",m.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&f()}async function f(){try{const p=await(await fetch("/api/v1/plex/libraries")).json();if(p.success&&p.libraries?.length>0){let d=`<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(p.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of p.libraries){const i=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";d+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${i} <strong>${escapeHtml(c.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
||||
</div>`}d+="</div></div>",k.innerHTML=d,k.style.display="block"}}catch{}}E?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,P),w?.addEventListener("click",g),B?.addEventListener("click",g)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
${t}
|
||||
</div>`}T.innerHTML=v}window.smartTestConnection=async function(p){const v=document.getElementById(`smart-${p}-url`),s=document.getElementById(`smart-${p}-key`),l=document.getElementById(`smart-${p}-status`),r=v?.value.trim(),f=s?.value.trim();if(!r||!f){l.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}l.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:p,url:r,apiKey:f})})).json();t.success?l.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:l.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(t.error)}</span>`}catch(u){l.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(u.message)}</span>`}};async function y(){M("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const p={};for(const s of["radarr","sonarr","prowlarr"]){const l=document.getElementById(`smart-${s}-url`)?.value.trim(),r=document.getElementById(`smart-${s}-key`)?.value.trim();r&&l?p[s]={apiKey:r,url:l}:r&&(p[s]={apiKey:r})}const v={services:Object.keys(p).length>0?p:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const l=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();let r="";for(const f of l.steps||[]){const u=f.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',t=f.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
||||
${u}
|
||||
<span>${escapeHtml(f.step)}</span>
|
||||
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(f.details||"")}</span>
|
||||
</div>`}w.innerHTML=r,setTimeout(()=>m(l),500)}catch(s){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(s.message)}</div>`}}function m(p){M("results");const v=p.summary||{},s=v.failed===0&&v.succeeded>0,l=s?"var(--ok-fg)":"#f39c12",r=s?"✓":"⚠",f=s?"All Connected!":`${escapeHtml(String(v.succeeded))}/${escapeHtml(String(v.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${l} 12%, transparent); border-radius: 10px; border: 1px solid ${l}; margin-bottom: 12px;">
|
||||
<div style="font-size: 1.5rem; color: ${l};">${r}</div>
|
||||
<div style="font-size: 1.1rem; font-weight: 600; color: ${l};">${f}</div>
|
||||
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(v.succeeded))} succeeded, ${escapeHtml(String(v.failed))} failed</div>
|
||||
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of p.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">✓</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(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
|
||||
</div>`}u+="</div>",H.innerHTML=u,$.style.display=v.failed>0?"block":"none",p.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&b()}async function b(){try{const v=await(await fetch("/api/v1/plex/libraries")).json();if(v.success&&v.libraries?.length>0){let s=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(v.serverName)} Libraries</h4>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const l of v.libraries){const r=l.type==="movie"?"\u{1F3AC}":l.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${r} <strong>${escapeHtml(l.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(l.count))} items</span>
|
||||
</div>`}s+="</div></div>",E.innerHTML=s,E.style.display="block"}}catch{}}S?.addEventListener("click",()=>{h.classList.add("show"),E.style.display="none",x()}),wireModal(h,A),k?.addEventListener("click",y),$?.addEventListener("click",y)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F514} Notification Settings</h3>
|
||||
|
||||
@@ -1000,15 +1000,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"),P=document.getElementById("manage-notifications"),w=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{S.style.display=B.checked?"block":"none"})});const O=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");O?.addEventListener("change",()=>{z.style.opacity=O.checked?"1":"0.5"});async function A(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const S=B.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");B.success&&B.history?.length>0?S.innerHTML=B.history.map(T=>{const j=new Date(T.timestamp).toLocaleString();return`
|
||||
</div>`);const S=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),k=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(E=>{const $=document.getElementById(`${E}-enabled`),I=document.getElementById(`${E}-config`);$?.addEventListener("change",()=>{I.style.display=$.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");R?.addEventListener("change",()=>{z.style.opacity=R.checked?"1":"0.5"});async function P(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const I=$.config;document.getElementById("notifications-enabled").checked=I.enabled,document.getElementById("discord-enabled").checked=I.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=I.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=I.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=I.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=I.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=I.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=I.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=I.providers?.email?.enabled?"block":"none",I.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=I.providers.ntfy.serverUrl),I.providers?.email?.host&&(document.getElementById("email-host").value=I.providers.email.host),I.providers?.email?.from&&(document.getElementById("email-from").value=I.providers.email.from),document.getElementById("health-check-enabled").checked=I.healthCheck?.enabled||!1,I.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=I.healthCheck.intervalMinutes),I.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(I.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=I.events?.containerDown!==!1,document.getElementById("event-container-up").checked=I.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=I.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=I.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=I.events?.resourceAlert!==!1}}catch(E){h.logError("[Notifications] Load Config",E,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),I=document.getElementById("notification-history");$.success&&$.history?.length>0?I.innerHTML=$.history.map(L=>{const j=new Date(L.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)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[L.type]||"var(--muted)"}">${L.type==="success"?"\u2713":L.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(T.title)}</div>
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(L.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function b(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>b("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>b("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>b("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>b("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),P?.addEventListener("click",()=>{E.classList.add("show"),A(),v()}),w?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const T=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();T.success?(showNotification("Test notification sent!","success",3e3),M()):showNotification(`Test failed: ${T.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function M(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(E,N)})(),(function(){document.addEventListener("click",h=>{const E=h.target.closest(".panel-tab");if(!E)return;const P=E.dataset.panel;if(!P)return;const w=E.closest(".panel-tabs"),N=w.closest(".weather-modal-content");w.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),E.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const O=N.querySelector("#"+P);O&&O.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca
Line truncated
|
||||
`}).join(""):I.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(E){h.logError("[Notifications] Load History",E,{function:"loadHistory"})}}async function T(){try{const E={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},I=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();I.success?(showNotification("Notification settings saved","success",3e3),S.classList.remove("show")):showNotification(`Failed to save: ${I.error}`,"error",3e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}async function w(E){try{const I=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:E})})).json();I.success?showNotification(`Test ${E} notification sent!`,"success",3e3):showNotification(`Test failed: ${I.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=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(E){showNotification(`Error: ${E.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{S.classList.add("show"),P(),g()}),k?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const E=document.getElementById("notifications-send-test"),$=E.textContent;E.textContent="Sending...",E.disabled=!0;try{const L=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();L.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${L.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}finally{E.textContent=$,E.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const I=document.getElementById("last-notification-sent");I&&(I.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(S,N)})(),(function(){document.addEventListener("click",h=>{const S=h.target.closest(".panel-tab");if(!S)return;const A=S.dataset.panel;if(!A)return;const k=S.closest(".panel-tabs"),N=k.closest(".weather-modal-content");k.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),S.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const R=N.querySelector("#"+A);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F4BE} Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1114,9 +1114,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="backup-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);var O=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),b=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),H=document.getElementById("backup-history-container"),R=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){O.classList.add("show"),T&&(T.style.display="none"),k&&(k.style.display="none"),M&&(M.style.display="none"),D=null}),wireModal(O,A),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=E();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),r=URL.createObjectURL(a),t=document.createElement("a");t.href=r,t.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(t),t.click(),document.body.removeChild(t),URL.revokeObjectURL(r);var s=Object.keys(o.browserState).length,l=o.themes?Object.keys(o.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),T.style.display="block",T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)"}catch(C){T.innerHTML="\u274C Export failed: "+escapeHtml(C.message),T.style.display="block",T.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",T.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){b.click()}),b?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){M.textContent="\u{1F4C4} "+o.name,M.style.display="block",T.style.display="none";try{var a=await o.text(),r=JSON.parse(a);if(w(r)){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";t+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),t+="</div>",B.innerHTML=t,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),l=await s.json();if(l.success){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";t+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',t+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var C in l.preview.files){var I=l.preview.files[C],F=I.action==="create"?"\u{1F195}":"\u{1F4DD}";t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(I.description)+"</span>"}t+="</div>",l.preview.serviceCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(t+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',t+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=t,k.style.display="block"}else T.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),T.style.display="block",T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12",k.style.display="none"}catch(q){T.innerHTML="\u274CLine truncated
|
||||
</div>`);var R=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),I=document.getElementById("backup-do-restore-btn"),L=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),M=document.getElementById("backup-history-container"),O=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){R.classList.add("show"),L&&(L.style.display="none"),E&&(E.style.display="none"),H&&(H.style.display="none"),D=null}),wireModal(R,P),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=S();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),d=URL.createObjectURL(a),n=document.createElement("a");n.href=d,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(d);var i=Object.keys(o.browserState).length,c=o.themes?Object.keys(o.themes).length:0;L.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+i+" browser settings"+(c?" + "+c+" themes":""),L.style.display="block",L.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",L.style.border="1px solid var(--ok-fg)"}catch(C){L.innerHTML="\u274C Export failed: "+escapeHtml(C.message),L.style.display="block",L.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",L.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){H.textContent="\u{1F4C4} "+o.name,H.style.display="block",L.style.display="none";try{var a=await o.text(),d=JSON.parse(a);if(k(d)){D=d;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(d.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',d.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+d.services.length+" services</span>"),d.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+d.customApps.length+" custom apps</span>"),d.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(d.theme)+"</span>"),d.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(d.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,E.style.display="block";return}var i=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),c=await i.json();if(c.success){D=d;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(d.exportedAt).toLocaleString()+" (v"+escapeHtml(d.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var C in c.preview.files){var B=c.preview.files[C],F=B.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(B.description)+"</span>"}n+="</div>",c.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+c.preview.serviceCount+" services</div>"),c.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+c.preview.themeCount+" custom themes</div>"),c.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+c.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,E.style.display="block"}else L.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(c.error),L.style.display="block",L.style.background="color-mix(in srgb, #f39c12 15%, transparent)",L.style.border="1px solid #f39c12",E.style.display="none"}catch(q){L.innerHTML="\u274CLine truncated
|
||||
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await a.json();r.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),d=await a.json();d.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(d.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F4CA} Resource Monitor</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1196,10 +1196,10 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),P=document.getElementById("stats-cancel"),w=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),O=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let L=null,b=null;function M(i){if(i===0||!i)return"0 B";const $=1024,y=["B","KB","MB","GB"],n=Math.floor(Math.log(i)/Math.log($));return parseFloat((i/Math.pow($,n)).toFixed(1))+" "+y[n]}function k(i){return i<30?"#2ecc71":i<70?"#f39c12":"#e74c3c"}function B(i){return i<50?"#2ecc71":i<80?"#f39c12":"#e74c3c"}async function S(){try{let i=null,$=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(i=e.stats,$=!0,b=e.stats)}catch{}if(!$){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){i={};for(const o of e.stats)i[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};b=i}}if(!i||Object.keys(i).length===0){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(i)){const o=e.current||e,a=o.cpu?.percent||0,r=o.memory?.percent||0,t=k(a),s=B(r),l=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,I=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;y+=`
|
||||
</div>`);const h=document.getElementById("stats-modal"),S=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let T=null,w=null;function H(r){if(r===0||!r)return"0 B";const f=1024,u=["B","KB","MB","GB"],t=Math.floor(Math.log(r)/Math.log(f));return parseFloat((r/Math.pow(f,t)).toFixed(1))+" "+u[t]}function E(r){return r<30?"#2ecc71":r<70?"#f39c12":"#e74c3c"}function $(r){return r<50?"#2ecc71":r<80?"#f39c12":"#e74c3c"}async function I(){try{let r=null,f=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(r=e.stats,f=!0,w=e.stats)}catch{}if(!f){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){r={};for(const o of e.stats)r[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};w=r}}if(!r||Object.keys(r).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(r)){const o=e.current||e,a=o.cpu?.percent||0,d=o.memory?.percent||0,n=E(a),i=$(d),c=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,B=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.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||n}</span>
|
||||
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
|
||||
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
||||
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
||||
</div>
|
||||
@@ -1208,32 +1208,32 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${n}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
||||
<div style="height: 100%; width: ${Math.min(r,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
<div style="height: 100%; width: ${Math.min(d,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
|
||||
</div>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${r.toFixed(1)}%</span>
|
||||
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${d.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(l)} / ${M(C)}</div>
|
||||
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(c)} / ${H(C)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
||||
<div style="font-size: 0.8rem;">
|
||||
<span style="color: #3498db;">\u2193 ${M(I)}</span>
|
||||
<span style="color: #3498db;">\u2193 ${H(B)}</span>
|
||||
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${M(F)}</span>
|
||||
<span style="color: #e74c3c;">\u2191 ${H(F)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}y+="</div>",O.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(i){O.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(i.message)}</div>`}}async function T(){if(!z)return;const i=b;if(!i||Object.keys(i).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let $='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,n]of Object.entries(i)){const e=n.aggregated;e&&($+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||y}</div>
|
||||
</div>`}u+="</div>",R.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(r){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(r.message)}</div>`}}async function L(){if(!z)return;const r=w;if(!r||Object.keys(r).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,t]of Object.entries(r)){const e=t.aggregated;e&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||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>
|
||||
@@ -1241,27 +1241,27 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
||||
</div>
|
||||
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
||||
</div>`)}$+="</div>",z.innerHTML=$}async function j(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const i=b;if(!i||Object.keys(i).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let $=!1;try{$=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{$=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const o=Object.entries(i).map(([t,s])=>{const l=n[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${t}">
|
||||
<td style="font-weight: 600; padding: 8px;">${s.name||t}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${$?"":"disabled"} /></td>
|
||||
</div>`)}f+="</div>",z.innerHTML=f}async function j(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const r=w;if(!r||Object.keys(r).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let u=[];try{const i=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();i.success&&(u=i.history||[])}catch{}let t={};try{const i=await(await fetch("/api/v1/monitoring/alerts/config")).json();i.success&&(t=i.configs||{})}catch{}const o=Object.entries(r).map(([n,i])=>{const c=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
||||
<tr data-container="${n}">
|
||||
<td style="font-weight: 600; padding: 8px;">${i.name||n}</td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${c.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${c.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${c.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${c.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
|
||||
<td style="padding: 4px 8px;">
|
||||
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${s.name||t}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${i.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
||||
</td>
|
||||
</tr>
|
||||
`}).join(""),a=y.map(t=>{const s=new Date(t.timestamp).toLocaleString(),l=t.notified?"\u2713":"\u2014";return`
|
||||
`}).join(""),a=u.map(n=>{const i=new Date(n.timestamp).toLocaleString(),c=n.notified?"\u2713":"\u2014";return`
|
||||
<tr>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${t.containerName||t.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${t.metric||t.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${l}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${i}</td>
|
||||
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
|
||||
<td style="padding: 6px 8px; text-transform: capitalize;">${n.metric||n.type}</td>
|
||||
<td style="padding: 6px 8px;">${typeof n.value=="number"?n.value.toFixed(1):n.value}${n.metric==="disk"?" MB/s":"%"}</td>
|
||||
<td style="padding: 6px 8px; text-align: center;">${c}</td>
|
||||
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
|
||||
</tr>
|
||||
`}).join(""),r=$?`
|
||||
`}).join(""),d=f?`
|
||||
<div style="margin-bottom: 20px;">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
||||
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
|
||||
@@ -1292,8 +1292,8 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
||||
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
||||
</div>
|
||||
`;A.innerHTML=`
|
||||
${r}
|
||||
`;P.innerHTML=`
|
||||
${d}
|
||||
<div>
|
||||
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
||||
${a?`
|
||||
@@ -1314,21 +1314,21 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
</div>
|
||||
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
||||
</div>
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;t[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),C=document.getElementById("save-all-alerts");C.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),h.classList.remove("show"),R(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.textContent;t.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${t.dataset.container}/test`,{method:"POST"}),t.textContent="\u2705",showNotification("Test alert sent for "+t.dataset.name,"success",3e3)}catch{t.textContent="\u274C"}setTimeout(()=>{t.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),R(),typeof openLicenseModal=="function"&&openLicenseModal()})}function H(){L&&clearInterval(L),N?.checked&&(L=setInterval(S,DC.POLL.STATS))}function R(){L&&(clearInterval(L),L=null)}E?.addEventListener("click",()=>{h.classList.add("show"),S(),H()}),P?.addEventListener("click",()=>{h.classList.remove("show"),R()}),h?.addEventListener("click",i=>{i.target===h&&(h.classList.remove("show"),R())}),w?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?H():R()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),g=document.querySelectorAll(".stats-range-btn");let u="1h";function f(i){switch(i){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 m(i){return i==="raw"?"live (10s samples)":i==="hourly"?"hourly average":i==="daily"?"daily average":i}function p(i,$,y,n,e){if(!i||i.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const o=i.map($).filter(G=>G!=null);if(o.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const a=Math.max(...o,1),r=Math.min(...o,0),t=a-r||1,s=600,l=80,C=4,I=(s-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*I,Q=l-C-(G-r)/t*(l-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return`
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(i=>{const c=i.dataset.container;n[c]={cpuThreshold:parseInt(i.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(i.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(i.querySelector(".alert-disk")?.value)||50,autoRestart:!!i.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const c=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),C=document.getElementById("save-all-alerts");C.textContent=c.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const c=document.getElementById("save-all-alerts");c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),h.classList.remove("show"),O(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=i},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),O(),typeof openLicenseModal=="function"&&openLicenseModal()})}function M(){T&&clearInterval(T),N?.checked&&(T=setInterval(I,DC.POLL.STATS))}function O(){T&&(clearInterval(T),T=null)}S?.addEventListener("click",()=>{h.classList.add("show"),I(),M()}),A?.addEventListener("click",()=>{h.classList.remove("show"),O()}),h?.addEventListener("click",r=>{r.target===h&&(h.classList.remove("show"),O())}),k?.addEventListener("click",I),N?.addEventListener("change",()=>{N.checked?M():O()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",L),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),y=document.querySelectorAll(".stats-range-btn");let m="1h";function b(r){switch(r){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function p(r){return r==="raw"?"live (10s samples)":r==="hourly"?"hourly average":r==="daily"?"daily average":r}function v(r,f,u,t,e){if(!r||r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=r.map(f).filter(G=>G!=null);if(o.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=Math.max(...o,1),d=Math.min(...o,0),n=a-d||1,i=600,c=80,C=4,B=(i-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*B,Q=c-C-(G-d)/n*(c-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.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(n)}</span>
|
||||
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
|
||||
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
|
||||
</div>
|
||||
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${F}" />
|
||||
<svg viewBox="0 0 ${i} ${c}" preserveAspectRatio="none" style="width: 100%; height: ${c}px; background: var(--base); border-radius: 4px;">
|
||||
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
|
||||
</svg>
|
||||
</div>
|
||||
`}function d(){if(!x)return;const i=b||{},$=x.value,y=Object.entries(i);if(y.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=y.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),$&&i[$]&&(x.value=$)}async function c(){if(!D||!x)return;const i=x.value;if(!i){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const $=Date.now(),y=$-f(u);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(i)}?startTime=${y}&endTime=${$}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${m(a)}.</div>`;return}const r=a==="raw",t=r?F=>F.cpu?.percent:F=>F.cpu?.avg,s=r?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=r?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=r?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let I=`
|
||||
`}function s(){if(!x)return;const r=w||{},f=x.value,u=Object.entries(r);if(u.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=u.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),f&&r[f]&&(x.value=f)}async function l(){if(!D||!x)return;const r=x.value;if(!r){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),u=f-b(m);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(r)}?startTime=${u}&endTime=${f}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${p(a)}.</div>`;return}const d=a==="raw",n=d?F=>F.cpu?.percent:F=>F.cpu?.avg,i=d?F=>F.memory?.percent:F=>F.memory?.avgPercent,c=d?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=d?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let B=`
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
||||
${o.length} samples \xB7 ${escapeHtml(m(a))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date($).toLocaleString()}
|
||||
${o.length} samples \xB7 ${escapeHtml(p(a))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
|
||||
</div>
|
||||
`;I+=p(o,t,"#2ecc71","CPU","%"),I+=p(o,s,"#3498db","Memory","%"),I+=p(o,l,"#9b59b6","Network RX"," MB"),I+=p(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=I}catch(n){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}g.forEach(i=>{i.addEventListener("click",()=>{g.forEach($=>$.classList.remove("active")),i.classList.add("active"),u=i.dataset.range,c()})}),x?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{d(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
||||
`;B+=v(o,n,"#2ecc71","CPU","%"),B+=v(o,i,"#3498db","Memory","%"),B+=v(o,c,"#9b59b6","Network RX"," MB"),B+=v(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=B}catch(t){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}y.forEach(r=>{r.addEventListener("click",()=>{y.forEach(f=>f.classList.remove("active")),r.classList.add("active"),m=r.dataset.range,l()})}),x?.addEventListener("change",l),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{s(),l()})})(),(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">
|
||||
@@ -1414,24 +1414,24 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),P=document.getElementById("health-cancel"),w=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),O=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),b=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function S(g){return g>=99.9?"var(--ok-fg)":g>=95?"#f39c12":"var(--bad-fg)"}function T(g){const u={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${u[g]||"var(--muted)"}20; color: ${u[g]||"var(--muted)"};">${g}</span>`}async function j(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const f=Object.values(u.status);let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',m+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',m+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',m+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of f){const d=p.status==="up",c=d?"var(--dot-ok)":"var(--dot-bad)",i=p.uptime?.["24h"]??"-",$=p.uptime?.["7d"]??"-",y=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",n=p.timestamp?timeAgo(p.timestamp):"-";m+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px; color: ${typeof i=="number"?S(i):"var(--muted)"};">${typeof i=="number"?i.toFixed(1)+"%":i}</td>`,m+=`<td style="padding: 8px; color: ${typeof $=="number"?S($):"var(--muted)"};">${typeof $=="number"?$.toFixed(1)+"%":$}</td>`,m+=`<td style="padding: 8px;">${y}</td>`,m+=`<td style="padding: 8px; color: var(--muted);">${n}</td>`,m+="</tr>",m+=`<tr id="health-detail-${escapeHtml(p.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>`}m+="</table>",N.innerHTML=m,A.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(p=>{p.addEventListener("click",async()=>{const d=p.dataset.healthId,c=document.getElementById("health-detail-"+d);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const $=await(await fetch(`/api/v1/health-checks/${d}/stats?hours=24`)).json();if($.success&&$.stats){const y=$.stats,n=y.responseTime||{};c.querySelector("td").innerHTML=`
|
||||
</div>`);const h=document.getElementById("health-modal"),S=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save");let $=null;function I(y){return y>=99.9?"var(--ok-fg)":y>=95?"#f39c12":"var(--bad-fg)"}function L(y){const m={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${m[y]||"var(--muted)"}20; color: ${m[y]||"var(--muted)"};">${y}</span>`}async function j(){try{const m=await(await fetch("/api/v1/health-checks/status")).json();if(!m.success||!m.status||Object.keys(m.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const b=Object.values(m.status);let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',p+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',p+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',p+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const v of b){const s=v.status==="up",l=s?"var(--dot-ok)":"var(--dot-bad)",r=v.uptime?.["24h"]??"-",f=v.uptime?.["7d"]??"-",u=v.avgResponseTime!=null?Math.round(v.avgResponseTime)+"ms":"-",t=v.timestamp?timeAgo(v.timestamp):"-";p+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(v.serviceId)}">`,p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.name||v.serviceId)}</td>`,p+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${l}; margin-right: 6px;"></span>${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px; color: ${typeof r=="number"?I(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,p+=`<td style="padding: 8px; color: ${typeof f=="number"?I(f):"var(--muted)"};">${typeof f=="number"?f.toFixed(1)+"%":f}</td>`,p+=`<td style="padding: 8px;">${u}</td>`,p+=`<td style="padding: 8px; color: var(--muted);">${t}</td>`,p+="</tr>",p+=`<tr id="health-detail-${escapeHtml(v.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}p+="</table>",N.innerHTML=p,P.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(v=>{v.addEventListener("click",async()=>{const s=v.dataset.healthId,l=document.getElementById("health-detail-"+s);if(l){if(l.style.display!=="none"){l.style.display="none";return}l.style.display="";try{const f=await(await fetch(`/api/v1/health-checks/${s}/stats?hours=24`)).json();if(f.success&&f.stats){const u=f.stats,t=u.responseTime||{};l.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>${y.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${S(y.uptime||0)};">${(y.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(n.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(n.p95||0)}ms / ${Math.round(n.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(n.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(n.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${y.upChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${y.downChecks||0}</strong></div>
|
||||
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(i){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}})})}catch(g){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(g.message)}</div>`}}async function H(){try{const[g,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),f=await g.json(),m=await u.json();let p="";const d=f.success&&f.incidents?f.incidents:[];if(d.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+d.length+")</h4>";for(const i of d)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
||||
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${u.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${I(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
|
||||
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(t.avg||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(t.p95||0)}ms / ${Math.round(t.p99||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(t.min||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(t.max||0)}ms</strong></div>
|
||||
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${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 l.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){l.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(y.message)}</div>`}}async function M(){try{const[y,m]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),b=await y.json(),p=await m.json();let v="";const s=b.success&&b.incidents?b.incidents:[];if(s.length>0){v+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+s.length+")</h4>";for(const r of s)v+=`<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(i.serviceId)}</span>
|
||||
<span>${T(i.severity)}</span>
|
||||
<span style="font-weight: 500;">${escapeHtml(r.serviceId)}</span>
|
||||
<span>${L(r.severity)}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(i.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(i.createdAt)} \xB7 ${i.occurrences||1} occurrence(s)</div>
|
||||
</div>`;p+="</div>"}else p+='<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=m.success&&m.history?m.history:[];if(c.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<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 i of c){const $=i.status==="resolved",y=$&&i.duration?i.duration<6e4?Math.round(i.duration/1e3)+"s":Math.round(i.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(i.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(i.type)}</td>`,p+=`<td style="padding: 6px;">${T(i.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${$?"var(--ok-fg)":"var(--bad-fg)"};">${i.status}</span></td>`,p+=`<td style="padding: 6px;">${y}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(i.createdAt)}</td>`,p+="</tr>"}p+="</table>"}O.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(g){O.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}async function R(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),f=u.success&&u.status?Object.values(u.status):[];if(f.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<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 p of f){const d=p.status==="up";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px; color: ${d?"var(--ok-fg)":"var(--bad-fg)"};">${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,m+='<td style="padding: 8px; text-align: right;">',m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,m+="</td></tr>"}m+="</table>",z.innerHTML=m}catch(g){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}function x(g,u,f,m,p,d,c){B=g||null,b.textContent=g?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=g||"",document.getElementById("health-form-id").disabled=!!g,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=f||"",document.getElementById("health-form-timeout").value=m||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=d||99.9,document.getElementById("health-form-slow").value=c||5e3,L.style.display="",v.style.display="none"}function D(){L.style.display="none",v.style.display="",B=null}v?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",D),k?.addEventListener("click",async()=>{const g=B||document.getElementById("health-form-id").value.trim();if(!g)return showNotification("Service ID is required","warning");const u=document.getElementById("health-form-url").value.trim();if(!u)return showNotification("URL is required","warning");const f=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),m={name:document.getElementById("health-form-name").value.trim()||g,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:f.length?f:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
|
||||
</div>`;v+="</div>"}else v+='<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 l=p.success&&p.history?p.history:[];if(l.length>0){v+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',v+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of l){const f=r.status==="resolved",u=f&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,v+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,v+=`<td style="padding: 6px;">${L(r.severity)}</td>`,v+=`<td style="padding: 6px;"><span style="color: ${f?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,v+=`<td style="padding: 6px;">${u}</td>`,v+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,v+="</tr>"}v+="</table>"}R.innerHTML=v||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function O(){try{const m=await(await fetch("/api/v1/health-checks/status")).json(),b=m.success&&m.status?Object.values(m.status):[];if(b.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let p='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const v of b){const s=v.status==="up";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.name||v.serviceId)}</td>`,p+=`<td style="padding: 8px; color: ${s?"var(--ok-fg)":"var(--bad-fg)"};">${s?"Up":"Down"}</td>`,p+=`<td style="padding: 8px;">${v.sla?.target?v.sla.target+"%":"-"}</td>`,p+='<td style="padding: 8px; text-align: right;">',p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(v.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,p+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(v.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,p+="</td></tr>"}p+="</table>",z.innerHTML=p}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}function x(y,m,b,p,v,s,l){$=y||null,w.textContent=y?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=y||"",document.getElementById("health-form-id").disabled=!!y,document.getElementById("health-form-name").value=m||"",document.getElementById("health-form-url").value=b||"",document.getElementById("health-form-timeout").value=p||1e4,document.getElementById("health-form-codes").value=v||"200",document.getElementById("health-form-sla").value=s||99.9,document.getElementById("health-form-slow").value=l||5e3,T.style.display="",g.style.display="none"}function D(){T.style.display="none",g.style.display="",$=null}g?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",D),E?.addEventListener("click",async()=>{const y=$||document.getElementById("health-form-id").value.trim();if(!y)return showNotification("Service ID is required","warning");const m=document.getElementById("health-form-url").value.trim();if(!m)return showNotification("URL is required","warning");const b=document.getElementById("health-form-codes").value.split(",").map(v=>parseInt(v.trim())).filter(Boolean),p={name:document.getElementById("health-form-name").value.trim()||y,url:m,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:b.length?b:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u2B06\uFE0F Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1505,17 +1505,17 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),P=document.getElementById("updates-cancel"),w=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),O=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function v(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const e=n.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';o+='<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 t of e){const s=(()=>{const l=window.APPS||[];for(const C of l)if(C.containerId===t.containerId||C.name===t.containerName||C.id===t.containerName)return C.id;return t.containerName})();o+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,o+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,o+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,o+=`<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(t.latestDigest)}</code></td>`,o+='<td style="padding: 8px; text-align: right;">',o+=`<button class="update-now-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,o+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,o+="</td></tr>"}o+="</table>",N.innerHTML=o,A.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(I.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Update failed")}catch(C){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{t.textContent="Update",t.disabled=!1,t.style.color="",t.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(I.success)t.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Rollback failed")}catch(C){t.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function L(){const y=window._pendingUpdates||[];if(!y.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,o=0;for(const a of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}n.textCLine truncated
|
||||
</div>`);const h=document.getElementById("updates-modal"),S=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function g(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';o+='<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 e){const i=(()=>{const c=window.APPS||[];for(const C of c)if(C.containerId===n.containerId||C.name===n.containerName||C.id===n.containerName)return C.id;return n.containerName})();o+=`<tr data-app-id="${escapeHtml(i)}" style="border-bottom: 1px solid var(--border);">`,o+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,o+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,o+=`<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>`,o+='<td style="padding: 8px; text-align: right;">',o+=`<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>`,o+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,o+="</td></tr>"}o+="</table>",N.innerHTML=o,P.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),d=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),d&&e.length>0&&(d.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Update "${c}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(i)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(B.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Update failed")}catch(C){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const i=n.dataset.id,c=n.dataset.name;if(confirm(`Rollback "${c}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const B=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(i)}`,{method:"POST"})).json();if(B.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(B.error||"Rollback failed")}catch(C){n.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(u){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}async function T(){const u=window._pendingUpdates||[];if(!u.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${u.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,o=0;for(const a of u)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml(C)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${F?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${F==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${F==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${F==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(C)}" value="${escapeHtml(U)}" 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>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(C)}"${q?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(C)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",z.innerHTML=t,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,C=s.closest("tr"),I=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)s.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){s.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),H=document.getElementById("dashcaddy-changelog"),R=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),g=document.getElementById("dashcaddy-status-bar"),u=document.getElementById("dashcaddy-history-container");let f=null;function m(y,n){g&&(g.style.display="block",g.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",g.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",g.textContent=y)}async function p(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;B.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function d(y){y||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(f=e,e.success&&e.available&&e.remote){S.style.display="",T.style.display="",j.textContent="v"+e.remote.version,H.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(r)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(r)}}else S.style.display="none",T.style.display="none",await p(),y||m("You are running the latest version.","success");y||(x.textContent="Check for Updates",x.disabled=!1)}catch(n){y||(m("Failed to check: "+n.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;R.textContent="Updating...",R.disabled=!0,m("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return m("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),R.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(y){throw m("Update failed: "+y.message,"error"),R.textContent="Update Now",R.disabled=!1,y}}async function i(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){u.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(C)}" value="${escapeHtml(U)}" 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>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(C)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(C)}" data-name="${escapeHtml(c)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",z.innerHTML=n,z.querySelectorAll(".save-auto-btn").forEach(i=>{i.addEventListener("click",async()=>{const c=i.dataset.id,C=i.closest("tr"),B=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();i.textContent="Saving...",i.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(c)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!B,schedule:B||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)i.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){i.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{i.textContent="Save",i.disabled=!1},2e3)})})}catch(u){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(u.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),I=document.getElementById("dashcaddy-update-badge"),L=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),M=document.getElementById("dashcaddy-changelog"),O=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),y=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let b=null;function p(u,t){y&&(y.style.display="block",y.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",y.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",y.textContent=u)}async function v(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(u){u||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(b=e,e.success&&e.available&&e.remote){I.style.display="",L.style.display="",j.textContent="v"+e.remote.version,M.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(d)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(d)}}else I.style.display="none",L.style.display="none",await v(),u||p("You are running the latest version.","success");u||(x.textContent="Check for Updates",x.disabled=!1)}catch(t){u||(p("Failed to check: "+t.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function l(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;O.textContent="Updating...",O.disabled=!0,p("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return p("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),O.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(u){throw p("Update failed: "+u.message,"error"),O.textContent="Update Now",O.disabled=!1,u}}async function r(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
`+e.join(`
|
||||
`)+`
|
||||
|
||||
Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;m("Rolling back to v"+o+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(r.success)m("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(y){m("Rollback failed: "+y.message,"error")}}x?.addEventListener("click",()=>d(!1)),R?.addEventListener("click",()=>c().catch(()=>{})),D?.addEventListener("click",$),w?.addEventListener("click",b),E?.addEventListener("click",()=>{h?.classList.add("show"),v()}),wireModal(h,P),window.openUpdateModal=function(y){h?.classList.add("show"),v().then(()=>{if(!y)return;const n=N.querySelector(`[data-app-id="${y}"]`);n&&(n.scrollIntoView({behavior:"smooth",block:"center"}),n.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{n.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{p(),i(),f||d(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=d,setTimeout(()=>d(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;p("Rolling back to v"+o+"...","info");const d=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(d.success)p("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(d.error||"Rollback failed")}catch(u){p("Rollback failed: "+u.message,"error")}}x?.addEventListener("click",()=>s(!1)),O?.addEventListener("click",()=>l().catch(()=>{})),D?.addEventListener("click",f),k?.addEventListener("click",w),S?.addEventListener("click",()=>{h?.classList.add("show"),g()}),wireModal(h,A),window.openUpdateModal=function(u){h?.classList.add("show"),g().then(()=>{if(!u)return;const t=N.querySelector(`[data-app-id="${u}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",E),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),r(),b||s(!0)}),window.dcApplyUpdate=l,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u{1F433} Docker Resources</h3>
|
||||
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
|
||||
@@ -1564,7 +1564,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),P=document.getElementById("dr-close");function w(A){if(!A||A===0)return"0 B";const v=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,L)).toFixed(1)+" "+v[L]}async function N(){const A=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let 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;">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 M of L){const k=M.name==="buildkit"||M.name.length===64;b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-vol-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete volume "${M.dataset.name}"? Data will be lost.`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(M.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),v=A.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),A.value="",showNotification(`Volume "${v}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function O(){const A=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let 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;">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 M of L){const k=["bridge","host","none"].includes(M.name);b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-net-del" data-id="${escapeHtml(M.id)}" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-net-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete network "${M.dataset.name}"?`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(M.dataset.id)}`),O()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),L=A.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:v.value}),A.value="",showNotification(`Network "${L}" created`,"success"),O()}catch(b){showNotification("Create failed: "+b.message,"error")}});async fuLine truncated
|
||||
</div>`);const h=document.getElementById("docker-resources-modal"),S=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function k(P){if(!P||P===0)return"0 B";const g=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,T)).toFixed(1)+" "+g[T]}async function N(){const P=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const E=H.name==="buildkit"||H.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',E||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),N()}catch(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),g=P.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),P.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of T){const E=["bridge","host","none"].includes(H.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',E||(w+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",P.innerHTML=w,P.querySelectorAll(".dr-net-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete network "${H.dataset.name}"?`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(H.dataset.id)}`),R()}catch(E){showNotification("Delete failed: "+E.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(g){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),T=P.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:g.value}),P.value="",showNotification(`Network "${T}" created`,"success"),R()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
|
||||
<h3>\u{1F4E6} Import Docker Compose</h3>
|
||||
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
|
||||
@@ -1605,7 +1605,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),P=document.getElementById("compose-cancel");wireModal(h,P);let w=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}E?.addEventListener("click",()=>{N("paste"),w=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const A=z.target.files[0];if(!A)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),L=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const b=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:A});w=b,w.stackName=A,O(b),N("preview")}catch(b){showNotification("Parse failed: "+b.message,"error")}finally{v.textContent=L,v.disabled=!1}});function O(z){const A=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const b=L.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${b}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),v+="</div>",!L.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),L.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const M=[];L.resources.cpus&&M.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&M.push(`Mem: ${L.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}v+="</div>"}v+="</div>",A.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!w)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:w.services,networks:w.networks,stackName:w.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const b of v.results){const M=b.status==="deployed"||b.status==="created"?"\u2705":b.status==="exists"?"\u26A1":b.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${M} <strong>${escapeHtml(b.name)}</strong> (${b.type}) \u2014 ${escapeHtml(b.status)}`,b.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(b.error)}</span>`),b.subdomain&&(L+=` \u2192 <code>${escapeHtml(b.subdomain)}</code>`),b.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(b.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
</div>`);const h=document.getElementById("compose-import-modal"),S=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(h,A);let k=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}S?.addEventListener("click",()=>{N("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const P=z.target.files[0];if(!P)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),T=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:P});k=w,k.stackName=P,R(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{g.textContent=T,g.disabled=!1}});function R(z){const P=document.getElementById("compose-preview-content");let g="";z.networks&&z.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const T of z.services){const w=T.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),g+="</div>",!T.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}g+="</div>"}g+="</div>",P.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const w of g.results){const H=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(T+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.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>
|
||||
@@ -1614,11 +1614,11 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<button id="exec-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),P=document.getElementById("exec-close");let w=null,N=null,O=null;function z(){if(N){try{N.close()}catch{}N=null}if(w){try{w.dispose()}catch{}w=null}O=null,E.innerHTML=""}function A(v,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||v}`,h?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}w=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"&&(O=new FitAddon.FitAddon,w.loadAddon(O)),w.open(E),O&&setTimeout(()=>O.fit(),50);const b=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${b}//${location.host}/ws/exec/${encodeURIComponent(v)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(w.writeln("\x1B[32mConnecting...\x1B[0m"),O){const k=O.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){w.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r
|
||||
`);return}if(B.type==="error"){w.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){w.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}w.write(k.data)}else w.write(new Uint8Array(k.data))},N.onclose=()=>{w&&w.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{w&&w.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},w.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),w.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const M=()=>{O&&O.fit()};window.addEventListener("resize",M),h._resizeHandler=M}P?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",v=>{v.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
</div>`);const h=document.getElementById("exec-modal"),S=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let k=null,N=null,R=null;function z(){if(N){try{N.close()}catch{}N=null}if(k){try{k.dispose()}catch{}k=null}R=null,S.innerHTML=""}function P(g,T){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||g}`,h?.classList.add("show"),typeof Terminal>"u"){S.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,k.loadAddon(R)),k.open(S),R&&setTimeout(()=>R.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),R){const E=R.proposeDimensions();E&&N.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},N.onmessage=E=>{if(typeof E.data=="string"){try{const $=JSON.parse(E.data);if($.type==="connected"){k.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
|
||||
`);return}if($.type==="error"){k.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){k.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(E.data)}else k.write(new Uint8Array(E.data))},N.onclose=()=>{k&&k.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{k&&k.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},k.onData(E=>{N&&N.readyState===WebSocket.OPEN&&N.send(E)}),k.onResize(({cols:E,rows:$})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:E,rows:$}))});const H=()=>{R&&R.fit()};window.addEventListener("resize",H),h._resizeHandler=H}A?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",g=>{g.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
||||
<h3>\u{1F4DC} Audit Log</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1654,7 +1654,118 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),P=document.getElementById("audit-cancel"),w=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),O=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let v=0;const L=50;async function b(M){try{M||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=O.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${v}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const T=await(await fetch(B)).json(),j=T.success&&T.entries?T.entries:[];if(j.length===0&&!M){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let H="";M||(H='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',H+='<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 R of j){const x=R.outcome==="success";H+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',H+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(R.timestamp)}</td>`,H+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(R.ip||"-")}</td>`,H+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(R.action||"-")}</td>`,H+=`<td style="padding: 6px;">${escapeHtml(R.resource||"-")}</td>`,H+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,H+="</tr>",R.details&&Object.keys(R.details).length>0&&(H+=`<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(R.details,null,2))}</pre></td></tr>`)}if(!M)H+="</table>",z.innerHTML=H;else{const R=z.querySelector("table");R&&R.insertAdjacentHTML("beforeend",H)}v+=j.length,A.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(R=>{R.dataset.wired||(R.dataset.wired="true",R.addEventListener("click",()=>{const x=R.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}E?.addEventListener("click",()=>{h?.classList.add("show"),b(!1)}),wireModal(h,P),w?.addEventListener("click",()=>b(!1)),O?.addEventListener("change",()=>b(!1)),A?.addEventListener("click",()=>b(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?b(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(M){showNotification("Error: "+M.message,"error")}})})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
</div>`);const h=document.getElementById("audit-modal"),S=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let g=0;const T=50;async function w(H){try{H||(g=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const E=R.value;let $=`/api/v1/audit-logs?limit=${T}&offset=${g}`;E&&($+=`&action=${encodeURIComponent(E)}`);const L=await(await fetch($)).json(),j=L.success&&L.entries?L.entries:[];if(j.length===0&&!H){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let M="";H||(M='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',M+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const O of j){const x=O.outcome==="success";M+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',M+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(O.timestamp)}</td>`,M+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(O.ip||"-")}</td>`,M+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(O.action||"-")}</td>`,M+=`<td style="padding: 6px;">${escapeHtml(O.resource||"-")}</td>`,M+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,M+="</tr>",O.details&&Object.keys(O.details).length>0&&(M+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(O.details,null,2))}</pre></td></tr>`)}if(!H)M+="</table>",z.innerHTML=M;else{const O=z.querySelector("table");O&&O.insertAdjacentHTML("beforeend",M)}g+=j.length,P.style.display=j.length>=T?"":"none",z.querySelectorAll(".audit-row").forEach(O=>{O.dataset.wired||(O.dataset.wired="true",O.addEventListener("click",()=>{const x=O.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(E){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(E.message)}</div>`}}S?.addEventListener("click",()=>{h?.classList.add("show"),w(!1)}),wireModal(h,A),k?.addEventListener("click",()=>w(!1)),R?.addEventListener("change",()=>w(!1)),P?.addEventListener("click",()=>w(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const E=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();E.success?w(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
||||
<h3>\u{1F6E1}\uFE0F Security Center</h3>
|
||||
<p class="modal-subtitle">
|
||||
Live events from API, Caddy, fail2ban & shared_bans. Live-tail via SSE.
|
||||
</p>
|
||||
|
||||
<div class="sec-tabs" style="display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);">
|
||||
<button class="sec-tab active" data-tab="overview">Overview</button>
|
||||
<button class="sec-tab" data-tab="events">Events</button>
|
||||
<button class="sec-tab" data-tab="hosts">Hosts</button>
|
||||
</div>
|
||||
|
||||
<!-- OVERVIEW TAB -->
|
||||
<div class="sec-panel" data-panel="overview">
|
||||
<div class="sec-stats" id="sec-stats" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:18px;">
|
||||
<div class="sec-stat" data-key="total">\u2014 events</div>
|
||||
<div class="sec-stat" data-key="warn">\u2014 warnings</div>
|
||||
<div class="sec-stat" data-key="error">\u2014 errors</div>
|
||||
<div class="sec-stat" data-key="denied">\u2014 denied</div>
|
||||
<div class="sec-stat" data-key="hosts">\u2014 hosts</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;">
|
||||
<div>
|
||||
<h4 style="margin:8px 0 6px;">Top Actors (24h)</h4>
|
||||
<div id="sec-top-actors" class="scroll-container" style="max-height:240px;">\u2014</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 style="margin:8px 0 6px;">Top Targets (24h)</h4>
|
||||
<div id="sec-top-targets" class="scroll-container" style="max-height:240px;">\u2014</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- EVENTS TAB -->
|
||||
<div class="sec-panel" data-panel="events" style="display:none;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;align-items:center;flex-wrap:wrap;">
|
||||
<select id="sec-filter-source" class="sec-filter">
|
||||
<option value="">All sources</option>
|
||||
<option value="api">API</option>
|
||||
<option value="caddy">Caddy</option>
|
||||
<option value="fail2ban">fail2ban</option>
|
||||
<option value="shared-bans">shared_bans</option>
|
||||
<option value="agent">Agent</option>
|
||||
</select>
|
||||
<select id="sec-filter-severity" class="sec-filter">
|
||||
<option value="">All severities</option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="error">error</option>
|
||||
<option value="warn">warn</option>
|
||||
<option value="notice">notice</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
<select id="sec-filter-host" class="sec-filter">
|
||||
<option value="">All hosts</option>
|
||||
</select>
|
||||
<input id="sec-filter-actor" class="sec-filter" placeholder="actor (IP or user)" style="padding:6px 10px;">
|
||||
<button id="sec-refresh-btn" class="btn-sm">\u{1F504} Refresh</button>
|
||||
<label style="margin-left:auto;display:flex;align-items:center;gap:6px;">
|
||||
<input type="checkbox" id="sec-live-tail" checked>
|
||||
<span>Live tail</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="sec-events-container" class="scroll-container" style="max-height:480px;">Loading\u2026</div>
|
||||
</div>
|
||||
|
||||
<!-- HOSTS TAB -->
|
||||
<div class="sec-panel" data-panel="hosts" style="display:none;">
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;">
|
||||
<button id="sec-host-register-btn" class="btn-sm">\u2795 Register Host</button>
|
||||
<button id="sec-hosts-refresh" class="btn-sm">\u{1F504} Refresh</button>
|
||||
</div>
|
||||
<div id="sec-hosts-container" class="scroll-container" style="max-height:480px;">Loading\u2026</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar">
|
||||
<button id="sec-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("security-modal"),S=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),k=h.querySelectorAll(".sec-tab"),N=h.querySelectorAll(".sec-panel");let R=[],z=[],P=null;k.forEach(s=>{s.addEventListener("click",()=>{k.forEach(l=>l.classList.toggle("active",l===s)),N.forEach(l=>l.style.display=l.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&O(),s.dataset.tab==="hosts"&&m()})}),S&&S.addEventListener("click",()=>{h.classList.add("show"),H(),T()}),A.addEventListener("click",g),h.addEventListener("click",s=>{s.target===h&&g()});function g(){h.classList.remove("show"),w()}function T(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",s=>{try{R=JSON.parse(s.data).events||[],x()}catch{}}),P.addEventListener("security",s=>{try{const l=JSON.parse(s.data);R.unshift(l),R.length>500&&(R.length=500);const r=h.querySelector(".sec-tab.active")?.dataset?.tab;r==="events"?x():r==="overview"&&H()}catch{}}),P.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function w(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[l,r,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),u=(await l.json()).data||{},t=(await r.json()).data?.hosts||[],e=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${u.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${u.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${u.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,E("sec-top-actors",u.top_actors||[]),E("sec-top-targets",u.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function E(s,l){const r=document.getElementById(s);if(!l.length){r.innerHTML='<div class="panel-empty">No data</div>';return}r.innerHTML='<table style="width:100%;font-size:0.85rem;">'+l.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${p(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),I=document.getElementById("sec-filter-severity"),L=document.getElementById("sec-filter-host"),j=document.getElementById("sec-filter-actor"),M=document.getElementById("sec-refresh-btn");[$,I,L].forEach(s=>s.addEventListener("change",O)),j.addEventListener("input",v(O,250)),M.addEventListener("click",O);async function O(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),I.value&&s.set("severity",I.value),L.value&&s.set("source_host",L.value),j.value&&s.set("actor_prefix",j.value),R=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],x(),(!L.options.length||L.options.length===1)&&await y()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}function x(){const s=document.getElementById("sec-events-container");if(!R.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=R.slice(0,200).map(D).join("")}function D(s){const l=s.severity||"info",r={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[l]||"#7f8c8d",f=s.ts?new Date(s.ts).toLocaleTimeString():"",u=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",o=s.action||"",a=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${r};font-weight:600;">${p(l)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(u)}</span>
|
||||
<span title="${p(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(t)}</span>
|
||||
<span title="${p(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${p(o)} ${p(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${p(a)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${p(f)}</span>
|
||||
</div>`}async function y(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],r=L.value;L.innerHTML='<option value="">All hosts</option>'+l.map(f=>`<option value="${p(f.id)}">${p(f.label||f.id)}</option>`).join(""),r&&(L.value=r)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",b),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const l=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];z=l;const r=document.getElementById("sec-hosts-container");if(!l.length){r.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}r.innerHTML=l.map(f=>{const u=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${p(f.label||f.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${p(f.type)}</span>
|
||||
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
||||
id: ${p(f.id)} \xB7
|
||||
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
|
||||
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:0.85rem;margin-right:12px;">${u}</span>
|
||||
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${p(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
||||
</div>
|
||||
</div>`}).join(""),r.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),m())})})}catch(s){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+p(s.message)+"</div>"}}async function b(){const s=prompt("Host id (lowercase, no spaces):");if(!s)return;const l=prompt("Display label:",s)||s,r=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:s,label:l,type:r})}),u=await f.json();if(!f.ok){alert("Failed: "+(u?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
|
||||
|
||||
id: ${u.data.host.id}
|
||||
label: ${u.data.host.label}
|
||||
type: ${u.data.host.type}
|
||||
|
||||
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
|
||||
|
||||
${u.data.api_key}
|
||||
|
||||
Send this key as: Authorization: Bearer <api_key>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(f){alert("Failed: "+f.message)}}function p(s){return String(s).replace(/[&<>"']/g,l=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[l])}function v(s,l){let r;return function(){clearTimeout(r),r=setTimeout(()=>s.apply(this,arguments),l)}}})(),(function(){const h=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>
|
||||
@@ -1665,23 +1776,23 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){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",P="weather-zip",w="weather-geo",N="weather-unit";!safeGet(E)&&safeGet(P)&&safeSet(E,safeGet(P));function O(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const A={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function b(H){return L[Math.round(H/22.5)%16]}async function M(H){const R=safeGet(w);if(R)try{const f=JSON.parse(R);if(f.query===H)return f}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(H)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const g=D.results[0],u={query:H,lat:g.latitude,lon:g.longitude,city:g.name,state:g.admin1||"",country:g.country||"",countryCode:g.country_code||""};return safeSet(w,JSON.stringify(u)),u}function k(H){return H.countryCode==="US"&&H.state?`${H.city}, ${H.state}`:H.country?`${H.city}, ${H.country}`:H.city}async function B(H){try{const R=await M(H),x=O(),D=x==="metric"?"celsius":"fahrenheit",g=x==="metric"?"kmh":"mph",u=`https://api.open-meteo.com/v1/forecast?latitude=${R.lat}&longitude=${R.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${g}`,f=await fetch(u);if(!f.ok)throw new Error("Weather fetch failed");const p=(await f.json()).current,d=p.weather_code;return{temp:Math.round(p.temperature_2m),condition:A[d]||"Unknown",icon:v[d]||"\u{1F324}\uFE0F",locationStr:k(R),windSpeed:Math.round(p.wind_speed_10m),windDir:b(p.wind_direction_10m),unit:x}}catch(R){return console.warn("Weather fetch failed:",R),null}}async function S(){const H=z();if(!H.icon||!H.temp||!H.condition||!H.location||!H.wind){console.warn("Weather widget elements not found");return}const R=safeGet(E);if(!R){H.location.textContent="Set Location",H.temp.textContent="--\xB0",H.condition.textContent="Click \u2699\uFE0F to configure",H.wind.textContent="--",H.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(R);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",g=x.unit==="metric"?"km/h":"mph";H.location.textContent=x.locationStr,H.temp.textContent=`${x.temp}${D}`,H.condition.textContent=x.condition,H.wind.textContent=`Wind: ${x.windSpeed} ${g} ${x.windDir}`,H.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),H.location.textContent="Weather Error",H.temp.textContent="Error",H.condition.textContent="Failed to load",H.wind.textContent="--"}}const T=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const H=O(),R=T.querySelector(`input[name="weather-unit-radio"][value="${H}"]`);R&&(R.checked=!0),T.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const H=j.value.trim();if(H){safeGet(E)!==H&&safeSet(w,""),safeSet(E,H);const x=T.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",g=O();safeSet(N,D),g!==D&&safeSet(w,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",H=>{H.keyLine truncated
|
||||
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const S="weather-location",A="weather-zip",k="weather-geo",N="weather-unit";!safeGet(S)&&safeGet(A)&&safeSet(S,safeGet(A));function R(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(M){return T[Math.round(M/22.5)%16]}async function H(M){const O=safeGet(k);if(O)try{const b=JSON.parse(O);if(b.query===M)return b}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(M)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const y=D.results[0],m={query:M,lat:y.latitude,lon:y.longitude,city:y.name,state:y.admin1||"",country:y.country||"",countryCode:y.country_code||""};return safeSet(k,JSON.stringify(m)),m}function E(M){return M.countryCode==="US"&&M.state?`${M.city}, ${M.state}`:M.country?`${M.city}, ${M.country}`:M.city}async function $(M){try{const O=await H(M),x=R(),D=x==="metric"?"celsius":"fahrenheit",y=x==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${O.lat}&longitude=${O.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${y}`,b=await fetch(m);if(!b.ok)throw new Error("Weather fetch failed");const v=(await b.json()).current,s=v.weather_code;return{temp:Math.round(v.temperature_2m),condition:P[s]||"Unknown",icon:g[s]||"\u{1F324}\uFE0F",locationStr:E(O),windSpeed:Math.round(v.wind_speed_10m),windDir:w(v.wind_direction_10m),unit:x}}catch(O){return console.warn("Weather fetch failed:",O),null}}async function I(){const M=z();if(!M.icon||!M.temp||!M.condition||!M.location||!M.wind){console.warn("Weather widget elements not found");return}const O=safeGet(S);if(!O){M.location.textContent="Set Location",M.temp.textContent="--\xB0",M.condition.textContent="Click \u2699\uFE0F to configure",M.wind.textContent="--",M.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await $(O);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",y=x.unit==="metric"?"km/h":"mph";M.location.textContent=x.locationStr,M.temp.textContent=`${x.temp}${D}`,M.condition.textContent=x.condition,M.wind.textContent=`Wind: ${x.windSpeed} ${y} ${x.windDir}`,M.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),M.location.textContent="Weather Error",M.temp.textContent="Error",M.condition.textContent="Failed to load",M.wind.textContent="--"}}const L=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(S)||"";const M=R(),O=L.querySelector(`input[name="weather-unit-radio"][value="${M}"]`);O&&(O.checked=!0),L.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{L.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const M=j.value.trim();if(M){safeGet(S)!==M&&safeSet(k,""),safeSet(S,M);const x=L.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",y=R();safeSet(N,D),y!==D&&safeSet(k,""),L.classList.remove("show"),I()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(L),document.addEventListener("keydown",M=>{M.keyLine truncated
|
||||
<circle cx="${i}" cy="${c}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${U}
|
||||
${q}
|
||||
<line x1="${s}" y1="${l}" x2="${s+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="${s}" y1="${l}" x2="${s+38*Math.cos(I*Math.PI/180)}" y2="${l+38*Math.sin(I*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(C*Math.PI/180)}" y2="${l+42*Math.sin(C*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=n.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(n)}</span></div></div>`,L="analog"}function u(){const n=new Date,e=n.getHours()%12||12,o=n.getMinutes(),a=n.getSeconds(),r="clock-widget"+(O!=="default"?" "+O:"");switch(h.className!==r&&(h.className=r),O){case"lcd":H(n);break;case"lcd-blue":H(n);break;case"lcd-amber":H(n);break;case"lcd-retro":H(n);break;case"lcd-taxi":H(n);break;case"flip":R(n);break;case"binary":D(n);break;case"analog":g(n,!1);break;case"roman":g(n,!0);break;default:j(n)}o===0&&a===0&&e!==z&&(z=e,k(e)),o!==0&&(z=-1)}function f(){clearTimeout(M);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;M=setTimeout(()=>{u(),f()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),u(),f()}),u(),f();const m=[{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 p='<div class="clock-style-grid">';m.forEach(n=>{p+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${n.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${n.icon}</span><span class="clock-style-label">${n.label}</span></span>
|
||||
</label>`}),p+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
<line x1="${i}" y1="${c}" x2="${i+28*Math.cos(F*Math.PI/180)}" y2="${c+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+38*Math.cos(B*Math.PI/180)}" y2="${c+38*Math.sin(B*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${i}" y1="${c}" x2="${i+42*Math.cos(C*Math.PI/180)}" y2="${c+42*Math.sin(C*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${i}" cy="${c}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";S.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function m(){const t=new Date,e=t.getHours()%12||12,o=t.getMinutes(),a=t.getSeconds(),d="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==d&&(h.className=d),R){case"lcd":M(t);break;case"lcd-blue":M(t);break;case"lcd-amber":M(t);break;case"lcd-retro":M(t);break;case"lcd-taxi":M(t);break;case"flip":O(t);break;case"binary":D(t);break;case"analog":y(t,!1);break;case"roman":y(t,!0);break;default:j(t)}o===0&&a===0&&e!==z&&(z=e,E(e)),o!==0&&(z=-1)}function b(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{m(),b()},e)}document.addEventListener("visibilitychange",()=>{g="",I(),m(),b()}),m(),b();const p=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let v='<div class="clock-style-grid">';p.forEach(t=>{v+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${t.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
|
||||
</label>`}),v+="</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>
|
||||
${p}
|
||||
${v}
|
||||
</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);">
|
||||
@@ -1705,7 +1816,7 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const d=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),i=document.getElementById("clock-chime-volume"),$=document.getElementById("clock-volume-section");function y(){const n=safeGet("clock-style")||"default",e=d.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",i.value=safeGet("clock-chime-volume")||"50",$.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{$.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),d.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(i.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=d.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",i.value),O=e,v="",S(),u(),f(),d.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{d.classList.remove("show")}),wireModal(d),d?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{O=n.value,v="",S(),u()})})})(),(function(){async function h(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[v,L]of Object.entries(A.status)){const b=document.getElementById("uptime-"+v),M=document.getElementById("uptime-bar-"+v);if(!b)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);b.textContent=`${B}% uptime`,b.className="uptime-chip",k>=99.9?b.classList.add("excellent"):k>=99?b.classList.add("good"):k>=95?b.classList.add("degraded"):b.classList.add("poor"),M&&(M.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}let P=[];async function w(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(b=>b.classList.remove("visible"));const L=v.updates||[];if(P=L,z&&L.length>0){const b=window._lastKnownUpdateCount||0;b>0&&L.length>b&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const b of L){const M=window.APPS||[];for(const k of M)if(k.containerId===b.containerId||k.id===b.containerName||k.name===b.containerName){if(E.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),w()},5e3),setInterval(()=>{h(),w(!0)},6e4)}const O=window.refreshAll;O&&(window.refreshAll=async function(){try{await O(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,E=null,P={},w={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],O=document.getElementById("theme");if(!O)return;var z=document.getElementById("theme-label");function A(a){if(w[a])return w[a];var r=safeGetJSON(window.USER_THEMES_KEY,{});return r[a]&&r[a].name||a}function v(){z&&(z.textContent=A(window.getActiveTheme()))}O.addEventListener("click",function(){var a=window.THEMES.slice(),r=window.getActiveTheme(),t=a.indexOf(r),s=a[(t+1)%a.length];window.applyTheme(s),v()}),v();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
</div>`);const s=document.getElementById("clock-settings-modal"),l=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function u(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),l.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",f.style.opacity=l.checked?"1":"0.4"}l?.addEventListener("change",()=>{f.style.opacity=l.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(r.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(l.checked)),safeSet("clock-chime-volume",r.value),R=e,g="",I(),m(),b(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{R=t.value,g="",I(),m()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[g,T]of Object.entries(P.status)){const w=document.getElementById("uptime-"+g),H=document.getElementById("uptime-bar-"+g);if(!w)continue;const E=T.uptime?.["24h"];if(E!=null){const $=E.toFixed(1);w.textContent=`${$}% uptime`,w.className="uptime-chip",E>=99.9?w.classList.add("excellent"):E>=99?w.classList.add("good"):E>=95?w.classList.add("degraded"):w.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let S;try{S=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{S=new Set}let A=[];async function k(z){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const T=g.updates||[];if(A=T,z&&T.length>0){const w=window._lastKnownUpdateCount||0;w>0&&T.length>w&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const w of T){const H=window.APPS||[];for(const E of H)if(E.containerId===w.containerId||E.id===w.containerName||E.name===w.containerName){if(S.has(E.id))break;const $=document.getElementById("update-badge-"+E.id),I=document.getElementById("update-btn-"+E.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=L=>{L.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(E.id)}),I&&(I.style.background="#f97316",I.style.borderColor="#f97316",I.style.boxShadow="0 0 6px #f9731688",I.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),k()},5e3),setInterval(()=>{h(),k(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,S=null,A={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var z=document.getElementById("theme-label");function P(a){if(k[a])return k[a];var d=safeGetJSON(window.USER_THEMES_KEY,{});return d[a]&&d[a].name||a}function g(){z&&(z.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var a=window.THEMES.slice(),d=window.getActiveTheme(),n=a.indexOf(d),i=a[(n+1)%a.length];window.applyTheme(i),g()}),g();function T(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
@@ -1742,13 +1853,13 @@ Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const h=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),P=document.getElementById("license-activate"),w=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),O=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),L=document.getElementById("license-details"),b=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",O.style.display="none"}function S(f){B(),N.textContent=f,N.style.display="block"}function T(f){B(),O.textContent=f,O.style.display="block"}function j(f){if(k=f,f.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",A.textContent="Premium Active";const d=f.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(f.expiresAt).toLocaleDateString()}</strong> (${f.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${f.code||"***"}</code></div>
|
||||
${d}
|
||||
`,M.style.display="none",P.style.display="none",w.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",A.textContent=f.expired?"License Expired":"Free Tier",L.innerHTML=f.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",M.style.display="",P.style.display="",w.style.display="none";const m=f.premiumFeatures||{},p=new Set(f.features||[]);b.innerHTML=Object.entries(m).map(([d,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;">${p.has(d)?"\u2705":"\u{1F512}"}</span>
|
||||
</div>`);const h=document.getElementById("license-modal"),S=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),k=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),R=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),T=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let E=null;function $(){N.style.display="none",R.style.display="none"}function I(b){$(),N.textContent=b,N.style.display="block"}function L(b){$(),R.textContent=b,R.style.display="block"}function j(b){if(E=b,b.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",z.textContent="\u2605",P.textContent="Premium Active";const s=b.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(b.expiresAt).toLocaleDateString()}</strong> (${b.daysRemaining} days remaining)</div>`;T.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${b.code||"***"}</code></div>
|
||||
${s}
|
||||
`,H.style.display="none",A.style.display="none",k.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",z.textContent="\u2606",P.textContent=b.expired?"License Expired":"Free Tier",T.innerHTML=b.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",k.style.display="none";const p=b.premiumFeatures||{},v=new Set(b.features||[]);w.innerHTML=Object.entries(p).map(([s,l])=>`<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;">${v.has(s)?"\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 style="font-weight: 600; font-size: 0.9rem;">${l.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${l.description}</div>
|
||||
</div>
|
||||
</div>`).join("")}async function H(){try{const m=await(await fetch("/api/v1/license/status")).json();m.success&&(j(m.license),D(m.license))}catch(f){console.warn("Failed to load license status:",f.message)}}async function R(){const f=E.value.trim();if(!f){S("Please enter a license code.");return}B(),P.disabled=!0,P.textContent="Activating...";try{const p=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();p.success?(T(p.message),E.value="",j(p.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(p.license)):S(p.error||"Activation failed")}catch(m){S("Network error: "+m.message)}finally{P.disabled=!1,P.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){w.disabled=!0,w.textContent="Deactivating...";try{const m=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();m.success?(T(m.message),await H(),showNotification("License deactivated.","info",3e3),D({active:!1})):S(m.error||"Deactivation failed")}catch(f){S("Network error: "+f.message)}finally{w.disabled=!1,w.textContent="Deactivate"}}}function D(f){const m=document.getElementById("license-status-topbar"),p=document.getElementById("license-topbar-icon"),d=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(m)if(m.className="license-status-topbar "+(f.active?"premium":"free"),f.active)if(p.textContent="\u2605",d.textContent="PREMIUM",f.lifetime)c.textContent="\xB7 LIFETIME";else{const i=f.daysRemaining;c.textContent=i!=null?"\xB7 "+i+"d remaining":""}else p.textContent="\u2606",d.textContent=f.expired?"EXPIRED":"FREE TIER",c.textContent=""}function g(){B(),H(),h.classList.add("show")}E.addEventListener("input",function(){let f=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(f.length>this._prevLength&&(f=f.replace(/-/g,""),f.length>2&&!f.startsWith("DC")&&(f="DC"+f),f.startsWith("DC")&&f.length>2)){const m=["DC"],p=f.substring(2);for(let d=0;d<p.length;d+=5)m.push(p.substring(d,d+5));f=m.join("-")}this._prevLength=f.length,this.value=f}),P.addEventListener("click",R),w.addEventListener("click",x),E.addEventListener("keydown",f=>{f.key==="Enter"&&R()}),wireModal(h,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=g,window.checkPremiumFeature=async function(f){try{return(await(await fetch(`/api/v1/license/feature/${f}`)).json()).available}catch{return!1}},H().then(f=>{k&&D(k)})})();
|
||||
</div>`).join("")}async function M(){try{const p=await(await fetch("/api/v1/license/status")).json();p.success&&(j(p.license),D(p.license))}catch(b){console.warn("Failed to load license status:",b.message)}}async function O(){const b=S.value.trim();if(!b){I("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const v=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:b})})).json();v.success?(L(v.message),S.value="",j(v.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(v.license)):I(v.error||"Activation failed")}catch(p){I("Network error: "+p.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const p=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();p.success?(L(p.message),await M(),showNotification("License deactivated.","info",3e3),D({active:!1})):I(p.error||"Deactivation failed")}catch(b){I("Network error: "+b.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function D(b){const p=document.getElementById("license-status-topbar"),v=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),l=document.getElementById("license-topbar-time");if(p)if(p.className="license-status-topbar "+(b.active?"premium":"free"),b.active)if(v.textContent="\u2605",s.textContent="PREMIUM",b.lifetime)l.textContent="\xB7 LIFETIME";else{const r=b.daysRemaining;l.textContent=r!=null?"\xB7 "+r+"d remaining":""}else v.textContent="\u2606",s.textContent=b.expired?"EXPIRED":"FREE TIER",l.textContent=""}function y(){$(),M(),h.classList.add("show")}S.addEventListener("input",function(){let b=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(b.length>this._prevLength&&(b=b.replace(/-/g,""),b.length>2&&!b.startsWith("DC")&&(b="DC"+b),b.startsWith("DC")&&b.length>2)){const p=["DC"],v=b.substring(2);for(let s=0;s<v.length;s+=5)p.push(v.substring(s,s+5));b=p.join("-")}this._prevLength=b.length,this.value=b}),A.addEventListener("click",O),k.addEventListener("click",x),S.addEventListener("keydown",b=>{b.key==="Enter"&&O()}),wireModal(h,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=y,window.checkPremiumFeature=async function(b){try{return(await(await fetch(`/api/v1/license/feature/${b}`)).json()).available}catch{return!1}},M().then(b=>{E&&D(E)})})();
|
||||
Vendored
+6
-6
@@ -1,4 +1,4 @@
|
||||
(function(){function v(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(e=>{window.APPS.find(n=>n.id===e.id)||window.APPS.push(e)})}catch(i){console.warn("Failed to load custom services:",i)}}v();function k(){const a=document.querySelectorAll(".top .card");a.forEach((i,e)=>{i.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(i=>i.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(i=>{console.warn("[init] Service worker registration failed:",i)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();i.success&&window._updateAuthCard(i.config.enabled&&i.config.isSetUp,i.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}S(),C()&&b()}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/driver.min.css",document.head.appendChild(i)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const i=document.createElement("link");i.rel="stylesheet",i.href="/css/onboarding.css",document.head.appendChild(i)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function E(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let i={};try{i=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(e=>{const n=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(i[n]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const n=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(n&&n.tourCompleted)}catch{}const i=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!i)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=a?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const n=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(n),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(n),5e3)}},i.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const a=await fetch("/api/v1/templates",{cache:"no-store"});if(!a.ok)return;const i=await a.json();i&&i.categories&&(window.DC_CATEGORIES=i.categories,typeof DC<"u"&&(DC.CATEGORIES=i.categories),q())}catch(a){console.warn("[init] Failed to load template categories:",a)}}function q(){const a=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;a&&document.querySelectorAll('select[data-role="service-category"]').forEach(i=>{const e=i.dataset.current||"",n=i.querySelector('option[value=""]');if(i.innerHTML="",n)i.appendChild(n);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",i.appendChild(c)}Object.entries(a).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),i.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const i=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(i.success&&i.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}f(Line truncated
|
||||
(function(){function v(){const i=safeGet("custom-services");if(i)try{JSON.parse(i).forEach(e=>{window.APPS.find(a=>a.id===e.id)||window.APPS.push(e)})}catch(n){console.warn("Failed to load custom services:",n)}}v();function k(){const i=document.querySelectorAll(".top .card");i.forEach((n,e)=>{n.style.transitionDelay=`${Math.min(e*60,300)}ms`}),requestAnimationFrame(()=>{i.forEach(n=>n.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const i=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(n=>{console.warn("[init] Service worker registration failed:",n)})};document.readyState==="complete"?i():window.addEventListener("load",i,{once:!0})}let h=!1;async function f(){if(h){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(h=!0,await window.loadServices(),await y(),window.buildGrid(),k(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window.refreshMonitoringWidgets=="function"&&window.refreshMonitoringWidgets(),typeof window._updateAuthCard=="function")try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();n.success&&window._updateAuthCard(n.config.enabled&&n.config.isSetUp,n.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}if(S(),C()&&b(),window.AdminPanel&&typeof window.AdminPanel.attachTrigger=="function")try{await window.AdminPanel.attachTrigger(document.body)}catch(i){console.warn("[init] AdminPanel attachTrigger failed:",i)}}function b(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const i=document.createElement("script");if(i.src="/dist/onboarding.js",i.defer=!0,document.head.appendChild(i),!document.querySelector('link[href="/css/driver.min.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/driver.min.css",document.head.appendChild(n)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const n=document.createElement("link");n.rel="stylesheet",n.href="/css/onboarding.css",document.head.appendChild(n)}}function C(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const i=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!i||!i.tourCompleted&&i.currentStep===0}catch{return!0}}function E(){const i=document.querySelectorAll(".tools-section");if(!i.length)return;let n={};try{n=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}i.forEach(e=>{const a=e.dataset.section,c=e.querySelector(".tools-section-header");c&&(n[a]&&(e.classList.add("open"),c.setAttribute("aria-expanded","true")),c.addEventListener("click",s=>{s.preventDefault();const l=e.classList.toggle("open");c.setAttribute("aria-expanded",l?"true":"false");const m={};document.querySelectorAll(".tools-section").forEach(t=>{m[t.dataset.section]=t.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(m))}))})}E();function S(){if(document.getElementById("restart-tour-btn"))return;let i=typeof SITE<"u"&&SITE.onboardingCompleted;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));i=i||!!(a&&a.tourCompleted)}catch{}const n=i?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!n)return;const e=document.createElement("button");e.id="restart-tour-btn",e.textContent=i?"Help Tour":"\u{1F393} Help Tour",e.title="Restart the onboarding tour",e.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{b();const a=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(a),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(a),5e3)}},n.appendChild(e)}window.initializeDashboard=f,window.loadCustomServices=v,u();async function y(){try{const i=await fetch("/api/v1/templates",{cache:"no-store"});if(!i.ok)return;const n=await i.json();n&&n.categories&&(window.DC_CATEGORIES=n.categories,typeof DC<"u"&&(DC.CATEGORIES=n.categories),q())}catch(i){console.warn("[init] Failed to load template categories:",i)}}function q(){const i=window.DC_CATEGORIES||typeof DC<"u"&&DC.CATEGORIES;i&&document.querySelectorAll('select[data-role="service-category"]').forEach(n=>{const e=n.dataset.current||"",a=n.querySelector('option[value=""]');if(n.innerHTML="",a)n.appendChild(a);else{const c=document.createElement("option");c.value="",c.textContent="\u2014 Select category \u2014",n.appendChild(c)}Object.entries(i).forEach(([c,s])=>{const l=document.createElement("option");l.value=c,l.textContent=`${s.icon||""} ${c}`.trim(),c===e&&(l.selected=!0),n.appendChild(l)})})}window.populateCategorySelects=q,window.loadTemplateCategories=y,(async()=>{try{const n=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(n.success&&Line truncated
|
||||
.dc-monitor {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
@@ -109,7 +109,7 @@
|
||||
<div class="dc-monitor-value" id="dc-monitor-health">\u2014</div>
|
||||
<div class="dc-monitor-sub" id="dc-monitor-health-sub">\u2014</div>
|
||||
</div>
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,n){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(n)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const n=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<n.length-1;)e/=1024,c++;return e.toFixed(1)+" "+n[c]}function C(){const e=(window.APPS||[]).length;let n=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&n++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${n} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${n} online \xB7 ${e-n} offline`)}function E(e){const n=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!n)return;if(!e||e.summary==null){n.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;n.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const n=await e.json();return n&&n.stats?n.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const n=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){n&&(n.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){n&&(n.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(n&&(n.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let a=!1;async function i(){if(!a){a=!0;try{C();const[e,n]=await Promise.all([S(),y()]);q(e),E(n);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{a=!1}}}window.refreshMonitoringWidgets=i,setInterval(i,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(i,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
`,k.parentNode.insertBefore(u,k);function h(e,a){const c=document.getElementById(e);if(!c)return;const s=Math.max(0,Math.min(100,Number(a)||0));c.style.width=s+"%",c.classList.remove("warn","bad"),s>=85?c.classList.add("bad"):s>=65&&c.classList.add("warn")}function f(e){return e==null||isNaN(e)?"\u2014":Math.round(e*10)/10+"%"}function b(e){if(e==null||isNaN(e))return"\u2014";const a=["B","KB","MB","GB","TB"];let c=0;for(;e>=1024&&c<a.length-1;)e/=1024,c++;return e.toFixed(1)+" "+a[c]}function C(){const e=(window.APPS||[]).length;let a=0;document.querySelectorAll("#cards .card").forEach(l=>{l.dataset.status==="on"&&a++});const c=document.getElementById("dc-monitor-services"),s=document.getElementById("dc-monitor-services-sub");c&&(c.textContent=`${a} / ${e}`),s&&(s.textContent=e===0?"no services yet":`${a} online \xB7 ${e-a} offline`)}function E(e){const a=document.getElementById("dc-monitor-health"),c=document.getElementById("dc-monitor-health-sub");if(!a)return;if(!e||e.summary==null){a.textContent="\u2014",c&&(c.textContent="no data");return}const s=e.summary,l=s.healthy??s.up??0,m=s.unhealthy??s.down??0,t=s.total??l+m;a.textContent=`${l}/${t}`,c&&(m===0?c.innerHTML='<span class="dc-monitor-pill ok">\u25CF all healthy</span>':m<=2?c.innerHTML=`<span class="dc-monitor-pill warn">\u25CF ${m} degraded</span>`:c.innerHTML=`<span class="dc-monitor-pill bad">\u25CF ${m} down</span>`)}async function S(){try{const e=await fetch("/api/v1/monitoring/stats",{cache:"no-store"});if(!e.ok)return null;const a=await e.json();return a&&a.stats?a.stats:null}catch{return null}}async function y(){try{const e=await fetch("/api/v1/health-checks/status",{cache:"no-store"});return e.ok?await e.json():null}catch{return null}}function q(e){const a=document.getElementById("dc-monitor-containers"),c=document.getElementById("dc-monitor-containers-sub"),s=document.getElementById("dc-monitor-cpu"),l=document.getElementById("dc-monitor-mem");if(!e){a&&(a.textContent="\u2014"),s&&(s.textContent="\u2014"),l&&(l.textContent="\u2014");return}const m=Object.values(e);if(m.length===0){a&&(a.textContent="0"),c&&(c.textContent="no containers reporting"),s&&(s.textContent="0%"),l&&(l.textContent="0%"),h("dc-monitor-cpu-bar",0),h("dc-monitor-mem-bar",0);return}let t=0,o=0,r=0,p=0,d=0;m.forEach(g=>{if(g.cpu!=null){const x=Number(g.cpu);isNaN(x)||(t+=x>1?x:x*100,p++)}if(g.memory!=null){const x=Number(g.memory);isNaN(x)||(o+=x,r+=Number(g.memoryUsage||0),d++)}});const w=p?t/p:0,L=d?o/d:0;if(a&&(a.textContent=String(m.length)),c){const g=r?` \xB7 ${b(r)} RAM`:"";c.textContent=`running${g}`}s&&(s.textContent=f(w)),l&&(l.textContent=f(L)),h("dc-monitor-cpu-bar",w),h("dc-monitor-mem-bar",L)}let i=!1;async function n(){if(!i){i=!0;try{C();const[e,a]=await Promise.all([S(),y()]);q(e),E(a);const c=document.getElementById("dc-monitor-refresh-stamp");if(c){const s=new Date;c.textContent=`updated ${s.toLocaleTimeString()}`}}finally{i=!1}}}window.refreshMonitoringWidgets=n,setInterval(n,typeof DC<"u"&&DC.POLL&&DC.POLL.STATS||5e3),setTimeout(n,200)})(),(function(){"use strict";const v=(...t)=>{window.DASHCADDY_DEBUG&&console.log(...t)},k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let u=null,h=null,f=null;function b(){try{C(),document.addEventListener("keydown",E),v("[Keyboard Shortcuts] Initialized"),v("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),v("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function C(){u=document.createElement("div"),u.id="quick-search-modal",u.className="quick-search-modal",u.innerHTML=`
|
||||
<div class="quick-search-content">
|
||||
<div class="quick-search-input-wrapper">
|
||||
<span class="quick-search-icon">\u{1F50D}</span>
|
||||
@@ -271,7 +271,7 @@
|
||||
font-family: monospace;
|
||||
margin-right: 4px;
|
||||
}
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),a()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function a(){const t=`
|
||||
`,document.head.appendChild(t),document.body.appendChild(u),h=document.getElementById("quick-search-input"),f=document.getElementById("quick-search-results"),h.addEventListener("input",e),h.addEventListener("keydown",l),u.addEventListener("click",o=>{o.target===u&&y()})}function E(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),S();return}if(t.key==="Escape"){if(u&&u.classList.contains("show")){y();return}q()}}catch(o){console.warn("[Keyboard Shortcuts] Error handling keydown:",o.message)}}function S(){try{u.classList.add("show"),h.value="",h.focus(),i()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function y(){try{u.classList.remove("show"),h.value="",f.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function q(){for(const t of k){const o=document.querySelector(t);if(o&&(o.classList.contains("show")||o.style.display==="flex"))return o.classList.remove("show"),o.style.display="none",!0}return!1}function i(){const t=`
|
||||
<div class="quick-search-category">Quick Actions</div>
|
||||
<div class="quick-search-item" data-action="refresh">
|
||||
<span class="quick-search-item-icon">\u{1F504}</span>
|
||||
@@ -303,8 +303,8 @@
|
||||
</div>
|
||||
|
||||
<div class="quick-search-category">Services</div>
|
||||
${i()}
|
||||
`;f.innerHTML=t,s()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
${n()}
|
||||
`;f.innerHTML=t,s()}function n(){const t=document.querySelectorAll(".card[data-app], #cards .card");let o="";return t.forEach(r=>{const p=r.querySelector(".name")?.textContent||"Unknown",d=r.dataset.status||"unknown",w=r.dataset.app||"";p&&p!=="--"&&(o+=`
|
||||
<div class="quick-search-item" data-action="open-service" data-service="${w}">
|
||||
<span class="quick-search-item-icon">${d==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
|
||||
<div class="quick-search-item-content">
|
||||
@@ -313,7 +313,7 @@
|
||||
</div>
|
||||
<span class="quick-search-item-badge">${d.toUpperCase()}</span>
|
||||
</div>
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){a();return}const r=n(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function n(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
`)}),o||'<div class="quick-search-empty">No services found</div>'}function e(t){try{const o=t.target.value.toLowerCase().trim();if(!o){i();return}const r=a(o);c(r)}catch(o){console.warn("[Keyboard Shortcuts] Error handling search input:",o.message)}}function a(t){const o={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(d=>{(d.title.toLowerCase().includes(t)||d.keywords.includes(t))&&o.actions.push(d)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(d=>{const w=d.querySelector(".name")?.textContent||"",L=d.dataset.app||"",g=d.dataset.status||"unknown";(w.toLowerCase().includes(t)||L.toLowerCase().includes(t))&&o.services.push({id:L,title:w,status:g,icon:g==="on"?"\u{1F7E2}":"\u{1F534}"})}),o}function c(t){let o="";t.actions.length>0&&(o+='<div class="quick-search-category">Actions</div>',t.actions.forEach(r=>{o+=`
|
||||
<div class="quick-search-item" data-action="${r.id}">
|
||||
<span class="quick-search-item-icon">${r.icon}</span>
|
||||
<div class="quick-search-item-content">
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
/**
|
||||
* Admin panel — DC-048.
|
||||
*
|
||||
* Minimal admin UI for managing users + invites. Rendered as a modal overlay
|
||||
* triggered by an "Admin" button in the top bar that only appears when
|
||||
* /api/v1/auth/me returns isAdmin=true. The panel renders three sections:
|
||||
*
|
||||
* 1. Users — list of authorized users with role badges, role-edit,
|
||||
* delete actions.
|
||||
* 2. Invite a user — form to issue a single-use invite (email, role,
|
||||
* TTL). The accept-link is shown post-issue so the admin can copy it.
|
||||
* 3. Outstanding invites — list of issued-not-yet-accepted invites
|
||||
* with a revoke button.
|
||||
*
|
||||
* The panel does NOT add a tab to the dashboard nav — it lives as a modal
|
||||
* to keep DC-048 surgical. Future DC-049 work can promote it to a tab.
|
||||
*
|
||||
* Behaviour:
|
||||
* - On load: GET /me; if !isAdmin → show "admin only" placeholder
|
||||
* - Then GET /admin/users + /admin/invites in parallel
|
||||
* - Forms POST to the admin endpoints, refresh lists on success
|
||||
* - "Copy link" button writes the acceptUrl to the clipboard
|
||||
*
|
||||
* Wires into the global error-handler (window.errorHandler) for failure
|
||||
* surfaces. Uses window.SITE for any UI constants (none currently).
|
||||
*/
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const API = {
|
||||
me: '/api/v1/auth/me',
|
||||
users: '/api/v1/auth/admin/users',
|
||||
allowlist: '/api/v1/auth/admin/allowlist',
|
||||
invites: '/api/v1/auth/admin/invites',
|
||||
};
|
||||
|
||||
function _el(tag, attrs, ...children) {
|
||||
const node = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const k of Object.keys(attrs)) {
|
||||
const v = attrs[k];
|
||||
if (v === null || v === undefined || v === false) continue;
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'text') node.textContent = v;
|
||||
else if (k === 'html') node.innerHTML = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
else node.setAttribute(k, v);
|
||||
}
|
||||
}
|
||||
for (const c of children) {
|
||||
if (c === null || c === undefined || c === false) continue;
|
||||
if (typeof c === 'string') node.appendChild(document.createTextNode(c));
|
||||
else node.appendChild(c);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
async function _fetchJSON(url, opts) {
|
||||
const csrf = (window.SITE && window.SITE.csrfToken) || '';
|
||||
opts = opts || {};
|
||||
opts.headers = Object.assign(
|
||||
{ 'Content-Type': 'application/json' },
|
||||
opts.headers || {},
|
||||
csrf ? { 'X-CSRF-Token': csrf } : {}
|
||||
);
|
||||
if (opts.body && typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
|
||||
const r = await fetch(url, opts);
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) {
|
||||
const msg = (data && (data.message || data.error)) || ('HTTP ' + r.status);
|
||||
const err = new Error(msg);
|
||||
err.status = r.status;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function _renderBadge(role) {
|
||||
const colors = {
|
||||
admin: 'background:#7c3aed;color:#fff',
|
||||
operator: 'background:#2563eb;color:#fff',
|
||||
viewer: 'background:#6b7280;color:#fff',
|
||||
};
|
||||
return _el('span', {
|
||||
class: 'role-badge',
|
||||
style: 'display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;text-transform:uppercase;' +
|
||||
(colors[role] || colors.viewer),
|
||||
text: role,
|
||||
});
|
||||
}
|
||||
|
||||
function _renderUsersList(container, users, onChange) {
|
||||
container.innerHTML = '';
|
||||
if (!users || users.length === 0) {
|
||||
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No users yet.' }));
|
||||
return;
|
||||
}
|
||||
const table = _el('table', {
|
||||
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
|
||||
});
|
||||
table.appendChild(_el('thead', null,
|
||||
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Created' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Last login' }),
|
||||
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
|
||||
),
|
||||
));
|
||||
const tbody = _el('tbody');
|
||||
for (const u of users) {
|
||||
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
|
||||
|
||||
const emailCell = _el('td', { style: 'padding:8px' });
|
||||
emailCell.appendChild(_el('span', { text: u.email || '(no email)' }));
|
||||
if (u.displayName && u.displayName !== (u.email || '').split('@')[0]) {
|
||||
emailCell.appendChild(_el('br'));
|
||||
emailCell.appendChild(_el('small', {
|
||||
style: 'color:var(--muted)', text: u.displayName,
|
||||
}));
|
||||
}
|
||||
row.appendChild(emailCell);
|
||||
|
||||
const roleCell = _el('td', { style: 'padding:8px' });
|
||||
roleCell.appendChild(_renderBadge(u.role));
|
||||
row.appendChild(roleCell);
|
||||
|
||||
row.appendChild(_el('td', {
|
||||
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
||||
text: u.createdAt ? new Date(u.createdAt).toLocaleDateString() : '—',
|
||||
}));
|
||||
row.appendChild(_el('td', {
|
||||
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
||||
text: u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : '—',
|
||||
}));
|
||||
|
||||
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
|
||||
const roleSelect = _el('select', {
|
||||
style: 'padding:2px 6px;margin-right:6px',
|
||||
onchange: async (ev) => {
|
||||
try {
|
||||
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), {
|
||||
method: 'PATCH', body: { role: ev.target.value },
|
||||
});
|
||||
onChange && onChange();
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Role update failed: ' + e.message);
|
||||
ev.target.value = u.role;
|
||||
}
|
||||
},
|
||||
});
|
||||
for (const r of ['admin', 'operator', 'viewer']) {
|
||||
const opt = _el('option', { value: r, text: r });
|
||||
if (r === u.role) opt.selected = true;
|
||||
roleSelect.appendChild(opt);
|
||||
}
|
||||
actionsCell.appendChild(roleSelect);
|
||||
|
||||
const delBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'padding:2px 8px',
|
||||
text: 'Delete',
|
||||
onclick: async () => {
|
||||
if (!confirm('Delete user ' + (u.email || u.id) + '? This cannot be undone.')) return;
|
||||
try {
|
||||
await _fetchJSON(API.users + '/' + encodeURIComponent(u.id), { method: 'DELETE' });
|
||||
onChange && onChange();
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Delete failed: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
actionsCell.appendChild(delBtn);
|
||||
row.appendChild(actionsCell);
|
||||
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
container.appendChild(table);
|
||||
}
|
||||
|
||||
function _renderInviteForm(container, onIssued) {
|
||||
const form = _el('form', {
|
||||
style: 'display:flex;gap:8px;flex-wrap:wrap;align-items:end',
|
||||
onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
const fd = new FormData(ev.target);
|
||||
const body = {
|
||||
email: fd.get('email'),
|
||||
role: fd.get('role'),
|
||||
ttlHours: parseInt(fd.get('ttlHours'), 10) || 24,
|
||||
sendEmail: fd.get('sendEmail') === 'on',
|
||||
};
|
||||
try {
|
||||
const r = await _fetchJSON(API.invites, { method: 'POST', body });
|
||||
ev.target.reset();
|
||||
onIssued && onIssued(r);
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Invite failed: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
||||
_el('span', { text: 'Email' }),
|
||||
_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }),
|
||||
));
|
||||
const roleSel = _el('select', { name: 'role', style: 'padding:6px' });
|
||||
for (const r of ['operator', 'viewer', 'admin']) {
|
||||
roleSel.appendChild(_el('option', { value: r, text: r }));
|
||||
}
|
||||
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
||||
_el('span', { text: 'Role' }), roleSel,
|
||||
));
|
||||
form.appendChild(_el('label', { style: 'display:flex;flex-direction:column;gap:2px;font-size:0.85rem' },
|
||||
_el('span', { text: 'TTL (hours)' }),
|
||||
_el('input', { name: 'ttlHours', type: 'number', min: '1', max: '168', value: '24', style: 'padding:6px;width:80px' }),
|
||||
));
|
||||
form.appendChild(_el('label', { style: 'display:flex;gap:4px;align-items:center;font-size:0.85rem' },
|
||||
_el('input', { name: 'sendEmail', type: 'checkbox', checked: true }),
|
||||
_el('span', { text: 'Send email' }),
|
||||
));
|
||||
form.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Issue invite' }));
|
||||
container.appendChild(form);
|
||||
}
|
||||
|
||||
function _renderInvitesList(container, invites, onChange) {
|
||||
container.innerHTML = '';
|
||||
if (!invites || invites.length === 0) {
|
||||
container.appendChild(_el('p', { style: 'color:var(--muted)', text: 'No outstanding invites.' }));
|
||||
return;
|
||||
}
|
||||
const table = _el('table', {
|
||||
style: 'width:100%;border-collapse:collapse;font-size:0.9rem',
|
||||
});
|
||||
table.appendChild(_el('thead', null,
|
||||
_el('tr', { style: 'border-bottom:1px solid var(--border)' },
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Email' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Role' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Invited by' }),
|
||||
_el('th', { style: 'text-align:left;padding:8px', text: 'Expires' }),
|
||||
_el('th', { style: 'text-align:right;padding:8px', text: 'Actions' }),
|
||||
),
|
||||
));
|
||||
const tbody = _el('tbody');
|
||||
for (const inv of invites) {
|
||||
const row = _el('tr', { style: 'border-bottom:1px solid var(--border)' });
|
||||
row.appendChild(_el('td', { style: 'padding:8px', text: inv.email }));
|
||||
row.appendChild(_el('td', { style: 'padding:8px' }, _renderBadge(inv.role)));
|
||||
row.appendChild(_el('td', { style: 'padding:8px;color:var(--muted)', text: inv.invitedBy || '—' }));
|
||||
row.appendChild(_el('td', {
|
||||
style: 'padding:8px;color:var(--muted);font-size:0.85rem',
|
||||
text: inv.expiresAt ? new Date(inv.expiresAt).toLocaleString() : '—',
|
||||
}));
|
||||
const actionsCell = _el('td', { style: 'padding:8px;text-align:right' });
|
||||
actionsCell.appendChild(_el('button', {
|
||||
class: 'btn-sm', style: 'padding:2px 8px',
|
||||
text: 'Revoke',
|
||||
onclick: async () => {
|
||||
if (!confirm('Revoke invite for ' + inv.email + '?')) return;
|
||||
try {
|
||||
await _fetchJSON(API.invites + '/' + encodeURIComponent(inv.id), { method: 'DELETE' });
|
||||
onChange && onChange();
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Revoke failed: ' + e.message);
|
||||
}
|
||||
},
|
||||
}));
|
||||
row.appendChild(actionsCell);
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
container.appendChild(table);
|
||||
}
|
||||
|
||||
function _renderIssuedInviteBanner(invite, parent) {
|
||||
const banner = _el('div', {
|
||||
style: 'margin-top:12px;padding:12px;border:1px solid #16a34a;border-radius:6px;background:#052e1a;color:#bbf7d0;font-size:0.85rem',
|
||||
});
|
||||
banner.appendChild(_el('strong', { text: 'Invite issued — copy the link below. ' +
|
||||
'It will not be shown again.' }));
|
||||
banner.appendChild(_el('br'));
|
||||
banner.appendChild(_el('code', {
|
||||
style: 'display:block;margin-top:8px;padding:8px;background:#000;border-radius:4px;word-break:break-all;color:#d1fae5',
|
||||
text: invite.acceptUrl,
|
||||
}));
|
||||
const copyBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'margin-top:8px;padding:4px 10px',
|
||||
text: 'Copy link',
|
||||
onclick: async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(invite.acceptUrl);
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => { copyBtn.textContent = 'Copy link'; }, 2000);
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Clipboard blocked: select the link manually.');
|
||||
}
|
||||
},
|
||||
});
|
||||
banner.appendChild(copyBtn);
|
||||
if (invite.deliveredVia === 'dev-console') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#fbbf24;font-size:0.8rem',
|
||||
text: 'SMTP not configured — the invite was logged to the server console (search for [DC-048-DEV-INVITE-LINK]).',
|
||||
}));
|
||||
} else if (invite.deliveredVia === 'email') {
|
||||
banner.appendChild(_el('p', {
|
||||
style: 'margin-top:8px;color:#86efac;font-size:0.8rem',
|
||||
text: 'Email sent to ' + invite.email + '.',
|
||||
}));
|
||||
}
|
||||
parent.appendChild(banner);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the admin panel. Called by the open trigger; safe to call multiple
|
||||
* times (re-renders into the same container).
|
||||
*/
|
||||
async function mount(container) {
|
||||
container.innerHTML = '';
|
||||
container.appendChild(_el('h2', { style: 'margin:0 0 16px', text: 'Admin · Users & Invites' }));
|
||||
|
||||
const meData = await _fetchJSON(API.me).catch(() => ({}));
|
||||
if (!meData || !meData.user || meData.user.role !== 'admin') {
|
||||
container.appendChild(_el('p', {
|
||||
style: 'color:var(--muted)',
|
||||
text: 'Admin role required to view this panel. If multi-user mode is enabled and you should have access, check /api/v1/auth/me.',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh button
|
||||
const refreshBtn = _el('button', {
|
||||
class: 'btn-sm', style: 'float:right;padding:4px 10px',
|
||||
text: 'Refresh',
|
||||
onclick: () => mount(container),
|
||||
});
|
||||
container.appendChild(refreshBtn);
|
||||
|
||||
// ── Users section ────────────────────────────────────────────────────
|
||||
const usersHeader = _el('h3', { style: 'margin:24px 0 8px;clear:both', text: 'Users' });
|
||||
container.appendChild(usersHeader);
|
||||
|
||||
const usersList = _el('div', { id: 'admin-users-list' });
|
||||
container.appendChild(usersList);
|
||||
|
||||
const usersData = await _fetchJSON(API.users).catch(() => ({ users: [] }));
|
||||
_renderUsersList(usersList, usersData.users, () => mount(container));
|
||||
|
||||
// Add user form (pre-authorize an email without issuing an invite).
|
||||
container.appendChild(_el('h4', { style: 'margin:24px 0 8px;font-size:0.95rem', text: 'Pre-authorize email' }));
|
||||
const addUserForm = _el('form', {
|
||||
style: 'display:flex;gap:8px;align-items:end',
|
||||
onsubmit: async (ev) => {
|
||||
ev.preventDefault();
|
||||
const email = ev.target.email.value.trim();
|
||||
if (!email) return;
|
||||
try {
|
||||
await _fetchJSON(API.users, { method: 'POST', body: { email } });
|
||||
ev.target.reset();
|
||||
mount(container);
|
||||
} catch (e) {
|
||||
window.errorHandler && window.errorHandler.show('Add failed: ' + e.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
addUserForm.appendChild(_el('input', { name: 'email', type: 'email', required: true, placeholder: 'user@example.com', style: 'padding:6px' }));
|
||||
addUserForm.appendChild(_el('button', { type: 'submit', class: 'btn-sm', style: 'padding:6px 12px', text: 'Add to allowlist' }));
|
||||
container.appendChild(addUserForm);
|
||||
|
||||
// ── Invites section ──────────────────────────────────────────────────
|
||||
container.appendChild(_el('h3', { style: 'margin:24px 0 8px', text: 'Issue invite' }));
|
||||
const inviteFormContainer = _el('div');
|
||||
container.appendChild(inviteFormContainer);
|
||||
|
||||
const invitesList = _el('div', { id: 'admin-invites-list', style: 'margin-top:16px' });
|
||||
container.appendChild(invitesList);
|
||||
|
||||
const invitesData = await _fetchJSON(API.invites).catch(() => ({ invites: [] }));
|
||||
_renderInvitesList(invitesList, invitesData.invites, () => mount(container));
|
||||
|
||||
_renderInviteForm(inviteFormContainer, (issued) => {
|
||||
_renderIssuedInviteBanner(issued, inviteFormContainer);
|
||||
mount(container);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Open the admin panel as a modal overlay. Closes on backdrop click or
|
||||
* the close button. Renders into document.body so it floats above the
|
||||
* dashboard chrome.
|
||||
*/
|
||||
async function open() {
|
||||
// If already open, just focus.
|
||||
const existing = document.getElementById('admin-panel-root');
|
||||
if (existing) return;
|
||||
|
||||
const backdrop = _el('div', {
|
||||
id: 'admin-panel-root',
|
||||
style: 'position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:1000;display:flex;align-items:center;justify-content:center;',
|
||||
onclick: (ev) => {
|
||||
if (ev.target === backdrop) close();
|
||||
},
|
||||
});
|
||||
|
||||
const card = _el('div', {
|
||||
style: 'background:var(--card-base,#1f2937);color:var(--text,#f3f4f6);border-radius:8px;padding:24px;max-width:900px;width:90%;max-height:85vh;overflow:auto;position:relative;box-shadow:0 10px 30px rgba(0,0,0,0.3)',
|
||||
});
|
||||
card.appendChild(_el('button', {
|
||||
class: 'btn-sm', style: 'position:absolute;top:12px;right:12px;padding:4px 10px',
|
||||
text: 'Close', onclick: close,
|
||||
}));
|
||||
|
||||
const body = _el('div', { id: 'admin-panel-body' });
|
||||
card.appendChild(body);
|
||||
backdrop.appendChild(card);
|
||||
document.body.appendChild(backdrop);
|
||||
|
||||
try {
|
||||
await mount(body);
|
||||
} catch (e) {
|
||||
body.innerHTML = '<p style="color:#f87171">Failed to load admin panel: ' + (e.message || e) + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
const existing = document.getElementById('admin-panel-root');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject an "Admin" button into the top bar. Only renders when the
|
||||
* current /me response says isAdmin=true. Re-checks periodically
|
||||
* (every 60s) so a permission downgrade takes effect without a reload.
|
||||
*/
|
||||
async function attachTrigger(barContainer) {
|
||||
async function _maybeShow() {
|
||||
const me = await _fetchJSON(API.me).catch(() => ({}));
|
||||
const existing = document.getElementById('admin-trigger-btn');
|
||||
if (me && me.user && me.user.role === 'admin') {
|
||||
if (existing) return;
|
||||
const btn = _el('button', {
|
||||
id: 'admin-trigger-btn',
|
||||
class: 'btn-sm',
|
||||
style: 'margin-left:8px;padding:6px 12px',
|
||||
text: 'Admin',
|
||||
onclick: open,
|
||||
});
|
||||
if (barContainer) barContainer.appendChild(btn);
|
||||
else if (document.body) document.body.appendChild(btn);
|
||||
} else if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
await _maybeShow();
|
||||
setInterval(_maybeShow, 60_000);
|
||||
}
|
||||
|
||||
window.AdminPanel = { open, close, attachTrigger };
|
||||
})();
|
||||
@@ -84,6 +84,17 @@
|
||||
if (shouldLoadOnboarding()) {
|
||||
loadOnboarding();
|
||||
}
|
||||
|
||||
// DC-048: inject the "Admin" trigger button into the top bar. The
|
||||
// button only renders when /me returns isAdmin=true; the module
|
||||
// re-checks every 60s so a permission downgrade takes effect.
|
||||
if (window.AdminPanel && typeof window.AdminPanel.attachTrigger === 'function') {
|
||||
try {
|
||||
await window.AdminPanel.attachTrigger(document.body);
|
||||
} catch (e) {
|
||||
console.warn('[init] AdminPanel attachTrigger failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy-load onboarding bundle (52 KB) — only loaded when needed
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-79829761c4';
|
||||
const CACHE = 'dashcaddy-shell-1b7c08184e';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in new issue
Block a user