774 lines
139 KiB
JavaScript
774 lines
139 KiB
JavaScript
(function(o){"use strict";class f{constructor(){this.errors=[],this.maxErrors=50}logError(g,i,s={}){const h={timestamp:new Date().toISOString(),context:g,message:i instanceof Error?i.message:i,stack:i instanceof Error?i.stack:null,metadata:s};this.errors.push(h),this.errors.length>this.maxErrors&&this.errors.shift(),console.error(`[Onboarding Error] ${g}:`,i,s)}recoverFromError(g,i){switch(this.classifyError(g)){case"ELEMENT_NOT_FOUND":return this.logError("Element Not Found",g,{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",g),{action:"USE_MEMORY_STORAGE",message:"Local storage unavailable, using in-memory storage"};case"DRIVER_NOT_LOADED":return this.logError("Driver.js Not Loaded",g),{action:"ABORT_TOUR",message:"Driver.js library not loaded, cannot start tour"};case"INVALID_TOOLTIP":return this.logError("Invalid Tooltip Configuration",g,{currentStep:i}),{action:"SKIP_STEP",nextStep:i+1,message:"Invalid tooltip configuration, skipping"};case"THEME_DETECTION_FAILED":return this.logError("Theme Detection Failed",g),{action:"USE_DEFAULT_THEME",message:"Using default dark theme"};default:return this.logError("Unknown Error",g,{currentStep:i}),{action:"ABORT_TOUR",message:"Unexpected error occurred, aborting tour"}}}classifyError(g){const i=g.message||g.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 g={total:this.errors.length,byContext:{},byType:{},recent:this.errors.slice(-10)};return this.errors.forEach(i=>{g.byContext[i.context]=(g.byContext[i.context]||0)+1;const s=this.classifyError({message:i.message});g.byType[s]=(g.byType[s]||0)+1}),g}handleDriverLoadFailure(){this.logError("Driver.js Load Failure","Driver.js library failed to load");const g=document.createElement("div");return g.id="onboarding-fallback",g.style.cssText=`
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
background: var(--card-base, #2a2a2a);
|
|
color: var(--fg, #ffffff);
|
|
padding: 15px 20px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
z-index: 9999;
|
|
max-width: 300px;
|
|
font-size: 14px;
|
|
`,g.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(g),setTimeout(()=>{g.parentNode&&g.parentNode.removeChild(g)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const g={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"),g}sendToErrorTracking(g){}}o.ErrorHandler=f,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 g=await fetch("/api/v1/config");if(g.ok){const i=await g.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[h,n]of Object.entries(i.dnsServers))h!=="__proto__"&&h!=="constructor"&&h!=="prototype"&&(SITE.dnsServers[h]=n);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(g=>g.textContent=SITE.tld);const f=document.getElementById("edit-tld-suffix");f&&(f.textContent=SITE.tld);const u=document.getElementById("external-proxy-ip");u&&SITE.dnsIp&&(u.value=SITE.dnsIp,u.placeholder=SITE.dnsIp)})();function buildDomain(o){return o+SITE.tld}function buildServiceUrl(o){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+o:SITE.configurationType==="public"&&SITE.domain?"https://"+o+"."+SITE.domain:"https://"+buildDomain(o)}function getDnsServerAddr(o){const f=SITE.dnsServers[o];return f?`${f.ip}:${f.port}`:buildDomain(o)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[o,f]of Object.entries(SITE.dnsServers))if(f.ip===SITE.dnsIp)return o;return null}function renderDnsCards(){const o=document.querySelector(".top");if(!o)return;const f=Object.keys(SITE.dnsServers);if(!f.length)return;const u='<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>',g=o.firstElementChild;f.forEach(i=>{const s=escapeHtml(i),h=escapeHtml((SITE.dnsServers[i].name||i).toUpperCase()),n=document.createElement("div");n.className="card",n.setAttribute("data-app",i),n.setAttribute("data-status","off"),n.innerHTML=`<span id="${s}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${u}</div><span class="name">${h}</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>`,o.insertBefore(n,g)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const o=await fetch("/api/v1/csrf-token");if(!o.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await o.json()).token,csrfToken}catch(o){throw errorHandler.logError("[CSRF] Get Token",o,{function:"getCSRFToken"}),o}}async function secureFetch(o,f={}){const u=(f.method||"GET").toUpperCase(),g=!["GET","HEAD","OPTIONS"].includes(u);if(g)try{const s=await getCSRFToken();f.headers={...f.headers,"X-CSRF-Token":s}}catch(s){errorHandler.logError("[CSRF] Add to Request",s,{function:"secureFetch"})}f.signal||(f={...f,signal:AbortSignal.timeout(15e3)});const i=await fetch(o,f);if(g&&i.status===403)try{const s=await i.clone().json();if(s.error&&(s.error.includes("DC-100")||s.error.includes("DC-101"))){csrfToken=null;const h=await getCSRFToken();return f.headers={...f.headers,"X-CSRF-Token":h},f.signal=AbortSignal.timeout(15e3),fetch(o,f)}}catch{}return i}async function postJSON(o,f){const u=await secureFetch(o,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)}),g=await u.json();if(!u.ok||g.success===!1)throw new Error(g.error||`Request failed (${u.status})`);return g}async function getJSON(o){const f=await secureFetch(o);if(!f.ok){let u=`Request failed (${f.status})`;try{u=(await f.json()).error||u}catch{}throw new Error(u)}return f.json()}async function deleteAPI(o){const f=await secureFetch(o,{method:"DELETE"}),u=await f.json();if(!f.ok||u.success===!1)throw new Error(u.error||`Delete failed (${f.status})`);return u}async function withButton(o,f,u,g={}){const i=o.innerHTML,{successText:s="\u2705",resetDelay:h=DC.DELAYS.BTN_RESET}=g;o.disabled=!0,o.innerHTML=f;try{const n=await u();return o.innerHTML=s,setTimeout(()=>{o.innerHTML=i,o.disabled=!1},h),n}catch(n){throw o.innerHTML=i,o.disabled=!1,n}}function openModal(o){document.getElementById(o)?.classList.add("show")}function closeModal(o){document.getElementById(o)?.classList.remove("show")}function wireModal(o,...f){o&&(o.addEventListener("click",u=>{u.target===o&&o.classList.remove("show")}),f.forEach(u=>{u&&typeof u.addEventListener=="function"&&u.addEventListener("click",()=>o.classList.remove("show"))}))}function showNotification(o,f="info",u=3e3){const g=document.querySelector(".deploy-notification");g&&g.remove();const i={info:{bg:"#2196F3",fg:"#fff"},success:{bg:"var(--ok-bg)",fg:"var(--ok-fg)"},error:{bg:"#f44336",fg:"#fff"},warning:{bg:"#ff9800",fg:"#fff"}},s=i[f]||i.info,h=document.createElement("div");h.className="deploy-notification",h.textContent=o,h.style.cssText=`
|
|
position: fixed; top: 20px; right: 20px;
|
|
background: ${s.bg}; color: ${s.fg};
|
|
padding: 16px 24px; border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
|
z-index: 10000; animation: slideIn 0.3s ease-out;
|
|
max-width: 400px; white-space: pre-line; font-size: 14px;
|
|
`,document.body.appendChild(h),u>0&&setTimeout(()=>h.remove(),u)}function timeAgo(o){const f=Date.now()-new Date(o).getTime();return f<6e4?"just now":f<36e5?Math.floor(f/6e4)+"m ago":f<864e5?Math.floor(f/36e5)+"h ago":Math.floor(f/864e5)+"d ago"}function safeGet(o,f=null){try{const u=localStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSet(o,f){try{localStorage.setItem(o,f)}catch{}}function safeRemove(o){try{localStorage.removeItem(o)}catch{}}function safeSessionGet(o,f=null){try{const u=sessionStorage.getItem(o);return u!==null?u:f}catch{return f}}function safeSessionSet(o,f){try{sessionStorage.setItem(o,f)}catch{}}function safeGetJSON(o,f=null){try{const u=localStorage.getItem(o);return u?JSON.parse(u):f}catch{return f}}function escapeHtml(o){return String(o??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}function injectModal(o,f){document.getElementById(o)||document.body.insertAdjacentHTML("beforeend",f)}const DC_BUS={_handlers:{},on(o,f){var u;((u=this._handlers)[o]||(u[o]=[])).push(f)},off(o,f){this._handlers[o]=this._handlers[o]?.filter(u=>u!==f)},emit(o,f){this._handlers[o]?.forEach(u=>u(f))}},AppState={_apps:[],getApps(){return this._apps},setApps(o){this._apps=o,window.APPS=o,DC_BUS.emit("apps:changed",o)},findApp(o){return this._apps.find(f=>f.id===o)},addApp(o){this._apps.push(o),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(o){const f=this._apps.findIndex(u=>u.id===o);return f>-1&&(this._apps.splice(f,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),f>-1},updateApp(o,f){const u=this._apps.find(g=>g.id===o);if(u){for(const[g,i]of Object.entries(f))g!=="__proto__"&&g!=="constructor"&&g!=="prototype"&&(u[g]=i);DC_BUS.emit("apps:changed",this._apps)}return u}};(function(){function o(){const g=document.createElement("div");return g.className="skeleton-card",g.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>',g}function f(g){const i=document.getElementById("cards");if(!(!i||i.querySelector(".card"))){g=g||6;for(let s=0;s<g;s++){const h=o();i.appendChild(h),setTimeout(function(){h.classList.add("loaded")},s*60)}}}function u(){const g=document.getElementById("cards");if(g){var i=g.querySelectorAll(".skeleton-card");i.length&&(i.forEach(function(s,h){setTimeout(function(){s.style.opacity="0",s.style.transform="translateY(-10px)"},h*25)}),setTimeout(function(){i.forEach(function(s){s.parentNode&&s.remove()})},i.length*25+300))}}window.SkeletonLoader={show:f,hide:u}})(),(function(){var o="theme",f="user-themes",u="custom-theme",g=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],i=g.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"],h=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],n=["fg-muted","hover","card-hover","base","success","error","warning"],w={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",warning:"#ffd080",accent:"#9cd4ff","accent-strong":"#6fb2ff"},black:{bg:"#0e0e0e",fg:"#f5f5f5",muted:"#999999","fg-muted":"#666666","card-base":"#1a1a1a","card-bg":"#1a1a1a",border:"#2e2e2e",hover:"#242424","card-hover":"#202020",base:"#161616","ok-bg":"#0f2a12","ok-fg":"#66ff7a","bad-bg":"#2a0f0f","bad-fg":"#ff6b6b","dot-ok":"#4caf50","dot-bad":"#ff4444",uptime:"#e0e0e0",success:"#4caf50",error:"#ff4444",warning:"#ff9800",accent:"#E63946","accent-strong":"#C62828"},nord:{bg:"#2e3440",fg:"#eceff4",muted:"#81a1c1","fg-muted":"#6882a0","card-base":"#3b4252","card-bg":"#3b4252",border:"#4c566a",hover:"#434c5e","card-hover":"#3f4858",base:"#353c4a","ok-bg":"#2d4f3e","ok-fg":"#a3be8c","bad-bg":"#4a2c2a","bad-fg":"#bf616a","dot-ok":"#a3be8c","dot-bad":"#bf616a",uptime:"#a3be8c",success:"#a3be8c",error:"#bf616a",warning:"#ebcb8b",accent:"#88c0d0","accent-strong":"#5e81ac"},dracula:{bg:"#282a36",fg:"#f8f8f2",muted:"#6272a4","fg-muted":"#515d85","card-base":"#44475a","card-bg":"#44475a",border:"#6272a4",hover:"#4e5170","card-hover":"#494c63",base:"#363848","ok-bg":"#1e3a2e","ok-fg":"#50fa7b","bad-bg":"#3d1a1a","bad-fg":"#ff5555","dot-ok":"#50fa7b","dot-bad":"#ff5555",uptime:"#50fa7b",success:"#50fa7b",error:"#ff5555",warning:"#f1fa8c",accent:"#bd93f9","accent-strong":"#8be9fd"},"solarized-dark":{bg:"#002b36",fg:"#839496",muted:"#586e75","fg-muted":"#4a5f65","card-base":"#073642","card-bg":"#073642",border:"#586e75",hover:"#0d4050","card-hover":"#0a3a48",base:"#053340","ok-bg":"#0d3d2c","ok-fg":"#859900","bad-bg":"#3d1a1a","bad-fg":"#dc322f","dot-ok":"#859900","dot-bad":"#dc322f",uptime:"#b5bd68",success:"#859900",error:"#dc322f",warning:"#b58900",accent:"#268bd2","accent-strong":"#2aa198"},"solarized-light":{bg:"#fdf6e3",fg:"#657b83",muted:"#93a1a1","fg-muted":"#adb8b8","card-base":"#eee8d5","card-bg":"#eee8d5",border:"#93a1a1",hover:"#e6dfcb","card-hover":"#eae3cf",base:"#e8e1cd","ok-bg":"#e8f5e8","ok-fg":"#859900","bad-bg":"#fdf2f2","bad-fg":"#dc322f","dot-ok":"#859900","dot-bad":"#dc322f",uptime:"#859900",success:"#859900",error:"#dc322f",warning:"#b58900",accent:"#268bd2","accent-strong":"#2aa198",lightBg:!0},taxi:{bg:"#f3d321",fg:"#0e0e00",muted:"#4a4a10","fg-muted":"#6b6b30","card-base":"#ffd700","card-bg":"#ffd700",border:"#b8a840",hover:"#ffe84d","card-hover":"#ffe033",base:"#f0d000","ok-bg":"#d4ffd9","ok-fg":"#0f2a0f","bad-bg":"#ffd4d4","bad-fg":"#2a0f0f","dot-ok":"#4caf50","dot-bad":"#ff4444",uptime:"#0e0e00",success:"#2e7d32",error:"#c62828",warning:"#e65100",accent:"#7a4a00","accent-strong":"#5c3800",lightBg:!0},ocean:{bg:"#2060b0",fg:"#faf5eb",muted:"#dcd2c0","fg-muted":"#b0a890","card-base":"#7a94ed","card-bg":"#7a94ed",border:"#deb67a",hover:"#8aa0f0","card-hover":"#8298e8",base:"#6888e0","ok-bg":"#4f5bb0","ok-fg":"#c7d7eb","bad-bg":"#f41a3a","bad-fg":"#6a1818","dot-ok":"#30a050","dot-bad":"#d04040",uptime:"#2d32f2",success:"#30a050",error:"#d04040",warning:"#e6a030",accent:"#1860a0","accent-strong":"#104080"}};function m(E){return!E||typeof E!="string"?{r:0,g:0,b:0}:(E=E.replace("#",""),E.length===3&&(E=E[0]+E[0]+E[1]+E[1]+E[2]+E[2]),{r:parseInt(E.substr(0,2),16)||0,g:parseInt(E.substr(2,2),16)||0,b:parseInt(E.substr(4,2),16)||0})}function r(E,L,$){return"#"+[E,L,$].map(function(x){var b=Math.max(0,Math.min(255,Math.round(x))).toString(16);return b.length===1?"0"+b:b}).join("")}function l(E,L,$){var x=m(E),b=m(L);return r(x.r+(b.r-x.r)*$,x.g+(b.g-x.g)*$,x.b+(b.b-x.b)*$)}function y(E){E=E.replace("#",""),E.length===3&&(E=E[0]+E[0]+E[1]+E[1]+E[2]+E[2]);var L=parseInt(E.substr(0,2),16)/255,$=parseInt(E.substr(2,2),16)/255,x=parseInt(E.substr(4,2),16)/255;return L=L<=.03928?L/12.92:Math.pow((L+.055)/1.055,2.4),$=$<=.03928?$/12.92:Math.pow(($+.055)/1.055,2.4),x=x<=.03928?x/12.92:Math.pow((x+.055)/1.055,2.4),.2126*L+.7152*$+.0722*x}function e(E){var L=E.bg||"#0b0f1a",$=E.fg||"#e8ecf5",x=E.muted||"#9aa6bf",b=E["card-base"]||E.bg||"#121826",I=E["dot-ok"]||"#4caf50",S=E["dot-bad"]||"#e74c3c",C=E.lightBg||L&&y(L)>.4,P={};return P.hover=C?l(b,L,.35):l(b,$,.08),P["card-hover"]=l(b,P.hover,.5),P.base=l(L,b,.6),P["fg-muted"]=l(x,L,.35),P.success=I,P.error=S,P.warning=C?"#d68a00":"#f39c12",P}function a(E,L){var $=L.lightBg||L.bg&&y(L.bg)>.4,x=L.accent||L["accent-strong"]||"#888888",b=m(x);return $?":root."+E+` body {
|
|
background:
|
|
radial-gradient(1200px 800px at 10% -10%, rgba(`+b.r+","+b.g+","+b.b+`, .08), transparent 60%),
|
|
radial-gradient(1000px 700px at 110% 10%, rgba(`+b.r+","+b.g+","+b.b+`, .05), transparent 55%),
|
|
var(--bg);
|
|
}
|
|
`:":root."+E+` body {
|
|
background:
|
|
radial-gradient(1200px 900px at 8% -12%, rgba(`+b.r+","+b.g+","+b.b+`, .10), transparent 60%),
|
|
radial-gradient(1000px 700px at 110% -10%, rgba(`+b.r+","+b.g+","+b.b+`, .07), transparent 55%),
|
|
var(--bg);
|
|
}
|
|
`}function p(E,L){var $=L.lightBg||L.bg&&y(L.bg)>.4;return $?":root."+E+` button:hover {
|
|
background: color-mix(in srgb, var(--accent-strong) 12%, white 88%);
|
|
border-color: rgba(0, 0, 0, .15);
|
|
box-shadow: 0 1px 6px rgba(0, 0, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .8);
|
|
}
|
|
`:":root."+E+` button:hover {
|
|
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
|
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
|
|
}
|
|
`}function t(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function c(){s.forEach(function(E){document.documentElement.style.removeProperty("--"+E)})}function v(E,L){var $=E.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),g.indexOf($)!==-1&&($=$+"-custom");for(var x=safeGetJSON(f,{}),b=$,I=2;x[$]&&$!==L;)$=b+"-"+I++;return $}function d(E){var L=document.getElementById("user-theme-styles");L&&L.remove(),i.length=g.length,Object.keys(w).forEach(function(S){g.indexOf(S)===-1&&delete w[S]});var $=E||safeGetJSON(f,{}),x=Object.keys($);if(x=x.filter(function(S){return g.indexOf(S)===-1}),!!x.length){var b="";x.forEach(function(S){var C=$[S];i.indexOf(S)===-1&&i.push(S);var P={};s.forEach(function(O){C[O]&&(P[O]=C[O])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var D=e(P);n.forEach(function(O){!P[O]&&D[O]&&(P[O]=D[O])}),w[S]=P,b+=":root."+S+` {
|
|
`,s.forEach(function(O){P[O]&&(b+=" --"+O+": "+P[O]+`;
|
|
`)}),b+=`}
|
|
`,b+=a(S,P),b+=p(S,P)});var I=document.createElement("style");I.id="user-theme-styles",I.textContent=b,document.head.appendChild(I)}}function k(){secureFetch("/api/v1/themes").then(function(E){return E.json()}).then(function(E){if(!(!E.success||!E.themes)){var L=E.themes,$=safeGetJSON(f,{});if(JSON.stringify(L)!==JSON.stringify($)){safeSet(f,JSON.stringify(L)),d(L);var x=safeGet(o);x&&i.indexOf(x)!==-1&&T(x)}}}).catch(function(){})}function B(){var E=safeGetJSON(u);if(E){var L=E.name||"Custom",$=v(L),x={name:L};s.forEach(function(S){E[S]&&(x[S]=E[S])});var b=safeGetJSON(f,{});b[$]=x,safeSet(f,JSON.stringify(b)),safeGet(o)==="custom"&&safeSet(o,$),safeRemove(u);var I={};s.forEach(function(S){x[S]&&(I[S]=x[S])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:L,colors:I})}).catch(function(){})}}function T(E){document.documentElement.classList.add("theme-transitioning"),i.forEach(function(b){b!=="dark"&&document.documentElement.classList.remove(b)}),c(),E!=="dark"&&document.documentElement.classList.add(E),safeSet(o,E);var L=w[E],$=document.querySelector('meta[name="theme-color"]');$&&L&&$.setAttribute("content",L.bg);var x=L&&L.lightBg;!x&&L&&L.bg&&(x=y(L.bg)>.4),x?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),d();var A=safeGet(o);A==="red"&&(A="black",safeSet(o,"black")),A&&A!=="dark"&&i.indexOf(A)===-1&&(A=null),T(A||t()),k(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(E){safeGet(o)||T(E.matches?"dark":"light")}),window.THEMES=i,window.BUILTIN_THEMES=g,window.THEME_COLORS=w,window.THEME_PROPS=s,window.BASE_PROPS=h,window.DERIVED_PROPS=n,window.USER_THEMES_KEY=f,window.applyTheme=T,window.clearCustomProperties=c,window.injectUserThemeStyles=d,window.syncThemesFromServer=k,window.slugifyThemeName=v,window.getActiveTheme=function(){return safeGet(o)||t()},window.deriveExtendedColors=e,window.hexToRgb=m,window.rgbToHex=r,window.blendColors=l})(),(function(){function o(){const h=document.querySelector(".totp-card");if(!h)return;const w=getComputedStyle(h).backgroundColor.match(/\d+/g);if(!w)return;const m=(.299*+w[0]+.587*+w[1]+.114*+w[2])/255,r=h.querySelector(".totp-logo-dark"),l=h.querySelector(".totp-logo-light");r&&(r.style.display=m>.5?"none":""),l&&(l.style.display=m>.5?"":"none")}function f(){const h=document.getElementById("totp-overlay");if(h){h.classList.add("show"),setTimeout(o,50);const n=h.querySelector(".totp-digits input");n&&setTimeout(()=>n.focus(),100)}}function u(){const h=document.getElementById("totp-overlay");h&&h.classList.remove("show")}const g=document.getElementById("totp-digits");if(g){const h=g.querySelectorAll("input");h.forEach((n,w)=>{n.addEventListener("input",m=>{const r=m.target.value.replace(/\D/g,"");m.target.value=r.slice(0,1),r&&w<h.length-1&&h[w+1].focus();const l=Array.from(h).map(y=>y.value).join("");l.length===6&&i(l)}),n.addEventListener("keydown",m=>{m.key==="Backspace"&&!m.target.value&&w>0&&(h[w-1].focus(),h[w-1].value="")}),n.addEventListener("paste",m=>{m.preventDefault();const r=(m.clipboardData.getData("text")||"").replace(/\D/g,"");r.length>=6&&(h.forEach((l,y)=>{l.value=r[y]||""}),h[5].focus(),i(r.slice(0,6)))})})}async function i(h){const n=document.getElementById("totp-error");n.textContent="Verifying...",n.className="totp-error verifying";try{const m=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:h})})).json();if(m.success){n.textContent="",m.csrfToken&&(csrfToken=m.csrfToken),u();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{n.textContent=m.error||"Invalid code",n.className="totp-error";const r=document.querySelectorAll("#totp-digits input");r.forEach(l=>{l.value=""}),r[0]?.focus()}}catch{n.textContent="Connection error",n.className="totp-error"}}const s=new URLSearchParams(window.location.search);if(s.get("auth")==="required"){const h=s.get("return");if(h)try{const n=new URL(h,window.location.origin),w=n.hostname,m=n.origin===window.location.origin,r=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,l=w.endsWith(r)||w===r.substring(1);(m||l)&&safeSessionSet("totp_redirect",h)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=f})(),(function(){const o=new ErrorHandler;injectModal("folder-browser-modal",`<div id="folder-browser-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 500px; max-width: 700px; max-height: 80vh;">
|
|
<h3>\u{1F4C2} Browse for Media Folders</h3>
|
|
|
|
<div id="folder-browser-path" style="padding: 10px; background: var(--card-bg); border-radius: 6px; margin-bottom: 12px; font-family: monospace; font-size: 0.9rem; word-break: break-all;">
|
|
/
|
|
</div>
|
|
|
|
<div id="folder-browser-list" style="max-height: 400px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px;">
|
|
<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>
|
|
</div>
|
|
|
|
<div id="folder-browser-selected" style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--success) 10%, transparent); border: 1px solid var(--success); border-radius: 6px; display: none;">
|
|
<div style="font-size: 0.85rem; color: var(--success); margin-bottom: 6px;">Selected folders:</div>
|
|
<div id="folder-browser-selected-list" style="display: flex; flex-wrap: wrap; gap: 6px;"></div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: space-between;">
|
|
<button id="folder-browser-select-current" class="btn-accent">
|
|
+ Add Current Folder
|
|
</button>
|
|
<div class="flex-row-gap">
|
|
<button id="folder-browser-cancel">Cancel</button>
|
|
<button id="folder-browser-done" style="background: color-mix(in srgb, var(--success) 20%, transparent); border-color: var(--success); color: var(--success);">Done</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`),injectModal("service-creds-modal",`<div id="service-creds-modal">
|
|
<div class="service-creds-content">
|
|
<h3 id="svc-creds-title" style="margin: 0 0 4px; font-size: 1.05rem;">Service Credentials</h3>
|
|
<p id="svc-creds-desc" style="font-size: 0.75rem; color: var(--muted); margin: 0 0 14px;">Credentials are injected automatically when accessing this service.</p>
|
|
|
|
<!-- Status indicator -->
|
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 12px;">
|
|
<span id="svc-creds-dot" class="status-dot"></span>
|
|
<span id="svc-creds-status" class="text-muted-sm">No credentials stored</span>
|
|
</div>
|
|
|
|
<!-- Seedhost credentials (shown for external services) -->
|
|
<div id="svc-creds-seedhost" style="display: none; margin-bottom: 14px;">
|
|
<label class="label-bold">Seedhost Login</label>
|
|
<p class="hint-micro">Username shared across all services. Password is per-service.</p>
|
|
<input type="text" id="svc-seedhost-user" placeholder="Username (shared)" autocomplete="username"
|
|
style="width: 100%; padding: 8px 10px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
|
<input type="password" id="svc-seedhost-pass" placeholder="Password" autocomplete="current-password"
|
|
class="input-creds" />
|
|
</div>
|
|
|
|
<!-- API Key (shown for arr services or services with API key support) -->
|
|
<div id="svc-creds-apikey" style="display: none; margin-bottom: 14px;">
|
|
<label class="label-bold">API Key</label>
|
|
<p class="hint-micro">Bypasses the app's own login screen</p>
|
|
<input type="text" id="svc-apikey-input" placeholder="API key"
|
|
style="width: 100%; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; font-family: monospace; box-sizing: border-box;" />
|
|
</div>
|
|
|
|
<!-- Quality Profile (shown for radarr/sonarr only) -->
|
|
<div id="svc-creds-quality" style="display: none; margin-bottom: 14px;">
|
|
<label class="label-bold">Quality Profile</label>
|
|
<p class="hint-micro">Used when requesting via Seerr</p>
|
|
<div style="display: flex; gap: 8px; align-items: center;">
|
|
<select id="svc-quality-select"
|
|
style="flex: 1; padding: 8px 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;">
|
|
<option value="">-- Enter API key first --</option>
|
|
</select>
|
|
<button id="svc-quality-fetch" type="button"
|
|
style="padding: 8px 12px; font-size: 0.75rem; cursor: pointer; white-space: nowrap;">
|
|
Fetch
|
|
</button>
|
|
</div>
|
|
<div id="svc-quality-status" style="font-size: 0.75rem; margin-top: 4px; min-height: 1em;"></div>
|
|
</div>
|
|
|
|
<!-- Per-service Basic Auth (shown for non-external services) -->
|
|
<div id="svc-creds-basic" style="display: none; margin-bottom: 14px;">
|
|
<label class="label-bold">Service Login</label>
|
|
<input type="text" id="svc-basic-user" placeholder="Username" autocomplete="username"
|
|
style="width: 100%; padding: 8px 10px; margin-top: 6px; margin-bottom: 6px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem; box-sizing: border-box;" />
|
|
<input type="password" id="svc-basic-pass" placeholder="Password" autocomplete="current-password"
|
|
class="input-creds" />
|
|
</div>
|
|
|
|
<!-- Error message -->
|
|
<div id="svc-creds-error" style="display: none; padding: 8px 10px; margin-bottom: 10px; background: color-mix(in srgb, var(--error, #c62828) 12%, transparent); border: 1px solid var(--error, #c62828); border-radius: 6px; font-size: 0.8rem; color: var(--error, #c62828);"></div>
|
|
|
|
<!-- Buttons -->
|
|
<div style="display: flex; gap: 8px; margin-top: 14px;">
|
|
<button id="svc-creds-save" class="btn-accent-solid" style="flex: 1; padding: 9px; border: none; border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem;">
|
|
Save
|
|
</button>
|
|
<button id="svc-creds-clear" style="padding: 9px 14px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 6px; cursor: pointer; font-size: 0.85rem; display: none;">
|
|
Clear
|
|
</button>
|
|
<button id="svc-creds-close" style="padding: 9px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 0.85rem;">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`);const f=document.getElementById("service-creds-modal");let u=null;const g=["sonarr","radarr","prowlarr","overseerr"],i=["sonarr","radarr"];function s(r){return r.externalUrl||r.url||""}function h(r){const l=document.getElementById("svc-creds-error");l.textContent=r,l.style.display=""}function n(){const r=document.getElementById("svc-creds-error");r.textContent="",r.style.display="none"}window.openServiceCredsModal=async function(r){u=r,n();const l=document.getElementById("svc-creds-title"),y=document.getElementById("svc-creds-desc"),e=document.getElementById("svc-creds-seedhost"),a=document.getElementById("svc-creds-apikey"),p=document.getElementById("svc-creds-basic"),t=document.getElementById("svc-creds-quality");l.textContent=r.name+" Credentials";const c=!!r.isExternal,v=g.includes(r.id)||g.includes(r.appTemplate),d=i.includes(r.id)||i.includes(r.appTemplate);e.style.display=c?"":"none",a.style.display=v?"":"none",t.style.display=d?"":"none",p.style.display=c?"none":"";const k=document.getElementById("svc-quality-select");k.innerHTML='<option value="">-- Enter API key first --</option>',document.getElementById("svc-quality-status").textContent="",c?(y.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}`):v?y.textContent="API key bypasses the app login screen automatically.":y.textContent="Credentials are injected automatically when accessing this service.",await w(r),f.classList.add("show")};async function w(r){const l=document.getElementById("svc-creds-dot"),y=document.getElementById("svc-creds-status"),e=document.getElementById("svc-creds-clear");let a=!1;if(r.isExternal){try{const c=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();c.success?(document.getElementById("svc-seedhost-user").value=c.username||"",c.hasCredentials&&(a=!0)):document.getElementById("svc-seedhost-user").value=""}catch{}document.getElementById("svc-seedhost-pass").value=""}try{const c=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();c.success&&(c.hasApiKey?(document.getElementById("svc-apikey-input").value="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",a=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",a=!0):document.getElementById("svc-basic-user").value="")}catch{}document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value="");const p=r.id||r.appTemplate;if(i.includes(p)&&await m(r),a){l.style.background="var(--ok-fg, #74dfc4)",y.style.color="var(--ok-fg, #74dfc4)",y.textContent="Credentials stored",e.style.display="";const t=document.getElementById(`creds-btn-${r.id}`);t&&t.classList.add("has-creds")}else l.style.background="var(--muted)",y.style.color="var(--muted)",y.textContent="No credentials stored",e.style.display="none"}async function m(r){const l=document.getElementById("svc-quality-select"),y=document.getElementById("svc-quality-status"),e=r.id||r.appTemplate,a=s(r);if(!a){l.innerHTML='<option value="">-- No service URL --</option>';return}l.innerHTML='<option value="">Loading...</option>',y.textContent="";try{const p=new URLSearchParams({service:e,url:a}),c=await(await fetch(`/api/v1/arr/quality-profiles?${p}`)).json();if(!c.success||!c.profiles?.length){l.innerHTML='<option value="">-- No profiles found (enter API key and click Fetch) --</option>';return}l.innerHTML="";for(const v of c.profiles){const d=document.createElement("option");d.value=v.id,d.textContent=v.name,l.appendChild(d)}if(c.storedProfileId&&(l.value=String(c.storedProfileId)),!l.value){const v=c.profiles.find(d=>/720/i.test(d.name));v&&(l.value=String(v.id))}!l.value&&c.profiles.length&&(l.value=String(c.profiles[0].id)),y.innerHTML=`<span style="color: var(--ok-fg);">${c.profiles.length} profiles loaded</span>`}catch(p){l.innerHTML='<option value="">-- Failed to load --</option>',y.innerHTML=`<span style="color: var(--error, #c62828);">Error: ${p.message}</span>`}}document.getElementById("svc-quality-fetch")?.addEventListener("click",async()=>{if(!u)return;const r=u.id||u.appTemplate,l=s(u),e=document.getElementById("svc-apikey-input")?.value.trim(),a=document.getElementById("svc-quality-select"),p=document.getElementById("svc-quality-status");if(!l){p.innerHTML='<span style="color: var(--error, #c62828);">No service URL available</span>';return}if(!e||e==="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"){p.innerHTML='<span style="color: var(--error, #c62828);">Enter an API key first</span>';return}a.innerHTML='<option value="">Fetching...</option>',p.textContent="";try{const t=new URLSearchParams({service:r,url:l,apiKey:e}),v=await(await fetch(`/api/v1/arr/quality-profiles?${t}`)).json();if(!v.success){a.innerHTML='<option value="">-- Error --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">${v.error||"Failed to fetch profiles"}</span>`;return}if(!v.profiles?.length){a.innerHTML='<option value="">-- No profiles found --</option>';return}a.innerHTML="";for(const k of v.profiles){const B=document.createElement("option");B.value=k.id,B.textContent=k.name,a.appendChild(B)}const d=v.profiles.find(k=>/720/i.test(k.name));d?a.value=String(d.id):v.profiles.length&&(a.value=String(v.profiles[0].id)),p.innerHTML=`<span style="color: var(--ok-fg);">${v.profiles.length} profiles loaded</span>`}catch(t){a.innerHTML='<option value="">-- Error --</option>',p.innerHTML=`<span style="color: var(--error, #c62828);">${t.message}</span>`}}),document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!u)return;const r=document.getElementById("svc-creds-save");r.textContent="Saving...",r.disabled=!0,n();try{const l=g.includes(u.id)||g.includes(u.appTemplate),y=u.id||u.appTemplate;if(u.isExternal){const p=document.getElementById("svc-seedhost-user").value.trim(),t=document.getElementById("svc-seedhost-pass").value;p&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:p,password:t||void 0,serviceId:u.id})})}const a=document.getElementById("svc-apikey-input")?.value.trim();if(a&&a!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022")if(l){const p=s(u),t=document.getElementById("svc-quality-select"),c=t?.value?parseInt(t.value):void 0,v=t?.selectedOptions?.[0]?.textContent||void 0,k=await(await secureFetch("/api/v1/arr/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:y,apiKey:a,url:p||void 0,qualityProfileId:c||void 0,qualityProfileName:v||void 0})})).json();if(!k.success){h(k.error||"Failed to save API key"),r.textContent="Save",r.disabled=!1;return}k.connectionTest&&!k.connectionTest.success&&h(`API key saved but connection test failed: ${k.connectionTest.error}`)}else await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:a})});else if(l&&i.includes(y)){const p=document.getElementById("svc-quality-select"),t=p?.value?parseInt(p.value):void 0,c=p?.selectedOptions?.[0]?.textContent||void 0;t&&await secureFetch("/api/v1/arr/quality-profiles",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:y,qualityProfileId:t,qualityProfileName:c})})}if(!u.isExternal){const p=document.getElementById("svc-basic-user").value.trim(),t=document.getElementById("svc-basic-pass").value;p&&t&&await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:p,password:t})})}await w(u)}catch(l){o.logError("[ServiceCredentials] Save",l,{function:"saveCredentials"}),h("Failed to save: "+(l.message||"Unknown error"))}r.textContent="Save",r.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(u&&confirm(`Remove stored credentials for ${u.name}?`)){n();try{const r=u.id||u.appTemplate,l=g.includes(r);u.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${u.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${u.id}/credentials`,{method:"DELETE"}),l&&await secureFetch(`/api/v1/arr/credentials/${r}`,{method:"DELETE"});const y=document.getElementById(`creds-btn-${u.id}`);y&&y.classList.remove("has-creds"),await w(u)}catch(r){o.logError("[ServiceCredentials] Clear",r,{function:"clearCredentials"}),h("Failed to clear: "+(r.message||"Unknown error"))}}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{f.classList.remove("show"),u=null}),f?.addEventListener("click",r=>{r.target===f&&(f.classList.remove("show"),u=null)}),window.refreshCredsButtons=async function(){try{for(const r of window.APPS||[]){if(!r.isExternal&&!r.appTemplate&&!r.url)continue;let l=!1;if(r.isExternal)try{const a=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();a.success&&a.hasCredentials&&(l=!0)}catch{}try{const a=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();a.success&&(a.hasApiKey||a.hasBasicAuth)&&(l=!0)}catch{}const y=document.getElementById(`creds-btn-${r.id}`);y&&y.classList.toggle("has-creds",l)}}catch{}}})(),(function(){const o=new ErrorHandler;injectModal("totp-settings-modal",`<div id="totp-settings-modal" class="weather-modal">
|
|
<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>
|
|
|
|
<!-- Status Banner -->
|
|
<div id="totp-status-banner" style="margin-bottom: 16px; padding: 12px 16px; border-radius: 8px; border: 1px solid var(--border); display: flex; align-items: center; gap: 8px;">
|
|
<span id="totp-status-dot" class="status-dot"></span>
|
|
<span id="totp-status-text" style="font-size: 0.9rem;">TOTP is not configured</span>
|
|
</div>
|
|
|
|
<!-- Setup Button (not configured state) -->
|
|
<div id="totp-setup-section">
|
|
<button id="totp-setup-btn" class="btn-accent-solid" style="width: 100%; padding: 12px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 0.95rem;">
|
|
Generate New Secret
|
|
</button>
|
|
<div style="display: flex; align-items: center; gap: 8px; margin: 10px 0 0;">
|
|
<div class="divider-line"></div>
|
|
<span class="text-tiny-muted">or</span>
|
|
<div class="divider-line"></div>
|
|
</div>
|
|
<div style="margin-top: 10px;">
|
|
<label class="text-hint">Import an existing secret key:</label>
|
|
<div style="display: flex; gap: 8px; margin-top: 6px;">
|
|
<input type="text" id="totp-import-key" placeholder="Paste your Base32 key" autocomplete="off" spellcheck="false"
|
|
style="flex: 1; padding: 10px; background: var(--bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.9rem; font-family: monospace; letter-spacing: 1px; text-transform: uppercase;" />
|
|
<button id="totp-import-btn" style="padding: 10px 16px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
|
|
Import
|
|
</button>
|
|
</div>
|
|
<div id="totp-import-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Secret Display (setup flow) -->
|
|
<div id="totp-qr-section" style="display: none;">
|
|
<!-- Manual Key (primary - for WinAuth/desktop authenticators) -->
|
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 8px;">Copy this key into your authenticator app:</p>
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px;">
|
|
<code id="totp-manual-key" style="flex: 1; display: block; padding: 12px; background: var(--bg, #0b0f1a); border: 1px solid var(--border); border-radius: 6px; font-size: 1rem; font-family: 'Sami Grotesk', monospace; letter-spacing: 2px; word-break: break-all; user-select: all; color: var(--fg);"></code>
|
|
<button id="totp-copy-key" style="padding: 10px 14px; background: var(--card-base); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 1rem; white-space: nowrap; color: var(--fg);" title="Copy to clipboard">\u{1F4CB}</button>
|
|
</div>
|
|
|
|
<!-- QR Code (secondary - for mobile apps) -->
|
|
<details class="mb-16">
|
|
<summary style="cursor: pointer; color: var(--muted); font-size: 0.8rem;">Show QR code (for mobile authenticator apps)</summary>
|
|
<div style="text-align: center; margin-top: 8px;">
|
|
<img id="totp-qr-image" style="width: 180px; height: 180px; border-radius: 8px;" />
|
|
</div>
|
|
</details>
|
|
|
|
<!-- Verify First Code -->
|
|
<div style="border-top: 1px solid var(--border); padding-top: 16px;">
|
|
<label class="font-bold-sm">Enter code to confirm setup:</label>
|
|
<div style="display: flex; gap: 8px; margin-top: 8px;">
|
|
<input type="text" id="totp-setup-code" maxlength="6" inputmode="numeric" pattern="[0-9]{6}" placeholder="000000"
|
|
style="flex: 1; text-align: center; font-size: 1.3rem; padding: 10px; font-family: 'Sami Grotesk', monospace; letter-spacing: 4px; background: var(--bg); color: var(--fg); border: 2px solid var(--border); border-radius: 8px; outline: none;" />
|
|
<button id="totp-confirm-setup" class="btn-accent-solid" style="padding: 10px 20px; border: none; border-radius: 8px; cursor: pointer; font-weight: 600;">
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
<div id="totp-setup-error" style="color: var(--bad-fg, #ff9aa3); font-size: 0.8rem; min-height: 1.2em; margin-top: 6px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Session Duration (active state) -->
|
|
<div id="totp-duration-section" style="display: none; margin-top: 12px;">
|
|
<label class="font-bold-sm">Session Duration:</label>
|
|
<select id="totp-duration-select" style="width: 100%; padding: 10px; margin-top: 6px; background: var(--bg, #0b0f1a); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; font-size: 0.9rem; cursor: pointer;">
|
|
<option value="15m">15 minutes</option>
|
|
<option value="30m">30 minutes</option>
|
|
<option value="1h">1 hour</option>
|
|
<option value="2h">2 hours</option>
|
|
<option value="4h">4 hours</option>
|
|
<option value="8h">8 hours</option>
|
|
<option value="12h">12 hours</option>
|
|
<option value="24h">24 hours</option>
|
|
<option value="never">Never (disable TOTP)</option>
|
|
</select>
|
|
<p style="font-size: 0.75rem; color: var(--muted); margin: 4px 0 0;">How long before you need to re-enter your code</p>
|
|
</div>
|
|
|
|
<!-- Disable Button (active state) -->
|
|
<div id="totp-disable-section" style="display: none; margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border);">
|
|
<button id="totp-disable-btn" style="width: 100%; padding: 10px; background: transparent; color: var(--bad-fg, #ff9aa3); border: 1px solid var(--bad-fg, #ff9aa3); border-radius: 8px; cursor: pointer; font-size: 0.9rem;">
|
|
Disable TOTP
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Close -->
|
|
<div style="margin-top: 16px; text-align: right;">
|
|
<button id="totp-modal-close" style="padding: 8px 20px; background: var(--card-base); color: var(--fg); border: 1px solid var(--border); border-radius: 8px; cursor: pointer;">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`);async function f(){try{const s=await(await fetch("/api/v1/totp/config")).json();if(!s.success)return;const{enabled:h,sessionDuration:n,isSetUp:w}=s.config,m=document.getElementById("totp-status-dot"),r=document.getElementById("totp-status-text"),l=document.getElementById("totp-status-banner"),y=document.getElementById("totp-setup-section"),e=document.getElementById("totp-qr-section"),a=document.getElementById("totp-duration-section"),p=document.getElementById("totp-disable-section");h&&w?(m.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)",r.textContent="TOTP is active",r.style.color="var(--ok-fg, #7ef2ff)",y.style.display="none",e.style.display="none",a.style.display="block",p.style.display="block",document.getElementById("totp-duration-select").value=n):(m.style.background="var(--muted)",l.style.borderColor="var(--border)",l.style.background="transparent",r.textContent="TOTP is not configured",r.style.color="var(--muted)",y.style.display="block",e.style.display="none",a.style.display="none",p.style.display="none"),g(h&&w,n)}catch(i){console.warn("Failed to load TOTP settings:",i)}}const u={"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 g(i,s){const h=document.getElementById("auth-card"),n=document.getElementById("auth-pill"),w=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");h&&(i?(h.setAttribute("data-status","on"),n.className="badge on",n.textContent="YES",w.className="dot ok at-bl",m.textContent="Session: "+(u[s]||s)):(h.setAttribute("data-status","off"),n.className="badge off",n.textContent="NO",w.className="dot bad at-bl",m.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){o.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 n=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:i})})).json();n.success?(s.textContent="",document.getElementById("totp-qr-image").src=n.qrCode,document.getElementById("totp-manual-key").textContent=n.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus()):s.textContent=n.error||n.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-confirm-setup")?.addEventListener("click",async()=>{const i=document.getElementById("totp-setup-code").value,s=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(i)){s.textContent="Enter a 6-digit code";return}try{const n=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:i})})).json();n.success?(s.textContent="",f()):s.textContent=n.error||"Invalid code"}catch{s.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",i=>{i.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async i=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:i.target.value})}),f()}catch(s){o.logError("[TOTP] Update Session Duration",s,{function:"updateSessionDuration"})}}),document.getElementById("totp-disable-btn")?.addEventListener("click",async()=>{if(confirm("Disable TOTP authentication? All services will be accessible without a code."))try{(await(await secureFetch("/api/v1/totp/disable",{method:"POST",headers:{"Content-Type":"application/json"},body:"{}"})).json()).success&&f()}catch(i){o.logError("[TOTP] Disable Failed",i,{function:"disableTOTP"})}}),document.getElementById("auth-settings-btn")?.addEventListener("click",()=>{f(),openModal("totp-settings-modal")}),document.getElementById("totp-modal-close")?.addEventListener("click",()=>{closeModal("totp-settings-modal")}),document.getElementById("totp-settings-modal")?.addEventListener("click",i=>{i.target.id==="totp-settings-modal"&&closeModal("totp-settings-modal")}),window._updateAuthCard=g,(async()=>{try{const s=await(await fetch("/api/v1/totp/config")).json();if(s.success){const h=s.config.enabled&&s.config.isSetUp;g(h,s.config.sessionDuration)}}catch(i){o.logError("[TOTP] AuthCard Update",i,{function:"authCardUpdate"})}})()})(),(function(){injectModal("token-management-modal",`
|
|
<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>
|
|
|
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
|
Enter Technitium DNS login credentials. Read-only accounts are used for logs; admin accounts for restarts, records, and updates.
|
|
</p>
|
|
|
|
<div id="dns-cred-sections"></div>
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
<button id="token-clear-all" style="margin-right: auto; background: color-mix(in srgb, var(--bad-fg) 15%, transparent); border-color: var(--bad-fg); color: var(--bad-fg);">Clear All</button>
|
|
<button id="token-cancel">Cancel</button>
|
|
<button id="token-save" class="btn-accent">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`);function o(){return Object.keys(SITE.dnsServers||{})}function f(t){return(SITE.dnsServers||{})[t]?.name||t.toUpperCase()}function u(){const t=document.getElementById("dns-cred-sections");if(!t)return;t.innerHTML="";const c=o();if(c.length===0){t.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const v of c)t.insertAdjacentHTML("beforeend",`
|
|
<div class="token-section">
|
|
<h4 class="token-section-title">${f(v)}</h4>
|
|
<div class="token-grid">
|
|
<div class="token-field">
|
|
<label for="${v}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
|
|
<input type="text" id="${v}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
|
<div class="token-input-row">
|
|
<input type="password" id="${v}-readonly-token" placeholder="Password" autocomplete="off" />
|
|
<button type="button" class="token-toggle" data-target="${v}-readonly-token">\u{1F441}</button>
|
|
</div>
|
|
</div>
|
|
<div class="token-field">
|
|
<label for="${v}-admin-username">\u{1F527} Admin:</label>
|
|
<input type="text" id="${v}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
|
|
<div class="token-input-row">
|
|
<input type="password" id="${v}-admin-token" placeholder="Password" autocomplete="off" />
|
|
<button type="button" class="token-toggle" data-target="${v}-admin-token">\u{1F441}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="token-status" id="${v}-token-status"></div>
|
|
</div>
|
|
`)}function g(){let t=safeSessionGet("dashcaddy-encryption-key");if(t)return t;const c=safeGet("dashcaddy-encryption-key");if(c)return safeSessionSet("dashcaddy-encryption-key",c),safeRemove("dashcaddy-encryption-key"),c;const v=new Uint8Array(32);return crypto.getRandomValues(v),t=Array.from(v,d=>d.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",t),t}const i=g();function s(t,c){if(!t)return"";const v=crypto.getRandomValues(new Uint8Array(8)),d=Array.from(v,T=>T.toString(16).padStart(2,"0")).join(""),k=new TextEncoder().encode(c+d);let B="";for(let T=0;T<t.length;T++){const A=t.charCodeAt(T)^k[T%k.length]^v[T%v.length]^T*31+17&255;B+=String.fromCharCode(A)}return d+":"+btoa(B)}function h(t,c){if(!t)return"";try{if(t.indexOf(":")===16){const B=t.substring(0,16),T=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(t.substring(17)),E=new TextEncoder().encode(c+B);let L="";for(let $=0;$<A.length;$++){const x=A.charCodeAt($)^E[$%E.length]^T[$%T.length]^$*31+17&255;L+=String.fromCharCode(x)}return L}const d=atob(t);let k="";for(let B=0;B<d.length;B++){const T=d.charCodeAt(B)^c.charCodeAt(B%c.length);k+=String.fromCharCode(T)}return k}catch{return""}}function n(t,c,v){const d=safeGet(`${t}-${c}-${v}-enc`);return h(d,i)}function w(t,c,v,d){const k=`${t}-${c}-${v}-enc`;d?safeSet(k,s(d,i)):safeRemove(k)}function m(t,c){return n(t,c,"token")}function r(t,c){return n(t,c,"username")}function l(t,c,v){w(t,c,"token",v)}function y(t,c,v){w(t,c,"username",v)}function e(){const t={};for(const c of o())t[c]={readonly:{username:r(c,"readonly"),token:m(c,"readonly")},admin:{username:r(c,"admin"),token:m(c,"admin")}};return t}function a(){o().forEach(t=>{["readonly","admin"].forEach(c=>{["token","username"].forEach(v=>{safeRemove(`${t}-${c}-${v}-enc`)})}),safeRemove(`${t}-token-enc`),safeRemove(`${t}-username-enc`)})}function p(t){const c=m(t,"readonly"),v=r(t,"readonly"),d=m(t,"admin"),k=r(t,"admin"),B=h(safeGet(`${t}-token-enc`),i),T=h(safeGet(`${t}-username-enc`),i);return{username:k||v||T,token:d||c||B,readonlyToken:c||B,readonlyUsername:v||T,adminToken:d||B,adminUsername:k||T}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{u();const t=document.getElementById("token-management-modal"),c=e();o().forEach(v=>{const d=c[v];document.getElementById(`${v}-readonly-username`).value=d.readonly.username,document.getElementById(`${v}-readonly-token`).value=d.readonly.token,document.getElementById(`${v}-admin-username`).value=d.admin.username,document.getElementById(`${v}-admin-token`).value=d.admin.token,document.getElementById(`${v}-token-status`).textContent=""}),t.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",t=>{const c=t.target.closest(".token-toggle");if(c){const v=c.dataset.target,d=document.getElementById(v);d.type==="password"?(d.type="text",c.textContent="\u{1F648}"):(d.type="password",c.textContent="\u{1F441}");return}t.target.id==="token-management-modal"&&t.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const t=o();t.forEach(d=>{y(d,"readonly",document.getElementById(`${d}-readonly-username`).value.trim()),l(d,"readonly",document.getElementById(`${d}-readonly-token`).value.trim()),y(d,"admin",document.getElementById(`${d}-admin-username`).value.trim()),l(d,"admin",document.getElementById(`${d}-admin-token`).value.trim())});const c={};let v=!1;if(t.forEach(d=>{const k={},B=document.getElementById(`${d}-readonly-username`).value.trim(),T=document.getElementById(`${d}-readonly-token`).value.trim(),A=document.getElementById(`${d}-admin-username`).value.trim(),E=document.getElementById(`${d}-admin-token`).value.trim();B&&T&&(k.readonly={username:B,password:T},v=!0),A&&E&&(k.admin={username:A,password:E},v=!0),Object.keys(k).length>0&&(c[d]=k)}),v){t.forEach(d=>{c[d]&&(document.getElementById(`${d}-token-status`).textContent="Verifying...",document.getElementById(`${d}-token-status`).className="token-status")});try{const k=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:c})})).json();k.results?t.forEach(B=>{const T=document.getElementById(`${B}-token-status`);if(!c[B]){T.textContent="";return}const A=k.results[B];A?.success?(T.textContent="\u2713 Verified & saved",T.className="token-status success"):A?.partial?(T.textContent="\u2713 "+A.partial,T.className="token-status success"):(T.textContent="\u2717 "+(A?.error||"Login failed"),T.className="token-status error")}):k.success?t.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):t.forEach(B=>{c[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(k.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(d){console.error("Failed to sync DNS credentials to backend:",d),t.forEach(k=>{c[k]&&(document.getElementById(`${k}-token-status`).textContent="\u2713 Saved locally (sync failed)",document.getElementById(`${k}-token-status`).className="token-status")})}}else t.forEach(d=>{document.getElementById(`${d}-token-status`).textContent=""});setTimeout(()=>{t.every(k=>{const B=document.getElementById(`${k}-token-status`)?.textContent;return!B||B.includes("\u2713")})&&closeModal("token-management-modal")},1500)}),document.getElementById("token-cancel")?.addEventListener("click",()=>{closeModal("token-management-modal")}),document.getElementById("token-clear-all")?.addEventListener("click",async()=>{if(confirm("Clear all stored DNS credentials? This cannot be undone.")){a(),o().forEach(t=>{document.getElementById(`${t}-readonly-username`).value="",document.getElementById(`${t}-readonly-token`).value="",document.getElementById(`${t}-admin-username`).value="",document.getElementById(`${t}-admin-token`).value="",document.getElementById(`${t}-token-status`).textContent="\u2713 Cleared",document.getElementById(`${t}-token-status`).className="token-status success"});try{await secureFetch("/api/v1/dns/credentials",{method:"DELETE"})}catch{}}}),window.getToken=m,window.getUsername=r,window.setToken=l,window.setUsername=y,window.getAllCredentials=e,window.getCredential=n,window.setCredential=w,window.getEncryptionKey=g,window.getDnsIds=o,window.getDnsDisplayName=f})(),(function(){function o(l,y,e=null){const a=document.getElementById(l+"-dot"),p=document.getElementById(l+"-pill"),t=document.getElementById(l+"-time"),c=document.querySelector(`[data-app="${l}"]`);a&&(a.classList.toggle("ok",y),a.classList.toggle("bad",!y)),p&&(p.textContent=y?"ON":"OFF",p.classList.toggle("on",y),p.classList.toggle("off",!y)),t&&e!==null&&(t.textContent=y?`${e}ms`:"timeout",t.className=`response-time ${f(e,y)}`),c&&c.setAttribute("data-status",y?"on":"off")}function f(l,y){return y?l<200?"excellent":l<500?"good":l<1e3?"fair":"slow":"timeout"}async function u(l){const y=performance.now();try{const e=await fetch("/probe/"+l,{cache:"no-store"}),a=performance.now(),p=Math.round(a-y);return{isUp:e.status>=200&&e.status<400||e.status===401||e.status===403,responseTime:p}}catch{const e=performance.now();return{isUp:!1,responseTime:Math.round(e-y)}}}window.APPS=[];let g=null,i=!1;async function s(){try{window.SkeletonLoader&&window.SkeletonLoader.show(6);const l=await fetch("/api/v1/services",{cache:"no-store"});l.ok?(window.APPS=await l.json(),window.SkeletonLoader&&window.SkeletonLoader.hide()):(console.error("Failed to load services:",l.status),window.SkeletonLoader&&window.SkeletonLoader.hide())}catch(l){console.error("Failed to load services:",l),window.SkeletonLoader&&window.SkeletonLoader.hide()}}function h(l){const y=window.APPS?.find(a=>a.id===l);if(y?.url)return y.url.startsWith("http")?y.url:"https://"+y.url;if(y?.isExternal&&y.externalUrl)return y.externalUrl;const e=SITE.dnsServers?.[l];return e?"http://"+e.ip+":"+(e.port||5380):buildServiceUrl(l)}function n(l,y,e){const a=document.createElement(l);return y&&(a.className=y),e&&(a.textContent=e),a}function w(){const l=document.getElementById("cards");l.innerHTML="";for(let y=0;y<window.APPS.length;y++){const e=window.APPS[y];if(e.id==="ca")continue;const a=n("div","card");a.setAttribute("data-app",e.id),a.setAttribute("data-status","off"),e.recipeId&&a.setAttribute("data-recipe-id",e.recipeId);const p=n("span","dot bad at-bl");p.id="dot-"+e.id+"-grid",a.appendChild(p);const t=n("div","row"),c=n("div","logo-wrap"),v=document.createElement("img");v.src=e.logo,v.alt=e.name,v.className="logo-img",v.onerror=function(){let S=e.id||e.appTemplate;if(!S&&e.name&&(S=e.name.toLowerCase().replace(/\s+/g,"-")),S){const C=[`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${S}.png`,`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${S.toLowerCase()}.png`,`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${S.replace(/-/g,"")}.png`,`https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/${e.name.toLowerCase().replace(/\s+/g,"-")}.png`],P=[...new Set(C)],O=P.indexOf(this.src)+1;O<P.length?this.src=P[O]:this.style.display="none"}else this.style.display="none"},c.appendChild(v),t.appendChild(c);const d=n("span","name",e.name);if(t.appendChild(d),e.tailscaleOnly){const S=n("span","ts-badge","\u{1F510}");S.title="Tailscale-only access",S.style.cssText="margin-left: 6px; font-size: 0.75rem; opacity: 0.8;",d.appendChild(S)}t.appendChild(n("span","spacer"));const k=n("span","badge off","OFF");k.id="badge-"+e.id,t.appendChild(k);const B=n("span","update-available-badge","UPDATE");B.id="update-badge-"+e.id,B.title="Update available",t.appendChild(B),a.appendChild(t);const T=n("div","response-row"),A=n("span","response-time","--");A.id="time-"+e.id,T.appendChild(A),a.appendChild(T);const E=n("div","health-row");E.id="health-"+e.id;const L=n("span","uptime-chip","--");L.id="uptime-"+e.id,E.appendChild(L);const $=document.createElement("div");$.className="uptime-mini-bar";const x=document.createElement("div");x.className="fill",x.id="uptime-bar-"+e.id,x.style.width="0%",$.appendChild(x),E.appendChild($),a.appendChild(E);const b=n("div","btn-row");if(e.containerId){const S=n("button","logs-btn","\u{1F4CB}");S.title="View container logs",S.onclick=D=>{D.stopPropagation(),window.openContainerLogsModal(e.containerId,e.name)},b.appendChild(S);const C=n("button","update-btn","\u2B06\uFE0F");C.title="Update container to latest version",C.id=`update-btn-${e.id}`,C.onclick=D=>{D.stopPropagation(),window.updateContainer(e.containerId,e.name,e.id)},b.appendChild(C);const P=n("button","exec-btn",">_");P.title="Open terminal",P.onclick=D=>{D.stopPropagation(),window.openExecModal&&window.openExecModal(e.containerId,e.name)},b.appendChild(P)}if(e.logPath&&!e.containerId){const S=n("button","logs-btn","\u{1F4CB}");S.title="View application logs",S.onclick=C=>{C.stopPropagation(),window.openFileLogsModal(e.logPath,e.name)},b.appendChild(S)}if(e.isExternal||e.appTemplate||e.url){const S=n("button","creds-btn","\u{1F511}");S.title="Auto-login credentials",S.id=`creds-btn-${e.id}`,S.onclick=C=>{C.stopPropagation(),window.openServiceCredsModal(e)},b.appendChild(S)}if(e.id!=="internet"){const S=n("button","options-btn","\u2699\uFE0F");S.title="Edit service settings",S.onclick=C=>{C.stopPropagation(),window.openServiceEditModal(e)},b.appendChild(S)}if(e.id!=="internet"){const S=n("button","delete-btn","\u{1F5D1}\uFE0F");S.title="Delete this service",S.onclick=C=>{C.stopPropagation(),window.deleteService(e.id,e.name)},b.appendChild(S)}const I=n("button",null,"Open");I.onclick=()=>window.open(h(e.id),"_blank","noopener"),b.appendChild(I),a.appendChild(b),a.style.transitionDelay=`${Math.min(y*45,270)}ms`,l.appendChild(a)}requestAnimationFrame(()=>{l.querySelectorAll(".card").forEach(y=>y.classList.add("loaded"))}),window.groupRecipeCards&&requestAnimationFrame(()=>window.groupRecipeCards())}function m(l,y,e=null){const a=document.getElementById("dot-"+l+"-grid"),p=document.getElementById("badge-"+l),t=document.getElementById("time-"+l),c=document.querySelector(`[data-app="${l}"]`);a&&(a.classList.toggle("ok",y),a.classList.toggle("bad",!y)),p&&(p.textContent=y?"ON":"OFF",p.classList.toggle("on",y),p.classList.toggle("off",!y)),t&&e!==null&&(t.textContent=y?`${e}ms`:"timeout",t.className=`response-time ${f(e,y)}`),c&&c.setAttribute("data-status",y?"on":"off")}async function r(){if(g)return i=!0,g;function l(a,p=new Date){const t=document.getElementById("stamp");t&&(t.textContent=`${a}: ${new Date(p).toLocaleTimeString()}`)}function y(a){Object.keys(SITE.dnsServers).forEach(t=>{const c=a[t];c&&o(t,c.isUp,c.responseTime)}),a.internet&&o("internet",a.internet.isUp,a.internet.responseTime),window.APPS.forEach(t=>{const c=a[t.id];c&&m(t.id,c.isUp,c.responseTime)})}async function e(){const a=Object.keys(SITE.dnsServers),p=a.map(d=>u(d));p.push(u("internet"));const t=await Promise.all(p);a.forEach((d,k)=>o(d,t[k].isUp,t[k].responseTime));const c=t[t.length-1];o("internet",c.isUp,c.responseTime),(await Promise.all(window.APPS.map(async d=>{const k=await u(d.id);return{id:d.id,...k}}))).forEach(d=>{m(d.id,d.isUp,d.responseTime)})}return g=(async()=>{try{const a=await fetch("/api/v1/services/status",{cache:"no-store"});if(!a.ok)throw new Error(`Status refresh failed (${a.status})`);const p=await a.json();y(p.statuses||{}),l("last check",p.checkedAt||new Date)}catch(a){console.warn("Batched status refresh failed, falling back to direct probes:",a);try{await e(),l("last check")}catch(p){console.error("Dashboard refresh failed:",p),l("last failed")}}finally{g=null,i&&(i=!1,setTimeout(()=>{window.refreshAll()},0))}})(),g}document.querySelector(".top")?.addEventListener("click",l=>{const y=l.target.closest('[id$="-open"]');if(!y)return;const e=y.id.replace("-open","");SITE.dnsServers[e]&&window.open(h(e),"_blank","noopener")}),document.getElementById("ca-open")?.addEventListener("click",()=>window.open(h("ca"),"_blank","noopener")),document.getElementById("creds-btn-ca")?.addEventListener("click",l=>{l.stopPropagation();const y=window.APPS.find(e=>e.id==="ca");y&&window.openServiceCredsModal&&window.openServiceCredsModal(y)}),document.getElementById("options-btn-ca")?.addEventListener("click",l=>{l.stopPropagation();const y=window.APPS.find(e=>e.id==="ca");y&&window.openServiceEditModal&&window.openServiceEditModal(y)}),document.getElementById("delete-btn-ca")?.addEventListener("click",l=>{l.stopPropagation(),window.deleteService&&window.deleteService("ca","DashCA")}),window.loadServices=s,window.buildGrid=w,window.refreshAll=r,window.setQuick=o,window.setBadge=m,window.getResponseTimeClass=f,window.checkServiceWithTiming=u,window.serviceUrl=h,window.el=n})(),(function(){async function o(n){const m=await(await secureFetch(`/api/v1/dns/restart/${n}`,{method:"POST"})).json();if(!m.success)throw new Error(m.error||"Restart failed");return m}document.querySelector(".top")?.addEventListener("click",async n=>{const w=n.target.closest('[id$="-restart"]');if(!w)return;const m=w.id.replace("-restart","");if(SITE.dnsServers[m]&&confirm(`Restart ${m.toUpperCase()} service?`))try{await withButton(w,"...",()=>o(m)),setTimeout(window.refreshAll,DC.DELAYS.RELOAD)}catch(r){showNotification("Restart failed: "+r.message,"error")}});async function f(n,w){const m=document.getElementById(`${n}-update`),r=m?.textContent||"\u2B06\uFE0F";try{m.textContent="\u{1F50D}",m.disabled=!0,m.title="Checking for updates...";const y=await(await fetch(`/api/v1/dns/check-update?server=${encodeURIComponent(w)}`)).json();if(!y.success)throw new Error(y.error||"Failed to check for updates");if(!y.updateAvailable){m.textContent="\u2705",m.title=`Already on latest version (${y.currentVersion})`,showNotification(`${n.toUpperCase()} is already up to date! Current version: ${y.currentVersion}`,"info"),setTimeout(()=>{m.textContent=r,m.disabled=!1,m.title="Update DNS server"},3e3);return}if(!confirm(`Update available for ${n.toUpperCase()}!
|
|
|
|
Current: ${y.currentVersion}
|
|
New: ${y.updateVersion}
|
|
|
|
`+(y.updateTitle?`${y.updateTitle}
|
|
|
|
`:"")+`The DNS server will restart during the update.
|
|
Proceed?`)){m.textContent=r,m.disabled=!1,m.title="Update DNS server";return}m.textContent="\u{1F504}",m.title="Updating...";const p=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(w)}`,{method:"POST"})).json();if(!p.success)throw new Error(p.error||"Update failed");if(p.manualUpdateRequired){m.textContent="\u2B06\uFE0F",m.title=`Update available: ${p.newVersion}`;const t=p.downloadLink?`
|
|
Download: ${p.downloadLink}`:"",c=p.instructionsLink?`
|
|
Instructions: ${p.instructionsLink}`:"";showNotification(`${n.toUpperCase()} update requires manual installation. Current: ${p.previousVersion} \u2192 ${p.newVersion}. Please update manually on the host machine.`,"warning",8e3),m.disabled=!1;return}m.textContent="\u2705",m.title="Updated successfully!",showNotification(`${n.toUpperCase()} updated successfully! ${p.previousVersion} \u2192 ${p.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{m.textContent=r,m.disabled=!1,m.title="Update DNS server",window.refreshAll()},1e4)}catch(l){console.error("DNS update error:",l),m.textContent="\u274C",m.title="Update failed",showNotification(`Failed to update ${n.toUpperCase()}: ${l.message}`,"error"),setTimeout(()=>{m.textContent=r,m.disabled=!1,m.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",n=>{const w=n.target.closest('[id$="-update"]');if(!w)return;const m=w.id.replace("-update","");SITE.dnsServers[m]&&f(m,SITE.dnsServers[m]?.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>
|
|
|
|
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
|
<div>
|
|
<label for="dns-edit-ip" class="form-label-accent-sm">Server IP</label>
|
|
<input type="text" id="dns-edit-ip" class="form-input-md" placeholder="192.168.1.1" />
|
|
</div>
|
|
<div>
|
|
<label for="dns-edit-port" class="form-label-accent-sm">Port</label>
|
|
<input type="number" id="dns-edit-port" class="form-input-md" placeholder="5380" />
|
|
</div>
|
|
<div>
|
|
<label for="dns-edit-name" class="form-label-accent-sm">Display Name (optional)</label>
|
|
<input type="text" id="dns-edit-name" class="form-input-md" placeholder="e.g. Primary DNS" />
|
|
</div>
|
|
<div class="form-hint-sm">Manage credentials via Tokens in the toolbar</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
|
<button id="dns-settings-cancel">Cancel</button>
|
|
<button id="dns-settings-delete" style="background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">Remove</button>
|
|
<button id="dns-settings-save" class="btn-accent">Save</button>
|
|
</div>
|
|
</div>
|
|
</div>`);let u=null;function g(n){u=n;const w=SITE.dnsServers[n]||{},m=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(w.name||n).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=w.ip||"",document.getElementById("dns-edit-port").value=w.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=w.name||"",m.classList.add("show")}async function i(){if(!u)return;const n=document.getElementById("dns-edit-ip").value.trim(),w=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,m=document.getElementById("dns-edit-name").value.trim();if(!n){showNotification("Server IP is required","warning");return}const r={dnsServers:{}};r.dnsServers[u]={ip:n,port:String(w)},m&&(r.dnsServers[u].name=m);try{const y=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)})).json();y.success?(SITE.dnsServers[u]=r.dnsServers[u],showNotification(`${u.toUpperCase()} settings saved`,"success"),h(),window.refreshAll()):showNotification(y.error||"Failed to save settings","error")}catch(l){showNotification("Failed to save: "+l.message,"error")}}async function s(){if(u&&confirm(`Remove ${u.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const w=await(await secureFetch("/api/v1/config")).json();w.dnsServers&&delete w.dnsServers[u];const r=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:w.dnsServers||{}})})).json();if(r.success){delete SITE.dnsServers[u];const l=document.querySelector(`.top [data-app="${u}"]`);l&&l.remove(),showNotification(`${u.toUpperCase()} removed from dashboard`,"success"),h()}else showNotification(r.error||"Failed to remove","error")}catch(n){showNotification("Failed to remove: "+n.message,"error")}}function h(){closeModal("dns-settings-modal"),u=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",h),document.getElementById("dns-settings-save")?.addEventListener("click",i),document.getElementById("dns-settings-delete")?.addEventListener("click",s),document.getElementById("dns-settings-modal")?.addEventListener("click",n=>{n.target.id==="dns-settings-modal"&&h()}),document.querySelector(".top")?.addEventListener("click",n=>{const w=n.target.closest('[id$="-settings"]');if(!w)return;const m=w.id.replace("-settings","");SITE.dnsServers[m]&&(n.stopPropagation(),g(m))}),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">
|
|
<h3 id="logs-title">DNS Logs</h3>
|
|
<div class="logs-controls">
|
|
<label for="log-lines">Show:</label>
|
|
<select id="log-lines">
|
|
<option value="25" selected>25</option>
|
|
<option value="50">50</option>
|
|
<option value="100">100</option>
|
|
<option value="200">200</option>
|
|
</select>
|
|
<button id="logs-stream" class="stream-btn" title="Enable real-time streaming">\u{1F4E1} Live</button>
|
|
<button id="logs-pause" class="pause-btn">\u23F8\uFE0F Pause</button>
|
|
<button id="logs-close" class="close-btn">\u2715</button>
|
|
</div>
|
|
</div>
|
|
<div class="logs-container scroll-container">
|
|
<div id="logs-content" class="logs-content">
|
|
<div class="logs-loading">Loading logs...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`);let o=null,f=null,u=!1,g=null,i=null,s=!1,h=null,n=null,w=!1,m=null,r=!1;async function l(x,b=25){try{const I=getDnsServerAddr(x),S=await fetch(`/api/v1/dns/logs?server=${I}&limit=${b}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return S.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${S.status}`}}catch(I){return console.error("DNS logs fetch failed:",I),{error:I.message}}}function y(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 b=document.createElement("div");if(b.className="log-entry",b.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 b.style.gridTemplateColumns="1fr",b.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(x.raw)}</span>`,b;const I=y(x.rcode),S=x.rcode==="Refused"||x.rcode==="REFUSED";return b.innerHTML=`
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.timestamp)}</span>
|
|
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(x.client)}">${escapeHtml(x.client)}</span>
|
|
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${S?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(x.domain)}">${escapeHtml(x.domain)}</span>
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(x.type)}</span>
|
|
<span style="color: ${I}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(x.rcode)}</span>
|
|
`,b}async function a(){if(w){await L();return}if(s){await B();return}if(u||!o)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await l(o,x);if(I.error){b.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}b.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(S=>{const C=e(S);b.appendChild(C)}):b.innerHTML+=`
|
|
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
|
No DNS queries logged yet
|
|
</div>`}catch(I){b.innerHTML=`
|
|
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
|
Failed to fetch logs: ${escapeHtml(I.message)}
|
|
</div>`}}function p(x){o=x,u=!1,s=!1;const b=document.getElementById("logs-modal"),I=document.getElementById("logs-title"),S=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");I.textContent=`${x.toUpperCase()} DNS Logs`,S.textContent="\u23F8\uFE0F Pause",S.classList.remove("paused"),C&&(C.style.display="none"),b.classList.add("show"),a(),f=setInterval(a,DC.POLL.LOGS)}function t(){document.getElementById("logs-modal").classList.remove("show"),f&&(clearInterval(f),f=null),v(),o=null,s=!1,g=null,i=null,w=!1,h=null,n=null,u=!1}function c(x){m&&v();const b=document.getElementById("logs-stream"),I=document.getElementById("logs-pause"),S=document.getElementById("logs-content");f&&(clearInterval(f),f=null);try{m=new EventSource(`/api/v1/logs/stream/${x}`),r=!0,b.classList.add("active"),b.textContent="\u{1F534} Live",b.title="Streaming - click to stop",I.style.display="none";const C=document.getElementById("logs-title");C.textContent.includes("\u{1F534}")||(C.innerHTML=C.textContent.replace("\u{1F4CB}","\u{1F4CB} \u{1F534}")),m.onmessage=P=>{try{const D=JSON.parse(P.data);if(D.error){console.error("Stream error:",D.error),v();return}const O=document.createElement("div");O.className="log-entry",O.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(D.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",M=`<span style="background: ${R?"var(--bad-bg)":"var(--ok-bg)"}; color: ${U}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${R?"STDERR":"STDOUT"}</span>`;for(O.innerHTML=`
|
|
<div style="flex-shrink: 0;">${M}</div>
|
|
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(D.text)}</div>
|
|
`,S.appendChild(O),S.scrollTop=S.scrollHeight;S.children.length>500;)S.removeChild(S.firstChild)}catch(D){console.error("Error parsing stream data:",D)}},m.onerror=P=>{console.error("EventSource error:",P),v()}}catch(C){console.error("Failed to start streaming:",C),v()}}function v(){m&&(m.close(),m=null),r=!1;const x=document.getElementById("logs-stream"),b=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"),b&&(b.style.display=""),I&&(I.textContent=I.textContent.replace(" \u{1F534}","")),s&&g&&!f&&(f=setInterval(B,DC.POLL.LOGS))}async function d(x,b=100){try{const I=`/api/v1/logs/container/${x}?tail=${b}×tamps=true`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,containerName:C.containerName,containerId:C.containerId}:{error:C.error||"Failed to fetch container logs"}}else return{error:`HTTP ${S.status}: ${S.statusText}`}}catch(I){return console.error("Container logs fetch failed:",I),{error:I.message}}}function k(x){const b=document.createElement("div");b.className="log-entry",b.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.stream==="stderr"?"var(--bad-fg)":"var(--fg)",S=x.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return b.innerHTML=`
|
|
<div style="flex-shrink: 0;">${S}</div>
|
|
<div style="flex: 1; color: ${I}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(x.text)}</div>
|
|
`,b}async function B(){if(u||!g||!s)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await d(g,x);if(I.error){b.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}b.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(S=>{const C=k(S);b.appendChild(C)}),b.scrollTop=b.scrollHeight):b.innerHTML+=`
|
|
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
|
No logs available for this container
|
|
</div>`}catch(I){b.innerHTML=`
|
|
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
|
Failed to fetch logs: ${escapeHtml(I.message)}
|
|
</div>`}}function T(x,b){g=x,i=b,s=!0,w=!1,u=!1,v();const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${b} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),I.classList.add("show"),B(),f=setInterval(B,DC.POLL.LOGS)}async function A(x,b=100){try{const I=`/api/v1/logs/file?path=${encodeURIComponent(x)}&tail=${b}`,S=await fetch(I,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(S.ok){const C=await S.json();return C.success&&C.logs?{logs:C.logs,count:C.count,logPath:C.logPath,totalLines:C.totalLines}:{error:C.error||"Failed to fetch file logs"}}else return{error:(await S.json().catch(()=>({}))).error||`HTTP ${S.status}`}}catch(I){return console.error("File logs fetch failed:",I),{error:I.message}}}function E(x){const b=document.createElement("div");b.className="log-entry",b.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const I=x.text;let S="INFO",C="var(--fg)";I.match(/ERROR|FATAL|CRITICAL/i)?(S="ERROR",C="var(--bad-fg)"):I.match(/WARN|WARNING/i)?(S="WARN",C="#f39c12"):I.match(/DEBUG/i)&&(S="DEBUG",C="var(--muted)");const D=`<span style="background: ${C==="var(--bad-fg)"?"var(--bad-bg)":"var(--ok-bg)"}; color: ${C}; padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600; min-width: 50px; text-align: center;">${S}</span>`;return b.innerHTML=`
|
|
<div style="flex-shrink: 0;">${D}</div>
|
|
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(I)}</div>
|
|
`,b}async function L(){if(u||!h||!w)return;const x=parseInt(document.getElementById("log-lines").value),b=document.getElementById("logs-content");try{const I=await A(h,x);if(I.error){b.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}b.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(S=>{const C=E(S);b.appendChild(C)}),b.scrollTop=b.scrollHeight):b.innerHTML+=`
|
|
<div style="padding: 20px; text-align: center; color: var(--muted);">
|
|
No logs available in this file
|
|
</div>`}catch(I){b.innerHTML=`
|
|
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
|
|
Failed to fetch logs: ${escapeHtml(I.message)}
|
|
</div>`}}function $(x,b){h=x,n=b,w=!0,s=!1,u=!1;const I=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");S.textContent=`\u{1F4CB} ${b} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),I.classList.add("show"),L(),f=setInterval(L,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",x=>{const b=x.target.closest('[id$="-logs"]');if(!b)return;const I=b.id.replace("-logs","");SITE.dnsServers[I]&&p(I)}),document.getElementById("logs-close")?.addEventListener("click",t),document.getElementById("logs-pause")?.addEventListener("click",()=>{u=!u;const x=document.getElementById("logs-pause");u?(x.textContent="\u25B6\uFE0F Resume",x.classList.add("paused")):(x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),a())}),document.getElementById("log-lines")?.addEventListener("change",()=>{u||a()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!s||!g||(r?v():c(g))}),document.getElementById("logs-modal")?.addEventListener("click",x=>{x.target.id==="logs-modal"&&t()}),document.addEventListener("keydown",x=>{x.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&t()}),window.openContainerLogsModal=T,window.openFileLogsModal=$,window.openLogsModal=p})(),(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>
|
|
|
|
<div style="display: grid; gap: 16px; margin-top: 16px;">
|
|
<!-- Service Info -->
|
|
<div style="display: flex; align-items: center; gap: 12px; padding: 12px; background: var(--card-bg); border-radius: 8px;">
|
|
<img id="edit-service-logo-preview" src="" alt="" style="width: 48px; height: 48px; border-radius: 8px; object-fit: contain; background: var(--bg);" />
|
|
<div style="flex: 1;">
|
|
<div id="edit-service-url-display" class="text-muted-sm"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Display Name -->
|
|
<div>
|
|
<label for="edit-service-name" class="form-label-accent-sm">
|
|
Display Name
|
|
</label>
|
|
<input type="text" id="edit-service-name" class="form-input-md" placeholder="Service name shown on dashboard" />
|
|
</div>
|
|
|
|
<!-- Subdomain -->
|
|
<div>
|
|
<label for="edit-subdomain" class="form-label-accent-sm">
|
|
Subdomain
|
|
</label>
|
|
<div class="flex-row-gap-center">
|
|
<input type="text" id="edit-subdomain" class="input-flex" />
|
|
<span id="edit-tld-suffix" style="color: var(--muted);">.home</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Port -->
|
|
<div>
|
|
<label for="edit-port" class="form-label-accent-sm">
|
|
Port
|
|
</label>
|
|
<input type="number" id="edit-port" class="form-input-md" />
|
|
<div class="form-hint-sm">
|
|
The port Caddy will proxy to (container's exposed port)
|
|
</div>
|
|
</div>
|
|
|
|
<!-- IP Address -->
|
|
<div>
|
|
<label for="edit-ip" class="form-label-accent-sm">
|
|
IP Address
|
|
</label>
|
|
<input type="text" id="edit-ip" class="form-input-md" />
|
|
</div>
|
|
|
|
<!-- Tailscale Protection -->
|
|
<div>
|
|
<label style="display: flex; align-items: center; gap: 10px; padding: 12px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="edit-tailscale-only" style="width: 18px; height: 18px;" />
|
|
<div>
|
|
<div class="fw-500">Tailscale-Only Access</div>
|
|
<div class="text-hint">Restrict this service to Tailscale users only</div>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
|
|
<!-- Logo -->
|
|
<div>
|
|
<label class="form-label-accent-sm">
|
|
Service Logo
|
|
</label>
|
|
<div class="flex-row-gap-center">
|
|
<input type="text" id="edit-logo-url" placeholder="/assets/service.png or https://..." class="input-flex" />
|
|
<label style="padding: 10px 16px; background: var(--card-bg); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; white-space: nowrap;">
|
|
<input type="file" id="edit-logo-file" accept="image/*" style="display: none;" />
|
|
Upload
|
|
</label>
|
|
</div>
|
|
<div class="form-hint-sm">
|
|
Enter a URL or upload an image file (PNG, JPG, SVG)
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 24px;">
|
|
<button id="service-edit-cancel">Cancel</button>
|
|
<button id="service-edit-save" class="btn-accent">
|
|
Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>`),injectModal("delete-service-modal",`
|
|
<div id="delete-service-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 400px; max-width: 500px;">
|
|
<h3 id="delete-modal-title" class="mb-16">Delete Service</h3>
|
|
|
|
<div id="delete-modal-message" style="margin-bottom: 20px; line-height: 1.5;">
|
|
<!-- Dynamic content -->
|
|
</div>
|
|
|
|
<div id="delete-modal-container-info" style="display: none; margin-bottom: 20px; padding: 12px; background: var(--card-bg); border-radius: 8px; border: 1px solid var(--border);">
|
|
<div style="font-weight: 500; margin-bottom: 8px;">Docker Container</div>
|
|
<div id="delete-modal-container-name" style="font-size: 0.9rem; color: var(--muted);"></div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button id="delete-modal-cancel" style="padding: 10px 20px;">Cancel</button>
|
|
<button id="delete-modal-remove" style="padding: 10px 20px; background: color-mix(in srgb, var(--accent) 20%, transparent); border-color: var(--accent); color: var(--accent);">
|
|
Remove
|
|
</button>
|
|
<button id="delete-modal-delete" style="display: none; padding: 10px 20px; background: color-mix(in srgb, #e74c3c 20%, transparent); border-color: #e74c3c; color: #e74c3c;">
|
|
Delete
|
|
</button>
|
|
</div>
|
|
|
|
<div id="delete-modal-help" style="display: none; margin-top: 16px; padding: 12px; background: color-mix(in srgb, var(--muted) 10%, transparent); border-radius: 8px; font-size: 0.85rem; color: var(--muted);">
|
|
<div><strong>Remove:</strong> Remove from dashboard only (container keeps running)</div>
|
|
<div style="margin-top: 6px;"><strong>Delete:</strong> Full removal - stops container, removes DNS & Caddy config</div>
|
|
</div>
|
|
</div>
|
|
</div>`),injectModal("add-service-modal",`
|
|
<div id="add-service-modal" class="weather-modal">
|
|
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
|
<h3>Add Service</h3>
|
|
|
|
<!-- Service Type Tabs -->
|
|
<div style="display: flex; gap: 2px; margin-bottom: 16px; background: var(--card-bg); border-radius: 8px; padding: 3px; border: 1px solid var(--border);">
|
|
<label id="tab-local" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; background: var(--accent); color: var(--bg); transition: all 0.15s;">
|
|
<input type="radio" name="service-type" value="local" id="service-type-local" checked style="display: none;" />
|
|
Local
|
|
</label>
|
|
<label id="tab-external" style="flex: 1; text-align: center; padding: 8px; border-radius: 6px; cursor: pointer; font-size: 0.85rem; font-weight: 500; color: var(--muted); transition: all 0.15s;">
|
|
<input type="radio" name="service-type" value="external" id="service-type-external" style="display: none;" />
|
|
External
|
|
</label>
|
|
</div>
|
|
<span id="service-type-description" style="display: none;"></span>
|
|
|
|
<!-- LOCAL SERVICE -->
|
|
<div id="local-service-config" style="display: grid; gap: 12px;">
|
|
<div>
|
|
<label for="service-name-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
|
<input type="text" id="service-name-input" placeholder="e.g., Jellyfin" style="font-size: 1rem;" />
|
|
<div id="subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
|
</div>
|
|
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label for="service-port-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Port</label>
|
|
<input type="number" id="service-port-input" placeholder="e.g., 8096" style="font-size: 1rem;" />
|
|
</div>
|
|
<div>
|
|
<label for="service-ip-input" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">IP Address</label>
|
|
<input type="text" id="service-ip-input" placeholder="Auto-detected" style="font-size: 1rem;" />
|
|
<div class="quick-ip-buttons" style="display: flex; gap: 4px; margin-top: 4px; flex-wrap: wrap;">
|
|
<button type="button" class="quick-ip-btn" data-ip="127.0.0.1" title="Localhost" style="font-size: 0.7rem; padding: 2px 6px;">localhost</button>
|
|
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-lan" title="LAN IP" style="font-size: 0.7rem; padding: 2px 6px;">LAN</button>
|
|
<button type="button" class="quick-ip-btn" data-ip="" id="quick-ip-tailscale" title="Tailscale IP" style="font-size: 0.7rem; padding: 2px 6px;">Tailscale</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Options (collapsed by default) -->
|
|
<details id="local-advanced-options">
|
|
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
|
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
|
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label for="service-subdomain-input">Subdomain:</label>
|
|
<input type="text" id="service-subdomain-input" placeholder="auto-derived from name" />
|
|
</div>
|
|
<div>
|
|
<label for="service-logo-input">Logo URL:</label>
|
|
<input type="text" id="service-logo-input" placeholder="/assets/name.png" />
|
|
</div>
|
|
</div>
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; align-items: start;">
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="create-dns-record" checked />
|
|
Create DNS Record
|
|
</label>
|
|
<div>
|
|
<label for="ssl-type-select">SSL:</label>
|
|
<select id="ssl-type-select" style="width: 100%;">
|
|
<option value="caddy-managed">Caddy Managed (Internal)</option>
|
|
<option value="letsencrypt">Let's Encrypt</option>
|
|
<option value="existing-ca">Existing CA</option>
|
|
<option value="custom-ca">Custom CA</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="dns-config">
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label for="dns-ttl-input">DNS TTL:</label>
|
|
<input type="number" id="dns-ttl-input" value="300" />
|
|
</div>
|
|
<div>
|
|
<label for="caddyfile-path-input">Caddyfile Path:</label>
|
|
<input type="text" id="caddyfile-path-input" value="C:\\caddy\\Caddyfile" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="existing-ca-config" style="display: none;">
|
|
<label for="existing-ca-select">Existing CA:</label>
|
|
<div class="flex-row-gap">
|
|
<select id="existing-ca-select" style="flex: 1;">
|
|
<option value="">Loading CAs...</option>
|
|
</select>
|
|
<button type="button" id="refresh-cas" style="padding: 4px 8px; font-size: 0.75rem;">\u{1F504}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="custom-ca-config" style="display: none;">
|
|
<label for="ca-name-input">CA Name:</label>
|
|
<input type="text" id="ca-name-input" placeholder="e.g., sami-ca" />
|
|
</div>
|
|
|
|
<div id="manual-tailscale-status" style="padding: 6px 10px; background: var(--card-bg); border-radius: 6px; font-size: 0.75rem;">
|
|
<span style="color: var(--muted);">Checking Tailscale...</span>
|
|
</div>
|
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer;">
|
|
<input type="checkbox" id="manual-tailscale-only" />
|
|
Tailscale-Only Access
|
|
</label>
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="reload-caddy" checked />
|
|
Reload Caddy after adding
|
|
</label>
|
|
|
|
<hr style="border: none; border-top: 1px solid var(--border); margin: 4px 0;" />
|
|
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label class="field-label-sm">
|
|
<input type="checkbox" id="enable-auth" />
|
|
Authentication
|
|
</label>
|
|
<label class="field-label-sm">
|
|
<input type="checkbox" id="enable-cors" />
|
|
CORS Headers
|
|
</label>
|
|
<label for="upstream-path-input">Upstream Path:</label>
|
|
<input type="text" id="upstream-path-input" value="/" />
|
|
</div>
|
|
<div>
|
|
<label for="health-check-input">Health Check:</label>
|
|
<input type="text" id="health-check-input" placeholder="/health" />
|
|
<label for="timeout-input">Timeout (s):</label>
|
|
<input type="number" id="timeout-input" value="30" />
|
|
<label for="custom-headers-input">Headers (JSON):</label>
|
|
<textarea id="custom-headers-input" placeholder='{"X-Custom": "value"}' rows="2" style="font-size: 0.7rem;"></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
<!-- EXTERNAL SERVICE -->
|
|
<div id="external-service-config" style="display: none;">
|
|
<div style="display: grid; gap: 12px;">
|
|
<div>
|
|
<label for="external-service-name" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">Name</label>
|
|
<input type="text" id="external-service-name" placeholder="e.g., Radarr (Seedhost)" style="font-size: 1rem;" />
|
|
<div id="external-subdomain-preview" style="font-size: 0.75rem; color: var(--accent); margin-top: 4px; min-height: 1em;"></div>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="external-service-url" style="font-size: 0.8rem; color: var(--muted); margin-bottom: 4px; display: block;">External URL</label>
|
|
<input type="url" id="external-service-url" placeholder="https://username.seedhost.eu/radarr" style="font-size: 1rem;" />
|
|
</div>
|
|
|
|
<!-- Options (collapsed by default) -->
|
|
<details id="external-advanced-options">
|
|
<summary style="cursor: pointer; color: var(--accent); font-size: 0.8rem; user-select: none;">Options</summary>
|
|
<div style="margin-top: 10px; display: grid; gap: 10px; font-size: 0.8rem;">
|
|
|
|
<div class="grid-2col">
|
|
<div>
|
|
<label for="external-service-subdomain">Subdomain:</label>
|
|
<input type="text" id="external-service-subdomain" placeholder="auto-derived from name" />
|
|
<span id="external-domain-preview" style="font-size: 0.7rem; color: var(--accent);"></span>
|
|
</div>
|
|
<div>
|
|
<label for="external-service-logo">Logo URL:</label>
|
|
<input type="text" id="external-service-logo" placeholder="/assets/name.png" />
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label for="external-service-icon">Icon Emoji:</label>
|
|
<input type="text" id="external-service-icon" placeholder="\u{1F3AC}" maxlength="2" style="width: 60px;" />
|
|
</div>
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="external-create-dns" checked />
|
|
Create DNS Record
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="external-create-caddy" checked />
|
|
Create Caddy Reverse Proxy
|
|
</label>
|
|
|
|
<div>
|
|
<label for="external-proxy-ip">Proxy Server IP:</label>
|
|
<input type="text" id="external-proxy-ip" placeholder="Auto-detected" />
|
|
</div>
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="external-preserve-host" checked />
|
|
Preserve Host Header
|
|
</label>
|
|
<label style="display: flex; align-items: center; gap: 6px; cursor: pointer;">
|
|
<input type="checkbox" id="external-follow-redirects" checked />
|
|
Follow Redirects
|
|
</label>
|
|
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weather-modal-buttons" style="margin-top: 16px; display: flex; gap: 8px; justify-content: flex-end;">
|
|
<button id="add-service-cancel">Cancel</button>
|
|
<button id="add-service-create" class="btn-accent">Create Service</button>
|
|
</div>
|
|
</div>
|
|
</div>`)})(),(function(){async function o(s){try{const h=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(s)}`);if(!h.ok)throw new Error(`Failed to load CAs: ${h.status}`);const n=await h.json();if(n.status==="success"){const w=document.getElementById("existing-ca-select");return w.innerHTML="",n.data.cas.length===0?w.innerHTML='<option value="">No CAs found in Caddyfile</option>':(w.innerHTML='<option value="">Select existing CA...</option>',n.data.cas.forEach(m=>{const r=document.createElement("option");typeof m=="object"?(r.value=m.id,r.textContent=m.displayName||m.name):(r.value=m,r.textContent=m),w.appendChild(r)})),n.data.cas}else throw new Error(n.message)}catch(h){console.error("Error loading CAs:",h);const n=document.getElementById("existing-ca-select");return n.innerHTML='<option value="">Error loading CAs</option>',[]}}function f(s){const{subdomain:h,port:n,ip:w,sslType:m,caName:r,existingCa:l,enableAuth:y,enableCors:e,customHeaders:a,upstreamPath:p,healthCheck:t,timeout:c,tailscaleOnly:v}=s;let d=`${buildDomain(h)} {
|
|
`;switch(v&&(d+=` @blocked not remote_ip 100.64.0.0/10
|
|
`,d+=` respond @blocked "Access denied. Tailscale connection required." 403
|
|
`),m){case"letsencrypt":break;case"caddy-managed":d+=` tls internal
|
|
`;break;case"existing-ca":l&&(d+=` tls {
|
|
ca ${l}
|
|
}
|
|
`);break;case"custom-ca":r&&(d+=` tls {
|
|
ca ${r}
|
|
}
|
|
`);break}if(y&&(d+=` 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+=` }
|
|
`),a)try{const k=JSON.parse(a);d+=` header {
|
|
`,Object.entries(k).forEach(([B,T])=>{d+=` ${B} "${T}"
|
|
`}),d+=` }
|
|
`}catch{console.warn("Invalid JSON in custom headers")}return t&&(d+=` health_uri ${t}
|
|
`),d+=` reverse_proxy ${w}:${n} {
|
|
`,p&&p!=="/"&&(d+=` rewrite ${p}
|
|
`),c&&c!==30&&(d+=` transport http {
|
|
`,d+=` dial_timeout ${c}s
|
|
`,d+=` response_header_timeout ${c}s
|
|
`,d+=` }
|
|
`),d+=` }
|
|
`,d+=`}
|
|
`,d}async function u(s,h,n=DC.DEFAULTS.TTL){const w=window.getToken(getPrimaryDnsId(),"admin");if(!w)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const m=buildDomain(s),r=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:m,ip:h,ttl:n,token:w,server:SITE.dnsIp})});if(!r.ok){const y=await r.text();throw new Error(`DNS API Error: ${r.status} - ${y}`)}const l=await r.json();if(!l.success)throw new Error(`DNS Error: ${l.error||"Unknown error"}`);return l}async function g(s){const h={id:s.subdomain,name:s.name,logo:s.logo||`/assets/${s.subdomain}.png`};try{const n=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(h)});if(!n.ok){const w=await n.json();throw new Error(w.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),h}catch(n){throw console.error("Failed to add service to config:",n),n}}async function i(s){const h=document.getElementById("service-subdomain-input").value.trim(),n=document.getElementById("service-ip-input").value.trim()||"localhost",w=document.getElementById("service-port-input").value.trim()||"80",m=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(h),upstream:`${n}:${w}`,config:s})}),r=await m.json();if(!m.ok||!r.success)throw new Error(r.error||`Caddy API Error: ${m.status}`);return r}window.loadExistingCAs=o,window.generateCaddyConfig=f,window.createDnsRecord=u,window.addServiceToConfig=g,window.addToCaddyfile=i})(),(function(){let o=null;function f(n){o=n;const w=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${n.name}`,document.getElementById("edit-service-name").value=n.name,document.getElementById("edit-service-url-display").textContent=n.url||buildServiceUrl(n.id),document.getElementById("edit-service-logo-preview").src=n.logo||`/assets/${n.id}.png`,document.getElementById("edit-subdomain").value=n.id,document.getElementById("edit-port").value=n.port||"",document.getElementById("edit-ip").value=n.ip||"localhost",document.getElementById("edit-tailscale-only").checked=n.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=n.logo||"",w.classList.add("show")}function u(){closeModal("service-edit-modal"),o=null}async function g(){if(!o)return;const n=document.getElementById("edit-subdomain").value.trim().toLowerCase(),w=document.getElementById("edit-service-name").value.trim(),m=document.getElementById("edit-port").value.trim(),r=document.getElementById("edit-ip").value.trim()||"localhost",l=document.getElementById("edit-tailscale-only").checked,y=document.getElementById("edit-logo-url").value.trim();if(!n){showNotification("Subdomain is required","warning");return}const e=o.id,a=[];if(n!==e&&a.push("subdomain"),w&&w!==o.name&&a.push("name"),m&&m!==String(o.port)&&a.push("port"),r!==o.ip&&a.push("ip"),l!==(o.tailscaleOnly||!1)&&a.push("tailscale"),y&&y!==o.logo&&a.push("logo"),a.length===0){u();return}const p=document.getElementById("service-edit-save");p.textContent="Saving...",p.disabled=!0;try{const c=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:e,newSubdomain:n,name:w||o.name,port:m||o.port,ip:r,tailscaleOnly:l,logo:y||void 0})})).json();if(!c.success)throw new Error(c.error||"Failed to update service");const v=window.APPS.findIndex(d=>d.id===e);v!==-1&&(window.APPS[v]={...window.APPS[v],id:n,name:w||window.APPS[v].name,port:m||window.APPS[v].port,ip:r,tailscaleOnly:l,logo:y||window.APPS[v].logo}),u(),window.buildGrid(),window.refreshAll()}catch(t){console.error("Error saving service changes:",t),showNotification(`Error saving changes: ${t.message}`,"error")}finally{p.textContent="Save Changes",p.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async n=>{const w=n.target.files[0];if(!w)return;if(!w.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const m=new FileReader;m.onload=async r=>{const l=r.target.result;if(document.getElementById("edit-service-logo-preview").src=l,document.getElementById("edit-logo-url").value=l,o)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${o.id}.png`,data:l})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},m.readAsDataURL(w)}),document.getElementById("service-edit-cancel")?.addEventListener("click",u),document.getElementById("service-edit-save")?.addEventListener("click",g),document.getElementById("service-edit-modal")?.addEventListener("click",n=>{n.target.id==="service-edit-modal"&&u()});function i(n,w,m){return new Promise(r=>{const l=document.getElementById("delete-service-modal"),y=document.getElementById("delete-modal-title"),e=document.getElementById("delete-modal-message"),a=document.getElementById("delete-modal-container-info"),p=document.getElementById("delete-modal-container-name"),t=document.getElementById("delete-modal-help"),c=document.getElementById("delete-modal-cancel"),v=document.getElementById("delete-modal-remove"),d=document.getElementById("delete-modal-delete");y.textContent=`Delete "${n}"`,w?(e.innerHTML="This service has an associated Docker container.<br>Choose how to proceed:",a.style.display="block",p.textContent=`Container ID: ${m?.slice(0,12)||"Unknown"}`,t.style.display="block",d.style.display="block"):(e.textContent="Remove this service from the dashboard?",a.style.display="none",t.style.display="none",d.style.display="none");const k=()=>{l.classList.remove("show"),c.removeEventListener("click",B),v.removeEventListener("click",T),d.removeEventListener("click",A),l.removeEventListener("click",E)},B=()=>{k(),r(null)},T=()=>{k(),r(!1)},A=()=>{k(),r(!0)},E=L=>{L.target===l&&(k(),r(null))};c.addEventListener("click",B),v.addEventListener("click",T),d.addEventListener("click",A),l.addEventListener("click",E),l.classList.add("show")})}async function s(n,w,m){const r=document.getElementById(`update-btn-${m}`),l=r?.textContent;if(confirm(`Update ${w} to the latest version?
|
|
|
|
This will:
|
|
1. Pull the latest image
|
|
2. Stop the container
|
|
3. Recreate with same settings
|
|
|
|
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/${n}/update`,{method:"POST"})).json();if(e.success){const a=window.APPS.find(p=>p.id===m);a&&e.newContainerId&&(a.containerId=e.newContainerId),r&&(r.textContent="\u2705",r.title="Updated successfully!",setTimeout(()=>{r.textContent=l,r.disabled=!1,r.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${w} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(y){console.error("Update error:",y),r&&(r.textContent="\u274C",r.title="Update failed",setTimeout(()=>{r.textContent=l,r.disabled=!1,r.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${w}: ${y.message}`,"error")}}async function h(n,w){const m=window.APPS.find(d=>d.id===n),r=m?buildDomain(m.id):null,l=m?.containerId,y=await i(w||n,l,m?.containerId);if(y===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(y&&l)try{const d=new URLSearchParams({containerId:m.containerId,subdomain:m.id,ip:m.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(m.id)}?${d.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(d){console.error("App removal error:",d)}else if(y&&r){try{const d=m?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(r)}&type=A&ipAddress=${encodeURIComponent(d)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(d){e.dns=d.message}try{const k=await(await secureFetch(`/api/v1/site/${encodeURIComponent(r)}`,{method:"DELETE"})).json();e.caddy=k.success||k.error&&k.error.includes("not found")?"removed":k.error||"failed"}catch(d){e.caddy=d.message}}const a=window.APPS.findIndex(d=>d.id===n);a>-1&&(window.APPS.splice(a,1),e.dashboard=!0);try{const d=safeGetJSON("custom-apps",[]),k=d.findIndex(B=>B.id===n);k>-1&&(d.splice(k,1),safeSet("custom-apps",JSON.stringify(d)))}catch{}try{const k=await(await secureFetch(`/api/v1/services/${encodeURIComponent(n)}`,{method:"DELETE"})).json();e.service=k.success?"removed":k.error||"failed"}catch(d){e.service=d.message}window.buildGrid(),window.refreshAll();let p=!1,t=[];e.dashboard||(p=!0,t.push("\u2717 Failed to remove from dashboard"));const c=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],v=d=>!d||c.some(k=>d.toLowerCase().includes(k.toLowerCase()));e.container&&!v(e.container)&&(p=!0,t.push(`\u26A0 Container: ${e.container}`)),e.dns&&!v(e.dns)&&(p=!0,t.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!v(e.caddy)&&(p=!0,t.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!v(e.service)&&(p=!0,t.push(`\u26A0 Service File: ${e.service}`)),p&&showNotification(`Error deleting "${w||n}": ${t.join(", ")}`,"error",6e3)}window.openServiceEditModal=f,window.showDeleteModal=i,window.updateContainer=s,window.deleteService=h})(),(function(){function o(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function f(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function u(){const e=document.getElementById("service-subdomain-input").value||"subdomain",a=document.getElementById("service-ip-input").value||g.lan||"localhost",p=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,t=document.getElementById("ssl-type-select").value,c=document.getElementById("ca-name-input").value||"sami-ca",v=document.getElementById("existing-ca-select").value,d=document.getElementById("enable-auth").checked,k=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,T=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,E=document.getElementById("timeout-input").value||30,L=document.getElementById("dns-preview");L&&(L.textContent=`${buildDomain(e)} \u2192 ${a}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const x={subdomain:e,port:p,ip:a,sslType:t,caName:c,existingCa:v,enableAuth:d,enableCors:k,customHeaders:B,upstreamPath:T,healthCheck:A,timeout:E},b=window.generateCaddyConfig(x),I=document.getElementById("caddy-config-preview");I&&(I.value=b)}const g={localhost:"127.0.0.1",lan:"",tailscale:""};async function i(){try{const t=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(t.ok){const c=await t.json();c.lan&&(g.lan=c.lan),c.tailscale&&(g.tailscale=c.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),a=document.getElementById("quick-ip-tailscale");e&&(g.lan?(e.dataset.ip=g.lan,e.textContent=`LAN (${g.lan})`,e.title=`LAN IP: ${g.lan}`):e.style.display="none"),a&&(g.tailscale?(a.dataset.ip=g.tailscale,a.textContent=`Tailscale (${g.tailscale})`,a.title=`Tailscale IP: ${g.tailscale}`):a.style.display="none");const p=document.getElementById("service-ip-input");p&&!p.value&&g.lan&&(p.value=g.lan)}function s(){document.querySelectorAll(".quick-ip-btn").forEach(e=>{e.addEventListener("click",()=>{const a=e.dataset.ip;a&&(document.getElementById("service-ip-input").value=a,document.querySelectorAll(".quick-ip-btn").forEach(p=>p.classList.remove("active")),e.classList.add("active"),u())})}),document.getElementById("service-ip-input")?.addEventListener("input",e=>{const a=e.target.value;document.querySelectorAll(".quick-ip-btn").forEach(p=>{p.classList.toggle("active",p.dataset.ip===a)})})}async function h(){const e=document.getElementById("add-service-modal");e.classList.add("show");const a=e.querySelector(".weather-modal-content");a&&(a.scrollTop=0),document.body.style.overflow="hidden";const p=document.getElementById("ssl-type-select");p&&(p.value=f()),await i();const t=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(t);const c=document.getElementById("manual-tailscale-status"),v=document.getElementById("manual-tailscale-only");try{const k=await(await fetch("/api/v1/tailscale/status")).json();k.success&&k.installed&&k.connected?(c.innerHTML=`
|
|
<span style="color: #4caf50;">\u2713 Connected</span>
|
|
<span style="color: var(--muted); margin-left: 6px;">${k.self?.hostname} (${k.self?.ip})</span>
|
|
`,v.disabled=!1):k.installed?(c.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',v.disabled=!0):(c.innerHTML='<span style="color: var(--muted);">Not available</span>',v.disabled=!0)}catch{c.innerHTML='<span style="color: var(--muted);">Could not check</span>',v.disabled=!0}v.checked=!1,u()}function n(){const e=document.getElementById("service-type-local"),a=document.getElementById("service-type-external"),p=document.getElementById("local-service-config"),t=document.getElementById("external-service-config"),c=document.getElementById("tab-local"),v=document.getElementById("tab-external");function d(){e.checked?(p.style.display="grid",t.style.display="none",c&&(c.style.background="var(--accent)",c.style.color="var(--bg)"),v&&(v.style.background="transparent",v.style.color="var(--muted)")):(p.style.display="none",t.style.display="block",v&&(v.style.background="var(--accent)",v.style.color="var(--bg)"),c&&(c.style.background="transparent",c.style.color="var(--muted)"))}e?.addEventListener("change",d),a?.addEventListener("change",d)}function w(){const e=document.getElementById("service-name-input"),a=document.getElementById("service-subdomain-input"),p=document.getElementById("subdomain-preview");let t=!1;e?.addEventListener("input",()=>{const T=o(e.value);!t&&a&&(a.value=T),p&&(p.textContent=T?`\u2192 ${buildDomain(T)}`:""),u()}),a?.addEventListener("input",()=>{t=a.value!==o(e?.value||"");const T=a.value.trim()||o(e?.value||"");p&&(p.textContent=T?`\u2192 ${buildDomain(T)}`:""),u()});const c=document.getElementById("external-service-name"),v=document.getElementById("external-service-subdomain"),d=document.getElementById("external-subdomain-preview"),k=document.getElementById("external-domain-preview");let B=!1;c?.addEventListener("input",()=>{const T=o(c.value);!B&&v&&(v.value=T);const A=v?.value||T;d&&(d.textContent=A?`\u2192 ${buildDomain(A)}`:""),k&&(k.textContent=A?buildDomain(A):"")}),v?.addEventListener("input",()=>{B=v.value!==o(c?.value||"");const T=v.value.trim()||o(c?.value||"");d&&(d.textContent=T?`\u2192 ${buildDomain(T)}`:""),k&&(k.textContent=T?buildDomain(T):"")})}async function m(){const e=document.getElementById("external-service-name").value.trim(),a=document.getElementById("external-service-url").value.trim(),p=(document.getElementById("external-service-subdomain").value.trim()||o(e)).toLowerCase(),t=document.getElementById("external-service-logo").value.trim(),c=document.getElementById("external-service-icon").value.trim(),v=document.getElementById("external-create-dns").checked,d=document.getElementById("external-create-caddy").checked,k=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,T=document.getElementById("external-follow-redirects").checked;if(!e||!a){showNotification("Please fill in Name and External URL","warning");return}if(!p){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!a.startsWith("http://")&&!a.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(p);try{const E={dns:null,caddy:null,dashboard:!1};if(v)if(window.getToken(getPrimaryDnsId(),"admin"))try{const C=await(await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:A,ip:k,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();E.dns=C.success?"created":C.error||"failed"}catch(S){E.dns=S.message}else E.dns="no admin token (configure in \u{1F511} Tokens)";if(d)try{const I={subdomain:p,externalUrl:a,preserveHost:B,followRedirects:T,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(I)})).json();E.caddy=C.success?"created":C.error||"failed"}catch(I){E.caddy=I.message}const L={id:p,name:e,url:`https://${A}`,externalUrl:a,logo:t||c||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(L),E.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],x=window.APPS.filter(I=>!$.includes(I.id));safeSet("custom-services",JSON.stringify(x));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(I){console.warn("Failed to save to services.json:",I)}window.buildGrid(),window.refreshAll(),r();const b=[`External service "${e}" added!`];v&&b.push(`DNS: ${E.dns==="created"?"\u2713":"\u26A0 "+E.dns}`),d&&b.push(`Caddy: ${E.caddy==="created"?"\u2713":"\u26A0 "+E.caddy}`),b.push(`Access at: https://${A}`),showNotification(b.join(" | "),"success",6e3)}catch(E){console.error("Failed to create external service:",E),showNotification(`Failed to create external service: ${E.message}`,"error")}}function r(){closeModal("add-service-modal"),document.body.style.overflow="",document.getElementById("service-name-input").value="",document.getElementById("service-subdomain-input").value="",document.getElementById("service-port-input").value="",document.getElementById("service-ip-input").value=g.lan||"",document.getElementById("service-logo-input").value="",document.getElementById("dns-ttl-input").value=DC.DEFAULTS.TTL,document.getElementById("ssl-type-select").value=f(),document.getElementById("ca-name-input").value="",document.getElementById("enable-auth").checked=!1,document.getElementById("enable-cors").checked=!1,document.getElementById("custom-headers-input").value="",document.getElementById("upstream-path-input").value="/",document.getElementById("health-check-input").value="",document.getElementById("timeout-input").value="30";const e=document.getElementById("subdomain-preview");e&&(e.textContent="");const a=document.getElementById("external-subdomain-preview");a&&(a.textContent="");const p=document.getElementById("external-service-name");p&&(p.value="");const t=document.getElementById("external-service-subdomain");t&&(t.value="");const c=document.getElementById("external-service-url");c&&(c.value="");const v=document.getElementById("external-service-logo");v&&(v.value="");const d=document.getElementById("external-service-icon");d&&(d.value="");const k=document.getElementById("local-advanced-options");k&&k.removeAttribute("open");const B=document.getElementById("external-advanced-options");B&&B.removeAttribute("open");const T=document.getElementById("service-type-local");T&&(T.checked=!0);const A=document.getElementById("local-service-config"),E=document.getElementById("external-service-config");A&&(A.style.display="grid"),E&&(E.style.display="none");const L=document.getElementById("tab-local"),$=document.getElementById("tab-external");L&&(L.style.background="var(--accent)",L.style.color="var(--bg)"),$&&($.style.background="transparent",$.style.color="var(--muted)")}async function l(){const e=document.getElementById("service-name-input").value.trim(),a=(document.getElementById("service-subdomain-input").value.trim()||o(e)).toLowerCase(),p=document.getElementById("service-port-input").value.trim(),t=document.getElementById("service-ip-input").value.trim(),c=document.getElementById("service-logo-input").value.trim(),v=document.getElementById("create-dns-record").checked,d=parseInt(document.getElementById("dns-ttl-input").value)||DC.DEFAULTS.TTL,k=document.getElementById("manual-tailscale-only")?.checked||!1,B=document.getElementById("ssl-type-select")?.value||"caddy-managed",T=document.getElementById("ca-name-input")?.value||"",A=document.getElementById("existing-ca-select")?.value||"",E=document.getElementById("enable-auth")?.checked||!1,L=document.getElementById("enable-cors")?.checked||!1,$=document.getElementById("custom-headers-input")?.value||"",x=document.getElementById("upstream-path-input")?.value||"/",b=document.getElementById("health-check-input")?.value||"",I=document.getElementById("timeout-input")?.value||30,S=window.getToken(getPrimaryDnsId(),"admin");if(!e||!p||!t){showNotification("Please fill in Name, Port, and IP Address","warning");return}if(!a){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(v&&!S){showNotification("DNS Admin token required. Configure it in the Tokens menu first.","warning");return}const C={dns:null,caddy:null,dashboard:!1};try{if(v)try{await window.createDnsRecord(a,t,d),C.dns="created"}catch(N){throw console.error("DNS creation failed:",N),C.dns=N.message,new Error(`DNS creation failed: ${N.message}`)}else C.dns="skipped";const P=window.generateCaddyConfig({subdomain:a,port:p,ip:t,sslType:B,caName:T,existingCa:A,enableAuth:E,enableCors:L,customHeaders:$,upstreamPath:x,healthCheck:b,timeout:I,tailscaleOnly:k});try{const R=await(await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(a),upstream:`${t}:${p}`,config:P})})).json();if(R.success)C.caddy="added & reloaded";else throw console.error("Caddy configuration failed:",R.error),C.caddy=R.error||"failed",new Error(`Caddy configuration failed: ${R.error}`)}catch(N){throw console.error("Caddy API error:",N),C.caddy=N.message,new Error(`Caddy API error: ${N.message}`)}const D={name:e,subdomain:a,port:p,ip:t,logo:c||`/assets/${a}.png`,tailscaleOnly:k||!1};await window.addServiceToConfig(D),C.dashboard=!0;const O=[`DNS: ${C.dns==="created"?"\u2713":C.dns==="skipped"?"\u25CB":"\u2717"}`,`Caddy: ${C.caddy==="added & reloaded"?"\u2713":"\u2717"}`,`Dashboard: ${C.dashboard?"\u2713":"\u2717"}`];showNotification(`Service "${e}" created! ${O.join(" | ")} \u2014 ${buildServiceUrl(a)}${k?" (Tailscale)":""}`,"success",6e3),r(),window.buildGrid(),window.refreshAll()}catch(P){console.error("Error creating service:",P),showNotification(`Error creating "${e}": ${P.message}`,"error",6e3)}}document.getElementById("add-service")?.addEventListener("click",h),document.getElementById("add-service-cancel")?.addEventListener("click",r),document.getElementById("add-service-create")?.addEventListener("click",()=>{document.querySelector('input[name="service-type"]:checked')?.value==="external"?m():l()}),n(),w(),s(),document.getElementById("ssl-type-select")?.addEventListener("change",e=>{const a=document.getElementById("existing-ca-config"),p=document.getElementById("custom-ca-config");a.style.display="none",p.style.display="none",e.target.value==="existing-ca"?a.style.display="block":e.target.value==="custom-ca"&&(p.style.display="block"),u()}),document.getElementById("refresh-cas")?.addEventListener("click",async()=>{const e=document.getElementById("refresh-cas"),a=e.textContent;e.textContent="\u231B Loading...",e.disabled=!0;try{const p=document.getElementById("caddyfile-path-input").value||DC.DEFAULTS.CADDYFILE;await window.loadExistingCAs(p),e.textContent="\u2705 Refreshed"}catch(p){e.textContent="\u274C Failed",console.error("Failed to refresh CAs:",p)}setTimeout(()=>{e.textContent=a,e.disabled=!1},2e3)}),document.getElementById("create-dns-record")?.addEventListener("change",e=>{const a=document.getElementById("dns-config");a.style.display=e.target.checked?"block":"none"}),["service-subdomain-input","service-ip-input","service-port-input","ca-name-input","existing-ca-select","enable-auth","enable-cors","custom-headers-input","upstream-path-input","health-check-input","timeout-input"].forEach(e=>{const a=document.getElementById(e);a&&(a.addEventListener("input",u),a.addEventListener("change",u))});function y(){const e=safeGet("custom-services");if(e)try{JSON.parse(e).forEach(p=>{window.APPS.find(t=>t.id===p.id)||window.APPS.push(p)})}catch(a){console.warn("Failed to load custom services:",a)}}y(),window.openAddServiceModal=h,window.closeAddServiceModal=r})(),(function(){let o=null,f=1e3;const u=3e4;function g(){if(o)try{o.close()}catch{}o=new EventSource("/api/v1/events/stream"),o.addEventListener("connected",()=>{f=1e3,debug("[SSE] Connected to event stream")}),o.addEventListener("status-change",i=>{try{const s=JSON.parse(i.data);if(s.serviceId&&typeof window.setBadge=="function"){const h=s.status==="up"||s.status==="healthy";window.setBadge(s.serviceId,h,s.responseTime||null)}}catch{}}),o.addEventListener("resource-alert",i=>{try{const s=JSON.parse(i.data),h=`${s.containerName||s.containerId}: ${s.metric} at ${s.value}% (threshold: ${s.threshold}%)`;typeof showNotification=="function"&&showNotification(h,"warning")}catch{}}),o.addEventListener("auto-restart",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Container "${s.containerName}" was auto-restarted`,"info")}catch{}}),o.addEventListener("update-available",i=>{try{const s=JSON.parse(i.data),h=document.getElementById("updates-btn");if(h&&!h.querySelector(".sse-dot")){const n=document.createElement("span");n.className="sse-dot",n.style.cssText="display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--accent);margin-left:6px;vertical-align:middle;",h.appendChild(n)}typeof showNotification=="function"&&showNotification(`Update available for ${s.containerName||s.containerId}`,"info")}catch{}}),o.addEventListener("update-complete",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Update completed: ${s.containerName||s.containerId}`,"success"),typeof window.refreshAll=="function"&&window.refreshAll()}catch{}}),o.addEventListener("update-failed",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&showNotification(`Update failed: ${s.containerName||s.containerId} \u2014 ${s.error||"unknown error"}`,"error")}catch{}}),o.addEventListener("incident",i=>{try{const s=JSON.parse(i.data);typeof showNotification=="function"&&(s.type==="created"?showNotification(`Incident: ${s.message||s.serviceId}`,"error"):s.type==="resolved"&&showNotification(`Resolved: ${s.serviceId||"incident"}`,"success"))}catch{}}),o.onerror=()=>{o.close(),console.warn(`[SSE] Disconnected, reconnecting in ${f/1e3}s...`),setTimeout(g,f),f=Math.min(f*2,u)}}g(),window._sseReconnect=g})(),(function(){const o=document.getElementById("service-filter-search"),f=document.getElementById("service-filter-status"),u=document.getElementById("service-filter-count");function g(){const s=o.value.toLowerCase().trim(),h=f.value,n=document.querySelectorAll("#cards .card");let w=0;if(n.forEach(m=>{const r=m.querySelector(".name")?.textContent?.toLowerCase()||"",l=m.dataset.app?.toLowerCase()||"",y=m.dataset.status||"off";(!s||r.includes(s)||l.includes(s))&&(h==="all"||y===h)?(m.style.display="",w++):m.style.display="none"}),u){const m=n.length;u.textContent=`${w} of ${m} services`}}function i(s,h){let n;return function(...w){clearTimeout(n),n=setTimeout(()=>s.apply(this,w),h)}}o?.addEventListener("input",i(g,200)),f?.addEventListener("change",g),document.readyState==="loading"?document.addEventListener("DOMContentLoaded",()=>setTimeout(g,500)):setTimeout(g,500),window.refreshServiceFilter=g})(),(function(){const o=document.getElementById("batch-operations-btn"),f=document.getElementById("batch-action-bar"),u=document.getElementById("batch-selected-count"),g=document.getElementById("batch-start-btn"),i=document.getElementById("batch-stop-btn"),s=document.getElementById("batch-restart-btn"),h=document.getElementById("batch-cancel-btn");let n=!1,w=new Set;function m(){n=!0,w.clear(),f.style.display="",o.textContent="\u2713 Exit Batch Mode",l(),document.querySelectorAll("#cards .card[data-app]").forEach(a=>{const p=a.dataset.containerId;if(!p)return;const t=a.querySelector(".batch-checkbox");t&&t.remove();const c=document.createElement("input");c.type="checkbox",c.className="batch-checkbox",c.dataset.containerId=p,c.dataset.serviceName=a.querySelector(".name")?.textContent||p,c.style.cssText="position: absolute; top: 8px; left: 8px; z-index: 10; width: 18px; height: 18px; cursor: pointer;",c.addEventListener("change",v=>{v.stopPropagation(),c.checked?w.add(p):w.delete(p),l()}),a.style.position="relative",a.insertBefore(c,a.firstChild)})}function r(){n=!1,w.clear(),f.style.display="none",o.textContent="\u2630 Batch Operations",document.querySelectorAll(".batch-checkbox").forEach(e=>e.remove())}function l(){const e=w.size;u.textContent=`${e} selected`,g.disabled=e===0,i.disabled=e===0,s.disabled=e===0}async function y(e){if(w.size===0)return;const a=Array.from(w),p={start:"Starting",stop:"Stopping",restart:"Restarting"}[e];if(!confirm(`${p} ${a.length} container(s)? This cannot be undone.`))return;const t=[g,i,s];t.forEach(k=>{k.disabled=!0,k.textContent="..."});let c=0,v=0;const d=[];for(const k of a)try{const B=await fetch(`/api/v1/containers/${encodeURIComponent(k)}/${e}`,{method:"POST"});if(B.ok)c++;else{v++;const T=await B.json().catch(()=>({}));d.push(`${k}: ${T.error||B.statusText}`)}}catch(B){v++,d.push(`${k}: ${B.message}`)}t[0].textContent="\u25B6 Start All",t[1].textContent="\u2B1B Stop All",t[2].textContent="\u{1F504} Restart All",l(),v===0?typeof showNotification=="function"&&showNotification(`${p} completed: ${c} container(s)`,"success"):(typeof showNotification=="function"&&showNotification(`${p}: ${c} succeeded, ${v} failed`,"warning"),console.error("Batch operation errors:",d)),setTimeout(()=>{typeof refreshAll=="function"&&refreshAll()},1500)}o?.addEventListener("click",()=>{n?r():m()}),g?.addEventListener("click",()=>y("start")),i?.addEventListener("click",()=>y("stop")),s?.addEventListener("click",()=>y("restart")),h?.addEventListener("click",r)})();
|