Build frontend bundles with CSRF token support

This commit is contained in:
Krystie committed 2026-03-23 10:34:57 +01:00
1 parent 883cce27df
commit 024be9c929
4 files changed
+2532

No files matched your search

+727
View File
@@ -0,0 +1,727 @@
const DC={NAME:"DashCaddy",POLL:{DASHBOARD:1e4,LOGS:3e3,STATS:5e3,WEATHER:6e5,HEALTH:1e3,DEPLOY_SSL:5e3},DELAYS:{BTN_RESET:2e3,RELOAD:5e3,MODAL_CLOSE:500,PORT_CHECK:500,DEPLOY_INIT:3e3},DEFAULTS:{DNS_PORT:"5380",SERVICE_PORT:"8080",TTL:300,CADDYFILE:"C:\\caddy\\Caddyfile"}},_cachedCfg=JSON.parse(localStorage.getItem("dashcaddy_site_config")||"null"),SITE={tld:_cachedCfg&&_cachedCfg.tld||".home",dnsIp:"",dnsPort:DC.DEFAULTS.DNS_PORT,dnsServers:{},configurationType:_cachedCfg&&_cachedCfg.configurationType||"homelab",domain:_cachedCfg&&_cachedCfg.domain||"",defaults:_cachedCfg&&_cachedCfg.defaults||{},routingMode:_cachedCfg&&_cachedCfg.routingMode||"subdomain",onboardingCompleted:!1};window.__dashcaddySiteConfigLoaded=(async function(){try{const p=await fetch("/api/v1/config");if(p.ok){const r=await p.json();if(r.tld&&(SITE.tld=r.tld.startsWith(".")?r.tld:"."+r.tld),r.dns&&(SITE.dnsIp=r.dns.ip||"",SITE.dnsPort=r.dns.port||DC.DEFAULTS.DNS_PORT),r.dnsServers&&typeof r.dnsServers=="object")for(const[f,t]of Object.entries(r.dnsServers))f!=="__proto__"&&f!=="constructor"&&f!=="prototype"&&(SITE.dnsServers[f]=t);r.configurationType&&(SITE.configurationType=r.configurationType),r.domain&&(SITE.domain=r.domain),r.defaults&&(SITE.defaults=r.defaults),r.routingMode&&(SITE.routingMode=r.routingMode),SITE.onboardingCompleted=r.onboardingCompleted===!0,localStorage.setItem("dashcaddy_site_config",JSON.stringify({tld:SITE.tld,configurationType:SITE.configurationType,domain:SITE.domain,routingMode:SITE.routingMode})),renderDnsCards();const E=document.getElementById("manage-tokens");E&&(E.style.display=Object.keys(SITE.dnsServers).length?"":"none")}}catch{}document.querySelectorAll("[data-tld]").forEach(p=>p.textContent=SITE.tld);const i=document.getElementById("edit-tld-suffix");i&&(i.textContent=SITE.tld);const g=document.getElementById("external-proxy-ip");g&&SITE.dnsIp&&(g.value=SITE.dnsIp,g.placeholder=SITE.dnsIp)})();function buildDomain(n){return n+SITE.tld}function buildServiceUrl(n){return SITE.routingMode==="subdirectory"&&SITE.domain?"https://"+SITE.domain+"/"+n:SITE.configurationType==="public"&&SITE.domain?"https://"+n+"."+SITE.domain:"https://"+buildDomain(n)}function getDnsServerAddr(n){const i=SITE.dnsServers[n];return i?`${i.ip}:${i.port}`:buildDomain(n)}function getPrimaryDnsId(){if(!SITE.dnsIp)return null;for(const[n,i]of Object.entries(SITE.dnsServers))if(i.ip===SITE.dnsIp)return n;return null}function renderDnsCards(){const n=document.querySelector(".top");if(!n)return;const i=Object.keys(SITE.dnsServers);if(!i.length)return;const g='<svg viewBox="0 0 24 24" class="service-icon"><rect x="3" y="4" width="18" height="16" rx="2" fill="#34495e"/><rect x="5" y="6" width="14" height="2" rx="1" fill="#3498db"/><rect x="5" y="9" width="10" height="1" fill="#ecf0f1"/><rect x="5" y="11" width="12" height="1" fill="#ecf0f1"/><rect x="5" y="13" width="8" height="1" fill="#ecf0f1"/><rect x="5" y="15" width="14" height="1" fill="#ecf0f1"/><circle cx="17" cy="11" r="2" fill="#e74c3c"/><path d="M17 9v4M15 11h4" stroke="white" stroke-width="1"/></svg>',p=n.firstElementChild;i.forEach(r=>{const E=escapeHtml(r),f=escapeHtml((SITE.dnsServers[r].name||r).toUpperCase()),t=document.createElement("div");t.className="card",t.setAttribute("data-app",r),t.setAttribute("data-status","off"),t.innerHTML=`<span id="${E}-dot" class="dot bad at-bl"></span><div class="row"><div class="logo-wrap">${g}</div><span class="name">${f}</span><span class="spacer"></span><span id="${E}-pill" class="badge off">OFF</span></div><div class="response-row"><span id="${E}-time" class="response-time">--</span></div><div class="health-row" id="health-${E}"><span id="uptime-${E}" class="uptime-chip">--</span><div class="uptime-mini-bar"><div class="fill" id="uptime-bar-${E}" style="width: 0%"></div></div></div><div class="btn-row"><button id="${E}-restart" class="restart-btn">Restart</button><button id="${E}-update" class="update-btn" title="Update DNS server">\u2B06\uFE0F</button><button id="${E}-open">Open</button><button id="${E}-logs" class="logs-btn">Logs</button><button id="${E}-settings" class="settings-btn">\u2699\uFE0F</button></div>`,n.insertBefore(t,p)})}window.renderDnsCards=renderDnsCards;let csrfToken=null;async function getCSRFToken(){if(csrfToken)return csrfToken;try{const n=await fetch("/api/v1/csrf-token");if(!n.ok)throw new Error("Failed to fetch CSRF token");return csrfToken=(await n.json()).token,csrfToken}catch(n){throw console.error("Failed to get CSRF token:",n),n}}async function secureFetch(n,i={}){const g=(i.method||"GET").toUpperCase();if(!["GET","HEAD","OPTIONS"].includes(g))try{const p=await getCSRFToken();i.headers={...i.headers,"X-CSRF-Token":p}}catch(p){console.error("Failed to add CSRF token to request:",p)}return i.signal||(i={...i,signal:AbortSignal.timeout(15e3)}),fetch(n,i)}async function postJSON(n,i){const g=await secureFetch(n,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),p=awaitLine truncated
position: fixed; top: 20px; right: 20px;
background: ${E.bg}; color: ${E.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(f),g>0&&setTimeout(()=>f.remove(),g)}function timeAgo(n){const i=Date.now()-new Date(n).getTime();return i<6e4?"just now":i<36e5?Math.floor(i/6e4)+"m ago":i<864e5?Math.floor(i/36e5)+"h ago":Math.floor(i/864e5)+"d ago"}function safeGet(n,i=null){try{const g=localStorage.getItem(n);return g!==null?g:i}catch{return i}}function safeSet(n,i){try{localStorage.setItem(n,i)}catch{}}function safeRemove(n){try{localStorage.removeItem(n)}catch{}}function safeSessionGet(n,i=null){try{const g=sessionStorage.getItem(n);return g!==null?g:i}catch{return i}}function safeSessionSet(n,i){try{sessionStorage.setItem(n,i)}catch{}}function safeGetJSON(n,i=null){try{const g=localStorage.getItem(n);return g?JSON.parse(g):i}catch{return i}}function escapeHtml(n){return String(n??"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;")}function injectModal(n,i){document.getElementById(n)||document.body.insertAdjacentHTML("beforeend",i)}const DC_BUS={_handlers:{},on(n,i){var g;((g=this._handlers)[n]||(g[n]=[])).push(i)},off(n,i){this._handlers[n]=this._handlers[n]?.filter(g=>g!==i)},emit(n,i){this._handlers[n]?.forEach(g=>g(i))}},AppState={_apps:[],getApps(){return this._apps},setApps(n){this._apps=n,window.APPS=n,DC_BUS.emit("apps:changed",n)},findApp(n){return this._apps.find(i=>i.id===n)},addApp(n){this._apps.push(n),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)},removeApp(n){const i=this._apps.findIndex(g=>g.id===n);return i>-1&&(this._apps.splice(i,1),window.APPS=this._apps,DC_BUS.emit("apps:changed",this._apps)),i>-1},updateApp(n,i){const g=this._apps.find(p=>p.id===n);if(g){for(const[p,r]of Object.entries(i))p!=="__proto__"&&p!=="constructor"&&p!=="prototype"&&(g[p]=r);DC_BUS.emit("apps:changed",this._apps)}return g}};(function(){function n(){const p=document.createElement("div");return p.className="skeleton-card",p.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>',p}function i(p){const r=document.getElementById("cards");if(!(!r||r.querySelector(".card"))){p=p||6;for(let E=0;E<p;E++){const f=n();r.appendChild(f),setTimeout(function(){f.classList.add("loaded")},E*60)}}}function g(){const p=document.getElementById("cards");if(p){var r=p.querySelectorAll(".skeleton-card");r.length&&(r.forEach(function(E,f){setTimeout(function(){E.style.opacity="0",E.style.transform="translateY(-10px)"},f*25)}),setTimeout(function(){r.forEach(function(E){E.parentNode&&E.remove()})},r.length*25+300))}}window.SkeletonLoader={show:i,hide:g}})(),(function(){var n="theme",i="user-themes",g="custom-theme",p=["dark","light","blue","black","nord","dracula","solarized-dark","solarized-light","taxi","ocean"],r=p.slice(),E=["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"],f=["bg","fg","muted","card-base","card-bg","border","ok-bg","ok-fg","bad-bg","bad-fg","dot-ok","dot-bad","uptime","accent","accent-strong"],t=["fg-muted","hover","card-hover","base","success","error","warning"],m={dark:{bg:"#0b0f1a",fg:"#e8ecf5",muted:"#9aa6bf","fg-muted":"#6b7a94","card-base":"#121826","card-bg":"#121826",border:"#263552",hover:"#1a2235","card-hover":"#161e2e",base:"#151c2b","ok-bg":"#0c2430","ok-fg":"#7ef2ff","bad-bg":"#2a121a","bad-fg":"#ff9aa3","dot-ok":"#35d1ff","dot-bad":"#ff5f7a",uptime:"#35d1ff",success:"#4caf50",error:"#e74c3c",warning:"#f39c12",accent:"#8FD6FF","accent-strong":"#1F7BFF"},light:{bg:"#f6f7fb",fg:"#0f1115",muted:"#5f6b7a","fg-muted":"#8993a4","card-base":"#ffffff","card-bg":"#ffffff",border:"#e2e7ef",hover:"#eef1f6","card-hover":"#f5f6fa",base:"#ebeef3","ok-bg":"#eafff1","ok-fg":"#0a7c3a","bad-bg":"#ffefef","bad-fg":"#b00020","dot-ok":"#0fb15a","dot-bad":"#d93b3b",uptime:"#0fb15a",success:"#0a7c3a",error:"#b00020",warning:"#d68a00",accent:"#4a90d9","accent-strong":"#2563eb",lightBg:!0},blue:{bg:"#1908AC",fg:"#e8f1ff",muted:"#d6e2ff","fg-muted":"#9eafdb","card-base":"#0d1533","card-bg":"#0d1533",border:"#1c2d6a",hover:"#141f4a","card-hover":"#111a3e",base:"#0f1840","ok-bg":"#162040","ok-fg":"#edffff","bad-bg":"#0a0e24","bad-fg":"#ffb3c0","dot-ok":"#c7e5ff","dot-bad":"#ffd6dc",uptime:"#7ec8ff",success:"#7ec8ff",error:"#ffb3c0", Line truncated
background:
radial-gradient(1200px 800px at 10% -10%, rgba(`+l.r+","+l.g+","+l.b+`, .08), transparent 60%),
radial-gradient(1000px 700px at 110% 10%, rgba(`+l.r+","+l.g+","+l.b+`, .05), transparent 55%),
var(--bg);
}
`:":root."+h+` body {
background:
radial-gradient(1200px 900px at 8% -12%, rgba(`+l.r+","+l.g+","+l.b+`, .10), transparent 60%),
radial-gradient(1000px 700px at 110% -10%, rgba(`+l.r+","+l.g+","+l.b+`, .07), transparent 55%),
var(--bg);
}
`}function v(h,T){var $=T.lightBg||T.bg&&k(T.bg)>.4;return $?":root."+h+` 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."+h+` button:hover {
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
}
`}function o(){return window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light"}function u(){E.forEach(function(h){document.documentElement.style.removeProperty("--"+h)})}function w(h,T){var $=h.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/^-|-$/g,"");$||($="custom"),p.indexOf($)!==-1&&($=$+"-custom");for(var b=safeGetJSON(i,{}),l=$,S=2;b[$]&&$!==T;)$=l+"-"+S++;return $}function a(h){var T=document.getElementById("user-theme-styles");T&&T.remove(),r.length=p.length,Object.keys(m).forEach(function(x){p.indexOf(x)===-1&&delete m[x]});var $=h||safeGetJSON(i,{}),b=Object.keys($);if(b=b.filter(function(x){return p.indexOf(x)===-1}),!!b.length){var l="";b.forEach(function(x){var C=$[x];r.indexOf(x)===-1&&r.push(x);var P={};E.forEach(function(D){C[D]&&(P[D]=C[D])}),P["card-bg"]=C["card-base"]||C.bg,C.lightBg&&(P.lightBg=!0);var O=e(P);t.forEach(function(D){!P[D]&&O[D]&&(P[D]=O[D])}),m[x]=P,l+=":root."+x+` {
`,E.forEach(function(D){P[D]&&(l+=" --"+D+": "+P[D]+`;
`)}),l+=`}
`,l+=d(x,P),l+=v(x,P)});var S=document.createElement("style");S.id="user-theme-styles",S.textContent=l,document.head.appendChild(S)}}function I(){secureFetch("/api/v1/themes").then(function(h){return h.json()}).then(function(h){if(!(!h.success||!h.themes)){var T=h.themes,$=safeGetJSON(i,{});if(JSON.stringify(T)!==JSON.stringify($)){safeSet(i,JSON.stringify(T)),a(T);var b=safeGet(n);b&&r.indexOf(b)!==-1&&L(b)}}}).catch(function(){})}function B(){var h=safeGetJSON(g);if(h){var T=h.name||"Custom",$=w(T),b={name:T};E.forEach(function(x){h[x]&&(b[x]=h[x])});var l=safeGetJSON(i,{});l[$]=b,safeSet(i,JSON.stringify(l)),safeGet(n)==="custom"&&safeSet(n,$),safeRemove(g);var S={};E.forEach(function(x){b[x]&&(S[x]=b[x])}),fetch("/api/v1/themes/"+$,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:T,colors:S})}).catch(function(){})}}function L(h){document.documentElement.classList.add("theme-transitioning"),r.forEach(function(l){l!=="dark"&&document.documentElement.classList.remove(l)}),u(),h!=="dark"&&document.documentElement.classList.add(h),safeSet(n,h);var T=m[h],$=document.querySelector('meta[name="theme-color"]');$&&T&&$.setAttribute("content",T.bg);var b=T&&T.lightBg;!b&&T&&T.bg&&(b=k(T.bg)>.4),b?document.documentElement.classList.add("light-bg"):document.documentElement.classList.remove("light-bg"),setTimeout(function(){document.documentElement.classList.remove("theme-transitioning")},300)}B(),a();var A=safeGet(n);A==="red"&&(A="black",safeSet(n,"black")),A&&A!=="dark"&&r.indexOf(A)===-1&&(A=null),L(A||o()),I(),window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change",function(h){safeGet(n)||L(h.matches?"dark":"light")}),window.THEMES=r,window.BUILTIN_THEMES=p,window.THEME_COLORS=m,window.THEME_PROPS=E,window.BASE_PROPS=f,window.DERIVED_PROPS=t,window.USER_THEMES_KEY=i,window.applyTheme=L,window.clearCustomProperties=u,window.injectUserThemeStyles=a,window.syncThemesFromServer=I,window.slugifyThemeName=w,window.getActiveTheme=function(){return safeGet(n)||o()},window.deriveExtendedColors=e,window.hexToRgb=s,window.rgbToHex=c,window.blendColors=y})(),(function(){function n(){const f=document.querySelector(".totp-card");if(!f)return;const m=getComputedStyle(f).backgroundColor.match(/\d+/g);if(!m)return;const s=(.299*+m[0]+.587*+m[1]+.114*+m[2])/255,c=f.querySelector(".totp-logo-dark"),y=f.querySelector(".totp-logo-light");c&&(c.style.display=s>.5?"none":""),y&&(y.style.display=s>.5?"":"none")}function i(){const f=document.getElementById("totp-overlay");if(f){f.classList.add("show"),setTimeout(n,50);const t=f.querySelector(".totp-digits input");t&&setTimeout(()=>t.focus(),100)}}function g(){const f=document.getElementById("totp-overlay");f&&f.classList.remove("show")}const p=document.getElementById("totp-digits");if(p){const f=p.querySelectorAll("input");f.forEach((t,m)=>{t.addEventListener("input",s=>{const c=s.target.value.replace(/\D/g,"");s.target.value=c.slice(0,1),c&&m<f.length-1&&f[m+1].focus();const y=Array.from(f).map(k=>k.value).join("");y.length===6&&r(y)}),t.addEventListener("keydown",s=>{s.key==="Backspace"&&!s.target.value&&m>0&&(f[m-1].focus(),f[m-1].value="")}),t.addEventListener("paste",s=>{s.preventDefault();const c=(s.clipboardData.getData("text")||"").replace(/\D/g,"");c.length>=6&&(f.forEach((y,k)=>{y.value=c[k]||""}),f[5].focus(),r(c.slice(0,6)))})})}async function r(f){const t=document.getElementById("totp-error");t.textContent="Verifying...",t.className="totp-error verifying";try{const s=await(await secureFetch("/api/v1/totp/verify",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();if(s.success){t.textContent="",g();const c=safeSessionGet("totp_redirect");if(c){try{sessionStorage.removeItem("totp_redirect")}catch{}window.location.href=c;return}typeof window.initializeDashboard=="function"&&window.initializeDashboard()}else{t.textContent=s.error||"Invalid code",t.className="totp-error";const c=document.querySelectorAll("#totp-digits input");c.forEach(y=>{y.value=""}),c[0]?.focus()}}catch{t.textContent="Connection error",t.className="totp-error"}}const E=new URLSearchParams(window.location.search);if(E.get("auth")==="required"){const f=E.get("return");if(f)try{const t=new URL(f,window.location.origin),m=t.hostname,s=t.origin===window.location.origin,c=SITE.tld.startsWith(".")?SITE.tld:"."+SITE.tld,y=m.endsWith(c)||m===c.substring(1);(s||y)&&safeSessionSet("totp_redirect",f)}catch{}window.history.replaceState({},"",window.location.pathname)}window._showTotpOverlay=i})(),(function(){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>
<!-- 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>
<!-- Buttons -->
<div style="display: flex; gap: 8px; margin-top: 14px;">
<button id="svc-creds-save" style="flex: 1; padding: 9px; background: var(--accent, #8FD6FF); color: #0b0f1a; 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 n=document.getElementById("service-creds-modal");let i=null;const g=["sonarr","radarr","prowlarr","overseerr"];window.openServiceCredsModal=async function(r){i=r;const E=document.getElementById("svc-creds-title"),f=document.getElementById("svc-creds-desc"),t=document.getElementById("svc-creds-seedhost"),m=document.getElementById("svc-creds-apikey"),s=document.getElementById("svc-creds-basic");E.textContent=r.name+" Credentials";const c=!!r.isExternal,y=g.includes(r.id)||g.includes(r.appTemplate);t.style.display=c?"":"none",m.style.display=y?"":"none",s.style.display=c?"none":"",c?(f.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}`):y?f.textContent="API key bypasses the app login screen automatically.":f.textContent="Credentials are injected automatically when accessing this service.",await p(r),n.classList.add("show")};async function p(r){const E=document.getElementById("svc-creds-dot"),f=document.getElementById("svc-creds-status"),t=document.getElementById("svc-creds-clear");let m=!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&&(m=!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",m=!0):document.getElementById("svc-apikey-input").value="",c.hasBasicAuth&&!r.isExternal?(document.getElementById("svc-basic-user").value=c.username||"",m=!0):document.getElementById("svc-basic-user").value="")}catch{}if(document.getElementById("svc-basic-pass")&&(document.getElementById("svc-basic-pass").value=""),m){E.style.background="var(--ok-fg, #74dfc4)",f.style.color="var(--ok-fg, #74dfc4)",f.textContent="Credentials stored",t.style.display="";const s=document.getElementById(`creds-btn-${r.id}`);s&&s.classList.add("has-creds")}else E.style.background="var(--muted)",f.style.color="var(--muted)",f.textContent="No credentials stored",t.style.display="none"}document.getElementById("svc-creds-save")?.addEventListener("click",async()=>{if(!i)return;const r=document.getElementById("svc-creds-save");r.textContent="Saving...",r.disabled=!0;try{if(i.isExternal){const t=document.getElementById("svc-seedhost-user").value.trim(),m=document.getElementById("svc-seedhost-pass").value;t&&await secureFetch("/api/v1/seedhost-creds",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:m||void 0,serviceId:i.id})})}const f=document.getElementById("svc-apikey-input").value.trim();if(f&&f!=="\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"&&await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({apiKey:f})}),!i.isExternal){const t=document.getElementById("svc-basic-user").value.trim(),m=document.getElementById("svc-basic-pass").value;t&&m&&await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:t,password:m})})}await p(i)}catch(E){console.error("Failed to save credentials:",E)}r.textContent="Save",r.disabled=!1}),document.getElementById("svc-creds-clear")?.addEventListener("click",async()=>{if(i&&confirm(`Remove stored credentials for ${i.name}?`))try{i.isExternal&&await secureFetch(`/api/v1/seedhost-creds?serviceId=${i.id}`,{method:"DELETE"}),await secureFetch(`/api/v1/services/${i.id}/credentials`,{method:"DELETE"});const r=document.getElementById(`creds-btn-${i.id}`);r&&r.classList.remove("has-creds"),await p(i)}catch(r){console.error("Failed to clear credentials:",r)}}),document.getElementById("svc-creds-close")?.addEventListener("click",()=>{n.classList.remove("show"),i=null}),n?.addEventListener("click",r=>{r.target===n&&(n.classList.remove("show"),i=null)}),window.refreshCredsButtons=async function(){try{for(const r of window.APPS||[]){if(!r.isExternal&&!r.appTemplate&&!r.url)continue;let E=!1;if(r.isExternal)try{const m=await(await fetch(`/api/v1/seedhost-creds?serviceId=${r.id}`)).json();m.success&&m.hasCredentials&&(E=!0)}catch{}try{const m=await(await fetch(`/api/v1/services/${r.id}/credentials`)).json();m.success&&(m.hasApiKey||m.hasBasicAuth)&&(E=!0)}catch{}const f=document.getElementById(`creds-btn-${r.id}`);f&&f.classList.toggle("has-creds",E)}}catch{}}})(),(function(){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" style="width: 100%; padding: 12px; background: var(--accent, #8FD6FF); color: #0b0f1a; 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>
</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" style="padding: 10px 20px; background: var(--accent, #8FD6FF); color: #0b0f1a; 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 n(){try{const r=await(await fetch("/api/v1/totp/config")).json();if(!r.success)return;const{enabled:E,sessionDuration:f,isSetUp:t}=r.config,m=document.getElementById("totp-status-dot"),s=document.getElementById("totp-status-text"),c=document.getElementById("totp-status-banner"),y=document.getElementById("totp-setup-section"),k=document.getElementById("totp-qr-section"),e=document.getElementById("totp-duration-section"),d=document.getElementById("totp-disable-section");E&&t?(m.style.background="var(--ok-fg, #7ef2ff)",c.style.borderColor="var(--ok-fg, #7ef2ff)",c.style.background="color-mix(in srgb, var(--ok-fg) 8%, transparent)",s.textContent="TOTP is active",s.style.color="var(--ok-fg, #7ef2ff)",y.style.display="none",k.style.display="none",e.style.display="block",d.style.display="block",document.getElementById("totp-duration-select").value=f):(m.style.background="var(--muted)",c.style.borderColor="var(--border)",c.style.background="transparent",s.textContent="TOTP is not configured",s.style.color="var(--muted)",y.style.display="block",k.style.display="none",e.style.display="none",d.style.display="none"),g(E&&t,f)}catch(p){console.warn("Failed to load TOTP settings:",p)}}const i={"15m":"15 min","30m":"30 min","1h":"1 hour","2h":"2 hours","4h":"4 hours","8h":"8 hours","12h":"12 hours","24h":"24 hours",never:"Disabled"};function g(p,r){const E=document.getElementById("auth-card"),f=document.getElementById("auth-pill"),t=document.getElementById("auth-dot"),m=document.getElementById("auth-status-text");E&&(p?(E.setAttribute("data-status","on"),f.className="badge on",f.textContent="YES",t.className="dot ok at-bl",m.textContent="Session: "+(i[r]||r)):(E.setAttribute("data-status","off"),f.className="badge off",f.textContent="NO",t.className="dot bad at-bl",m.textContent="Not configured"))}document.getElementById("totp-setup-btn")?.addEventListener("click",async()=>{try{const r=await(await secureFetch("/api/v1/totp/setup",{method:"POST"})).json();r.success&&(document.getElementById("totp-qr-image").src=r.qrCode,document.getElementById("totp-manual-key").textContent=r.manualKey,document.getElementById("totp-setup-section").style.display="none",document.getElementById("totp-qr-section").style.display="block",document.getElementById("totp-setup-code").value="",document.getElementById("totp-setup-error").textContent="",document.getElementById("totp-setup-code").focus())}catch(p){console.error("TOTP setup failed:",p)}}),document.getElementById("totp-import-btn")?.addEventListener("click",async()=>{const p=document.getElementById("totp-import-key").value.trim();if(p)try{const E=await(await secureFetch("/api/v1/totp/setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({secret:p})})).json();E.success?(document.getElementById("totp-qr-image").src=E.qrCode,document.getElementById("totp-manual-key").textContent=E.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()):(document.getElementById("totp-import-key").style.borderColor="var(--bad-fg)",setTimeout(()=>{document.getElementById("totp-import-key").style.borderColor=""},2e3))}catch(r){console.error("TOTP import failed:",r)}}),document.getElementById("totp-copy-key")?.addEventListener("click",()=>{const p=document.getElementById("totp-manual-key").textContent;navigator.clipboard.writeText(p).then(()=>{const r=document.getElementById("totp-copy-key");r.textContent="\u2705",setTimeout(()=>{r.textContent="\u{1F4CB}"},2e3)})}),document.getElementById("totp-confirm-setup")?.addEventListener("click",async()=>{const p=document.getElementById("totp-setup-code").value,r=document.getElementById("totp-setup-error");if(!/^\d{6}$/.test(p)){r.textContent="Enter a 6-digit code";return}try{const f=await(await secureFetch("/api/v1/totp/verify-setup",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:p})})).json();f.success?(r.textContent="",n()):r.textContent=f.error||"Invalid code"}catch{r.textContent="Connection error"}}),document.getElementById("totp-setup-code")?.addEventListener("keydown",p=>{p.key==="Enter"&&document.getElementById("totp-confirm-setup")?.click()}),document.getElementById("totp-duration-select")?.addEventListener("change",async p=>{try{await secureFetch("/api/v1/totp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sessionDuration:p.target.value})}),n()}catch(r){console.error("Failed to update session duration:",r)}}),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/jsonLine truncated
<div id="token-management-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 500px; max-width: 600px;">
<h3>\u{1F511} DNS Credentials</h3>
<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 n(){return Object.keys(SITE.dnsServers||{})}function i(o){return(SITE.dnsServers||{})[o]?.name||o.toUpperCase()}function g(){const o=document.getElementById("dns-cred-sections");if(!o)return;o.innerHTML="";const u=n();if(u.length===0){o.innerHTML='<p style="color: var(--muted); text-align: center; padding: 20px;">No DNS servers configured.</p>';return}for(const w of u)o.insertAdjacentHTML("beforeend",`
<div class="token-section">
<h4 class="token-section-title">${i(w)}</h4>
<div class="token-grid">
<div class="token-field">
<label for="${w}-readonly-username">\u{1F4D6} Read-Only (Logs):</label>
<input type="text" id="${w}-readonly-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${w}-readonly-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${w}-readonly-token">\u{1F441}</button>
</div>
</div>
<div class="token-field">
<label for="${w}-admin-username">\u{1F527} Admin:</label>
<input type="text" id="${w}-admin-username" placeholder="Username" autocomplete="off" style="margin-bottom: 4px;" />
<div class="token-input-row">
<input type="password" id="${w}-admin-token" placeholder="Password" autocomplete="off" />
<button type="button" class="token-toggle" data-target="${w}-admin-token">\u{1F441}</button>
</div>
</div>
</div>
<div class="token-status" id="${w}-token-status"></div>
</div>
`)}function p(){let o=safeSessionGet("dashcaddy-encryption-key");if(o)return o;const u=safeGet("dashcaddy-encryption-key");if(u)return safeSessionSet("dashcaddy-encryption-key",u),safeRemove("dashcaddy-encryption-key"),u;const w=new Uint8Array(32);return crypto.getRandomValues(w),o=Array.from(w,a=>a.toString(16).padStart(2,"0")).join(""),safeSessionSet("dashcaddy-encryption-key",o),o}const r=p();function E(o,u){if(!o)return"";const w=crypto.getRandomValues(new Uint8Array(8)),a=Array.from(w,L=>L.toString(16).padStart(2,"0")).join(""),I=new TextEncoder().encode(u+a);let B="";for(let L=0;L<o.length;L++){const A=o.charCodeAt(L)^I[L%I.length]^w[L%w.length]^L*31+17&255;B+=String.fromCharCode(A)}return a+":"+btoa(B)}function f(o,u){if(!o)return"";try{if(o.indexOf(":")===16){const B=o.substring(0,16),L=new Uint8Array(B.match(/.{2}/g).map($=>parseInt($,16))),A=atob(o.substring(17)),h=new TextEncoder().encode(u+B);let T="";for(let $=0;$<A.length;$++){const b=A.charCodeAt($)^h[$%h.length]^L[$%L.length]^$*31+17&255;T+=String.fromCharCode(b)}return T}const a=atob(o);let I="";for(let B=0;B<a.length;B++){const L=a.charCodeAt(B)^u.charCodeAt(B%u.length);I+=String.fromCharCode(L)}return I}catch{return""}}function t(o,u,w){const a=safeGet(`${o}-${u}-${w}-enc`);return f(a,r)}function m(o,u,w,a){const I=`${o}-${u}-${w}-enc`;a?safeSet(I,E(a,r)):safeRemove(I)}function s(o,u){return t(o,u,"token")}function c(o,u){return t(o,u,"username")}function y(o,u,w){m(o,u,"token",w)}function k(o,u,w){m(o,u,"username",w)}function e(){const o={};for(const u of n())o[u]={readonly:{username:c(u,"readonly"),token:s(u,"readonly")},admin:{username:c(u,"admin"),token:s(u,"admin")}};return o}function d(){n().forEach(o=>{["readonly","admin"].forEach(u=>{["token","username"].forEach(w=>{safeRemove(`${o}-${u}-${w}-enc`)})}),safeRemove(`${o}-token-enc`),safeRemove(`${o}-username-enc`)})}function v(o){const u=s(o,"readonly"),w=c(o,"readonly"),a=s(o,"admin"),I=c(o,"admin"),B=f(safeGet(`${o}-token-enc`),r),L=f(safeGet(`${o}-username-enc`),r);return{username:I||w||L,token:a||u||B,readonlyToken:u||B,readonlyUsername:w||L,adminToken:a||B,adminUsername:I||L}}document.getElementById("manage-tokens")?.addEventListener("click",()=>{g();const o=document.getElementById("token-management-modal"),u=e();n().forEach(w=>{const a=u[w];document.getElementById(`${w}-readonly-username`).value=a.readonly.username,document.getElementById(`${w}-readonly-token`).value=a.readonly.token,document.getElementById(`${w}-admin-username`).value=a.admin.username,document.getElementById(`${w}-admin-token`).value=a.admin.token,document.getElementById(`${w}-token-status`).textContent=""}),o.classList.add("show")}),document.getElementById("token-management-modal")?.addEventListener("click",o=>{const u=o.target.closest(".token-toggle");if(u){const w=u.dataset.target,a=document.getElementById(w);a.type==="password"?(a.type="text",u.textContent="\u{1F648}"):(a.type="password",u.textContent="\u{1F441}");return}o.target.id==="token-management-modal"&&o.target.classList.remove("show")}),document.getElementById("token-save")?.addEventListener("click",async()=>{const o=n();o.forEach(a=>{k(a,"readonly",document.getElementById(`${a}-readonly-username`).value.trim()),y(a,"readonly",document.getElementById(`${a}-readonly-token`).value.trim()),k(a,"admin",document.getElementById(`${a}-admin-username`).value.trim()),y(a,"admin",document.getElementById(`${a}-admin-token`).value.trim())});const u={};let w=!1;if(o.forEach(a=>{const I={},B=document.getElementById(`${a}-readonly-username`).value.trim(),L=document.getElementById(`${a}-readonly-token`).value.trim(),A=document.getElementById(`${a}-admin-username`).value.trim(),h=document.getElementById(`${a}-admin-token`).value.trim();B&&L&&(I.readonly={username:B,password:L},w=!0),A&&h&&(I.admin={username:A,password:h},w=!0),Object.keys(I).length>0&&(u[a]=I)}),w){o.forEach(a=>{u[a]&&(document.getElementById(`${a}-token-status`).textContent="Verifying...",document.getElementById(`${a}-token-status`).className="token-status")});try{const I=await(await secureFetch("/api/v1/dns/credentials",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({servers:u})})).json();I.results?o.forEach(B=>{const L=document.getElementById(`${B}-token-status`);if(!u[B]){L.textContent="";return}const A=I.results[B];A?.success?(L.textContent="\u2713 Verified & saved",L.className="token-status success"):A?.partial?(L.textContent="\u2713 "+A.partial,L.className="token-status success"):(L.textContent="\u2717 "+(A?.error||"Login failed"),L.className="token-status error")}):I.success?o.forEach(B=>{u[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2713 Saved",document.getElementById(`${B}-token-status`).className="token-status success")}):o.forEach(B=>{u[B]&&(document.getElementById(`${B}-token-status`).textContent="\u2717 "+(I.error||"Failed"),document.getElementById(`${B}-token-status`).className="token-status error")})}catch(a){console.erro Line truncated
Current: ${k.currentVersion}
New: ${k.updateVersion}
`+(k.updateTitle?`${k.updateTitle}
`:"")+`The DNS server will restart during the update.
Proceed?`)){s.textContent=c,s.disabled=!1,s.title="Update DNS server";return}s.textContent="\u{1F504}",s.title="Updating...";const v=await(await secureFetch(`/api/v1/dns/update?server=${encodeURIComponent(m)}`,{method:"POST"})).json();if(!v.success)throw new Error(v.error||"Update failed");if(v.manualUpdateRequired){s.textContent="\u2B06\uFE0F",s.title=`Update available: ${v.newVersion}`;const o=v.downloadLink?`
Download: ${v.downloadLink}`:"",u=v.instructionsLink?`
Instructions: ${v.instructionsLink}`:"";showNotification(`${t.toUpperCase()} update requires manual installation. Current: ${v.previousVersion} \u2192 ${v.newVersion}. Please update manually on the host machine.`,"warning",8e3),s.disabled=!1;return}s.textContent="\u2705",s.title="Updated successfully!",showNotification(`${t.toUpperCase()} updated successfully! ${v.previousVersion} \u2192 ${v.newVersion}. Server is restarting...`,"success"),setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update DNS server",window.refreshAll()},1e4)}catch(y){console.error("DNS update error:",y),s.textContent="\u274C",s.title="Update failed",showNotification(`Failed to update ${t.toUpperCase()}: ${y.message}`,"error"),setTimeout(()=>{s.textContent=c,s.disabled=!1,s.title="Update DNS server"},3e3)}}document.querySelector(".top")?.addEventListener("click",t=>{const m=t.target.closest('[id$="-update"]');if(!m)return;const s=m.id.replace("-update","");SITE.dnsServers[s]&&i(s,SITE.dnsServers[s]?.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 g=null;function p(t){g=t;const m=SITE.dnsServers[t]||{},s=document.getElementById("dns-settings-modal");document.getElementById("dns-settings-title").textContent=`${(m.name||t).toUpperCase()} Settings`,document.getElementById("dns-edit-ip").value=m.ip||"",document.getElementById("dns-edit-port").value=m.port||DC.DEFAULTS.DNS_PORT,document.getElementById("dns-edit-name").value=m.name||"",s.classList.add("show")}async function r(){if(!g)return;const t=document.getElementById("dns-edit-ip").value.trim(),m=document.getElementById("dns-edit-port").value.trim()||DC.DEFAULTS.DNS_PORT,s=document.getElementById("dns-edit-name").value.trim();if(!t){showNotification("Server IP is required","warning");return}const c={dnsServers:{}};c.dnsServers[g]={ip:t,port:String(m)},s&&(c.dnsServers[g].name=s);try{const k=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(c)})).json();k.success?(SITE.dnsServers[g]=c.dnsServers[g],showNotification(`${g.toUpperCase()} settings saved`,"success"),f(),window.refreshAll()):showNotification(k.error||"Failed to save settings","error")}catch(y){showNotification("Failed to save: "+y.message,"error")}}async function E(){if(g&&confirm(`Remove ${g.toUpperCase()} from dashboard? This won't affect the actual DNS server.`))try{const m=await(await secureFetch("/api/v1/config")).json();m.dnsServers&&delete m.dnsServers[g];const c=await(await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dnsServers:m.dnsServers||{}})})).json();if(c.success){delete SITE.dnsServers[g];const y=document.querySelector(`.top [data-app="${g}"]`);y&&y.remove(),showNotification(`${g.toUpperCase()} removed from dashboard`,"success"),f()}else showNotification(c.error||"Failed to remove","error")}catch(t){showNotification("Failed to remove: "+t.message,"error")}}function f(){closeModal("dns-settings-modal"),g=null}document.getElementById("dns-settings-cancel")?.addEventListener("click",f),document.getElementById("dns-settings-save")?.addEventListener("click",r),document.getElementById("dns-settings-delete")?.addEventListener("click",E),document.getElementById("dns-settings-modal")?.addEventListener("click",t=>{t.target.id==="dns-settings-modal"&&f()}),document.querySelector(".top")?.addEventListener("click",t=>{const m=t.target.closest('[id$="-settings"]');if(!m)return;const s=m.id.replace("-settings","");SITE.dnsServers[s]&&(t.stopPropagation(),p(s))}),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 n=null,i=null,g=!1,p=null,r=null,E=!1,f=null,t=null,m=!1,s=null,c=!1;async function y(b,l=25){try{const S=getDnsServerAddr(b),x=await fetch(`/api/v1/dns/logs?server=${S}&limit=${l}`,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.json();return C.success&&C.logs?{logs:C.logs,count:C.count,server:C.server}:{error:C.error||"Failed to fetch logs"}}else return x.status===401?{error:"DNS auto-auth failed - check credentials in settings"}:{error:`HTTP ${x.status}`}}catch(S){return console.error("DNS logs fetch failed:",S),{error:S.message}}}function k(b){return{NoError:"var(--ok-fg)",NOERROR:"var(--ok-fg)",NxDomain:"var(--muted)",NXDOMAIN:"var(--muted)",Refused:"var(--bad-fg)",REFUSED:"var(--bad-fg)",ServerFailure:"#f39c12",SERVFAIL:"#f39c12"}[b]||"var(--fg)"}function e(b){const l=document.createElement("div");if(l.className="log-entry",l.style.cssText="display: grid; grid-template-columns: 140px 110px 1fr 70px 80px; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: center;",b.parsed===!1)return l.style.gridTemplateColumns="1fr",l.innerHTML=`<span style="color: var(--muted); font-family: monospace; font-size: 0.75rem;">${escapeHtml(b.raw)}</span>`,l;const S=k(b.rcode),x=b.rcode==="Refused"||b.rcode==="REFUSED";return l.innerHTML=`
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.timestamp)}</span>
<span style="color: var(--accent); font-size: 0.75rem;" title="${escapeHtml(b.client)}">${escapeHtml(b.client)}</span>
<span style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; ${x?"text-decoration: line-through; opacity: 0.6;":""}" title="${escapeHtml(b.domain)}">${escapeHtml(b.domain)}</span>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(b.type)}</span>
<span style="color: ${S}; font-weight: 500; font-size: 0.75rem;">${escapeHtml(b.rcode)}</span>
`,l}async function d(){if(m){await T();return}if(E){await B();return}if(g||!n)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await y(n,b);if(S.error){l.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(S.error)}</div>
</div>`;return}l.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>`,S.logs&&S.logs.length>0?S.logs.forEach(x=>{const C=e(x);l.appendChild(C)}):l.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No DNS queries logged yet
</div>`}catch(S){l.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function v(b){n=b,g=!1,E=!1;const l=document.getElementById("logs-modal"),S=document.getElementById("logs-title"),x=document.getElementById("logs-pause"),C=document.getElementById("logs-stream");S.textContent=`${b.toUpperCase()} DNS Logs`,x.textContent="\u23F8\uFE0F Pause",x.classList.remove("paused"),C&&(C.style.display="none"),l.classList.add("show"),d(),i=setInterval(d,DC.POLL.LOGS)}function o(){document.getElementById("logs-modal").classList.remove("show"),i&&(clearInterval(i),i=null),w(),n=null,E=!1,p=null,r=null,m=!1,f=null,t=null,g=!1}function u(b){s&&w();const l=document.getElementById("logs-stream"),S=document.getElementById("logs-pause"),x=document.getElementById("logs-content");i&&(clearInterval(i),i=null);try{s=new EventSource(`/api/v1/logs/stream/${b}`),c=!0,l.classList.add("active"),l.textContent="\u{1F534} Live",l.title="Streaming - click to stop",S.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}")),s.onmessage=P=>{try{const O=JSON.parse(P.data);if(O.error){console.error("Stream error:",O.error),w();return}const D=document.createElement("div");D.className="log-entry",D.style.cssText="display: flex; gap: 12px; padding: 6px 10px; border-bottom: 1px solid var(--border); font-size: 0.8rem; align-items: flex-start; font-family: monospace;";const R=(O.stream||"stdout")==="stderr",U=R?"var(--bad-fg)":"var(--fg)",F=`<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(D.innerHTML=`
<div style="flex-shrink: 0;">${F}</div>
<div style="flex: 1; color: ${U}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(O.text)}</div>
`,x.appendChild(D),x.scrollTop=x.scrollHeight;x.children.length>500;)x.removeChild(x.firstChild)}catch(O){console.error("Error parsing stream data:",O)}},s.onerror=P=>{console.error("EventSource error:",P),w()}}catch(C){console.error("Failed to start streaming:",C),w()}}function w(){s&&(s.close(),s=null),c=!1;const b=document.getElementById("logs-stream"),l=document.getElementById("logs-pause"),S=document.getElementById("logs-title");b&&(b.classList.remove("active"),b.textContent="\u{1F4E1} Live",b.title="Enable real-time streaming"),l&&(l.style.display=""),S&&(S.textContent=S.textContent.replace(" \u{1F534}","")),E&&p&&!i&&(i=setInterval(B,DC.POLL.LOGS))}async function a(b,l=100){try{const S=`/api/v1/logs/container/${b}?tail=${l}&timestamps=true`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.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 ${x.status}: ${x.statusText}`}}catch(S){return console.error("Container logs fetch failed:",S),{error:S.message}}}function I(b){const l=document.createElement("div");l.className="log-entry",l.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 S=b.stream==="stderr"?"var(--bad-fg)":"var(--fg)",x=b.stream==="stderr"?'<span style="background: var(--bad-bg); color: var(--bad-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDERR</span>':'<span style="background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px; font-size: 0.7rem; font-weight: 600;">STDOUT</span>';return l.innerHTML=`
<div style="flex-shrink: 0;">${x}</div>
<div style="flex: 1; color: ${S}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(b.text)}</div>
`,l}async function B(){if(g||!p||!E)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await a(p,b);if(S.error){l.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(S.error)}</div>
</div>`;return}l.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>`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=I(x);l.appendChild(C)}),l.scrollTop=l.scrollHeight):l.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available for this container
</div>`}catch(S){l.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function L(b,l){p=b,r=l,E=!0,m=!1,g=!1,w();const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${l} - Container Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display=""),S.classList.add("show"),B(),i=setInterval(B,DC.POLL.LOGS)}async function A(b,l=100){try{const S=`/api/v1/logs/file?path=${encodeURIComponent(b)}&tail=${l}`,x=await fetch(S,{cache:"no-store",headers:{Accept:"application/json","Cache-Control":"no-cache"}});if(x.ok){const C=await x.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 x.json().catch(()=>({}))).error||`HTTP ${x.status}`}}catch(S){return console.error("File logs fetch failed:",S),{error:S.message}}}function h(b){const l=document.createElement("div");l.className="log-entry",l.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 S=b.text;let x="INFO",C="var(--fg)";S.match(/ERROR|FATAL|CRITICAL/i)?(x="ERROR",C="var(--bad-fg)"):S.match(/WARN|WARNING/i)?(x="WARN",C="#f39c12"):S.match(/DEBUG/i)&&(x="DEBUG",C="var(--muted)");const O=`<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;">${x}</span>`;return l.innerHTML=`
<div style="flex-shrink: 0;">${O}</div>
<div style="flex: 1; color: ${C}; word-break: break-all; white-space: pre-wrap;">${escapeHtml(S)}</div>
`,l}async function T(){if(g||!f||!m)return;const b=parseInt(document.getElementById("log-lines").value),l=document.getElementById("logs-content");try{const S=await A(f,b);if(S.error){l.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(S.error)}</div>
</div>`;return}l.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 (${S.count} of ${S.totalLines} lines)</span>
</div>`,S.logs&&S.logs.length>0?(S.logs.forEach(x=>{const C=h(x);l.appendChild(C)}),l.scrollTop=l.scrollHeight):l.innerHTML+=`
<div style="padding: 20px; text-align: center; color: var(--muted);">
No logs available in this file
</div>`}catch(S){l.innerHTML=`
<div style="padding: 20px; text-align: center; color: var(--bad-fg);">
Failed to fetch logs: ${escapeHtml(S.message)}
</div>`}}function $(b,l){f=b,t=l,m=!0,E=!1,g=!1;const S=document.getElementById("logs-modal"),x=document.getElementById("logs-title"),C=document.getElementById("logs-pause"),P=document.getElementById("logs-stream");x.textContent=`\u{1F4CB} ${l} - Application Logs`,C.textContent="\u23F8\uFE0F Pause",C.classList.remove("paused"),P&&(P.style.display="none"),S.classList.add("show"),T(),i=setInterval(T,DC.POLL.LOGS)}document.querySelector(".top")?.addEventListener("click",b=>{const l=b.target.closest('[id$="-logs"]');if(!l)return;const S=l.id.replace("-logs","");SITE.dnsServers[S]&&v(S)}),document.getElementById("logs-close")?.addEventListener("click",o),document.getElementById("logs-pause")?.addEventListener("click",()=>{g=!g;const b=document.getElementById("logs-pause");g?(b.textContent="\u25B6\uFE0F Resume",b.classList.add("paused")):(b.textContent="\u23F8\uFE0F Pause",b.classList.remove("paused"),d())}),document.getElementById("log-lines")?.addEventListener("change",()=>{g||d()}),document.getElementById("logs-stream")?.addEventListener("click",()=>{!E||!p||(c?w():u(p))}),document.getElementById("logs-modal")?.addEventListener("click",b=>{b.target.id==="logs-modal"&&o()}),document.addEventListener("keydown",b=>{b.key==="Escape"&&document.getElementById("logs-modal")?.classList.contains("show")&&o()}),window.openContainerLogsModal=L,window.openFileLogsModal=$,window.openLogsModal=v})(),(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>
<div id="edit-service-name-display" style="font-weight: 600; font-size: 1.1rem;"></div>
<div id="edit-service-url-display" class="text-muted-sm"></div>
</div>
</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 n(E){try{const f=await fetch(`/api/v1/caddy/cas?caddyfilePath=${encodeURIComponent(E)}`);if(!f.ok)throw new Error(`Failed to load CAs: ${f.status}`);const t=await f.json();if(t.status==="success"){const m=document.getElementById("existing-ca-select");return m.innerHTML="",t.data.cas.length===0?m.innerHTML='<option value="">No CAs found in Caddyfile</option>':(m.innerHTML='<option value="">Select existing CA...</option>',t.data.cas.forEach(s=>{const c=document.createElement("option");typeof s=="object"?(c.value=s.id,c.textContent=s.displayName||s.name):(c.value=s,c.textContent=s),m.appendChild(c)})),t.data.cas}else throw new Error(t.message)}catch(f){console.error("Error loading CAs:",f);const t=document.getElementById("existing-ca-select");return t.innerHTML='<option value="">Error loading CAs</option>',[]}}function i(E){const{subdomain:f,port:t,ip:m,sslType:s,caName:c,existingCa:y,enableAuth:k,enableCors:e,customHeaders:d,upstreamPath:v,healthCheck:o,timeout:u,tailscaleOnly:w}=E;let a=`${buildDomain(f)} {
`;switch(w&&(a+=` @blocked not remote_ip 100.64.0.0/10
`,a+=` respond @blocked "Access denied. Tailscale connection required." 403
`),s){case"letsencrypt":break;case"caddy-managed":a+=` tls internal
`;break;case"existing-ca":y&&(a+=` tls {
ca ${y}
}
`);break;case"custom-ca":c&&(a+=` tls {
ca ${c}
}
`);break}if(k&&(a+=` basicauth {
admin $2a$14$hashed_password_here
}
`),e&&(a+=` header {
`,a+=` Access-Control-Allow-Origin "*"
`,a+=` Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS"
`,a+=` Access-Control-Allow-Headers "Content-Type, Authorization"
`,a+=` }
`),d)try{const I=JSON.parse(d);a+=` header {
`,Object.entries(I).forEach(([B,L])=>{a+=` ${B} "${L}"
`}),a+=` }
`}catch{console.warn("Invalid JSON in custom headers")}return o&&(a+=` health_uri ${o}
`),a+=` reverse_proxy ${m}:${t} {
`,v&&v!=="/"&&(a+=` rewrite ${v}
`),u&&u!==30&&(a+=` transport http {
`,a+=` dial_timeout ${u}s
`,a+=` response_header_timeout ${u}s
`,a+=` }
`),a+=` }
`,a+=`}
`,a}async function g(E,f,t=DC.DEFAULTS.TTL){const m=window.getToken(getPrimaryDnsId(),"admin");if(!m)throw new Error("DNS admin token not configured. Please set it in the Tokens menu.");const s=buildDomain(E),c=await secureFetch("/api/v1/dns/record",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:s,ip:f,ttl:t,token:m,server:SITE.dnsIp})});if(!c.ok){const k=await c.text();throw new Error(`DNS API Error: ${c.status} - ${k}`)}const y=await c.json();if(!y.success)throw new Error(`DNS Error: ${y.error||"Unknown error"}`);return y}async function p(E){const f={id:E.subdomain,name:E.name,logo:E.logo||`/assets/${E.subdomain}.png`};try{const t=await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(f)});if(!t.ok){const m=await t.json();throw new Error(m.error||"Failed to save service")}return await window.loadServices(),window.buildGrid(),f}catch(t){throw console.error("Failed to add service to config:",t),t}}async function r(E){const f=document.getElementById("service-subdomain-input").value.trim(),t=document.getElementById("service-ip-input").value.trim()||"localhost",m=document.getElementById("service-port-input").value.trim()||"80",s=await secureFetch("/api/v1/site",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({domain:buildDomain(f),upstream:`${t}:${m}`,config:E})}),c=await s.json();if(!s.ok||!c.success)throw new Error(c.error||`Caddy API Error: ${s.status}`);return c}window.loadExistingCAs=n,window.generateCaddyConfig=i,window.createDnsRecord=g,window.addServiceToConfig=p,window.addToCaddyfile=r})(),(function(){let n=null;function i(t){n=t;const m=document.getElementById("service-edit-modal");document.getElementById("service-edit-title").textContent=`Edit ${t.name}`,document.getElementById("edit-service-name-display").textContent=t.name,document.getElementById("edit-service-url-display").textContent=t.url||buildServiceUrl(t.id),document.getElementById("edit-service-logo-preview").src=t.logo||`/assets/${t.id}.png`,document.getElementById("edit-subdomain").value=t.id,document.getElementById("edit-port").value=t.port||"",document.getElementById("edit-ip").value=t.ip||"localhost",document.getElementById("edit-tailscale-only").checked=t.tailscaleOnly||!1,document.getElementById("edit-logo-url").value=t.logo||"",m.classList.add("show")}function g(){closeModal("service-edit-modal"),n=null}async function p(){if(!n)return;const t=document.getElementById("edit-subdomain").value.trim().toLowerCase(),m=document.getElementById("edit-port").value.trim(),s=document.getElementById("edit-ip").value.trim()||"localhost",c=document.getElementById("edit-tailscale-only").checked,y=document.getElementById("edit-logo-url").value.trim();if(!t){showNotification("Subdomain is required","warning");return}const k=n.id,e=[];if(t!==k&&e.push("subdomain"),m&&m!==String(n.port)&&e.push("port"),s!==n.ip&&e.push("ip"),c!==(n.tailscaleOnly||!1)&&e.push("tailscale"),y!==n.logo&&e.push("logo"),e.length===0){g();return}const d=document.getElementById("service-edit-save");d.textContent="Saving...",d.disabled=!0;try{if(e.includes("subdomain")||e.includes("port")||e.includes("ip")||e.includes("tailscale")){const u=await(await secureFetch("/api/v1/services/update",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({oldSubdomain:k,newSubdomain:t,port:m||n.port,ip:s,tailscaleOnly:c})})).json();if(!u.success)throw new Error(u.error||"Failed to update service")}const v=window.APPS.findIndex(o=>o.id===k);v!==-1&&(window.APPS[v]={...window.APPS[v],id:t,port:m||window.APPS[v].port,ip:s,tailscaleOnly:c,logo:y||window.APPS[v].logo}),await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:t,name:n.name,port:m||n.port,ip:s,logo:y||n.logo,tailscaleOnly:c,containerId:n.containerId,appTemplate:n.appTemplate})}),t!==k&&await secureFetch(`/api/v1/services/${k}`,{method:"DELETE"}),g(),window.buildGrid(),window.refreshAll()}catch(v){console.error("Error saving service changes:",v),showNotification(`Error saving changes: ${v.message}`,"error")}finally{d.textContent="Save Changes",d.disabled=!1}}document.getElementById("edit-logo-file")?.addEventListener("change",async t=>{const m=t.target.files[0];if(!m)return;if(!m.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const s=new FileReader;s.onload=async c=>{const y=c.target.result;if(document.getElementById("edit-service-logo-preview").src=y,document.getElementById("edit-logo-url").value=y,n)try{const e=await(await secureFetch("/api/v1/assets/upload",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({filename:`${n.id}.png`,data:y})})).json();e.success&&e.path&&(document.getElementById("edit-logo-url").value=e.path)}catch{}},s.readAsDataURL(m)}),document.getElementById("service-edit-cancel")?.addEventListener("click",g),document.getE Line truncated
This will:
1. Pull the latest image
2. Stop the container
3. Recreate with same settings
The service will be briefly unavailable.`))try{c&&(c.textContent="\u{1F504}",c.disabled=!0,c.title="Updating...");const e=await(await secureFetch(`/api/v1/containers/${t}/update`,{method:"POST"})).json();if(e.success){const d=window.APPS.find(v=>v.id===s);d&&e.newContainerId&&(d.containerId=e.newContainerId),c&&(c.textContent="\u2705",c.title="Updated successfully!",setTimeout(()=>{c.textContent=y,c.disabled=!1,c.title="Update container to latest version"},3e3)),setTimeout(()=>window.refreshAll(),2e3),showNotification(`${m} updated successfully!`,"success")}else throw new Error(e.error||"Update failed")}catch(k){console.error("Update error:",k),c&&(c.textContent="\u274C",c.title="Update failed",setTimeout(()=>{c.textContent=y,c.disabled=!1,c.title="Update container to latest version"},3e3)),showNotification(`Failed to update ${m}: ${k.message}`,"error")}}async function f(t,m){const s=window.APPS.find(a=>a.id===t),c=s?buildDomain(s.id):null,y=s?.containerId,k=await r(m||t,y,s?.containerId);if(k===null)return;let e={dashboard:!1,container:null,dns:null,caddy:null,service:null};if(k&&y)try{const a=new URLSearchParams({containerId:s.containerId,subdomain:s.id,ip:s.ip||"localhost",deleteContainer:"true"}),B=await(await secureFetch(`/api/v1/apps/${encodeURIComponent(s.id)}?${a.toString()}`,{method:"DELETE"})).json();B.success?e={...e,...B.results,dashboard:!1}:console.error("App removal failed:",B.error)}catch(a){console.error("App removal error:",a)}else if(k&&c){try{const a=s?.ip||"localhost",B=await(await secureFetch(`/api/v1/dns/record?domain=${encodeURIComponent(c)}&type=A&ipAddress=${encodeURIComponent(a)}&server=${SITE.dnsIp}`,{method:"DELETE"})).json();e.dns=B.success?"deleted":B.error||"failed"}catch(a){e.dns=a.message}try{const I=await(await secureFetch(`/api/v1/site/${encodeURIComponent(c)}`,{method:"DELETE"})).json();e.caddy=I.success||I.error&&I.error.includes("not found")?"removed":I.error||"failed"}catch(a){e.caddy=a.message}}const d=window.APPS.findIndex(a=>a.id===t);d>-1&&(window.APPS.splice(d,1),e.dashboard=!0);try{const a=safeGetJSON("custom-apps",[]),I=a.findIndex(B=>B.id===t);I>-1&&(a.splice(I,1),safeSet("custom-apps",JSON.stringify(a)))}catch{}try{const I=await(await secureFetch(`/api/v1/services/${encodeURIComponent(t)}`,{method:"DELETE"})).json();e.service=I.success?"removed":I.error||"failed"}catch(a){e.service=a.message}window.buildGrid(),window.refreshAll();let v=!1,o=[];e.dashboard||(v=!0,o.push("\u2717 Failed to remove from dashboard"));const u=["removed","already removed","not found","deleted","kept (user choice)","skipped","no such record","does not exist"],w=a=>!a||u.some(I=>a.toLowerCase().includes(I.toLowerCase()));e.container&&!w(e.container)&&(v=!0,o.push(`\u26A0 Container: ${e.container}`)),e.dns&&!w(e.dns)&&(v=!0,o.push(`\u26A0 DNS Record: ${e.dns}`)),e.caddy&&!w(e.caddy)&&(v=!0,o.push(`\u26A0 Caddy Config: ${e.caddy}`)),e.service&&!w(e.service)&&(v=!0,o.push(`\u26A0 Service File: ${e.service}`)),v&&showNotification(`Error deleting "${m||t}": ${o.join(", ")}`,"error",6e3)}window.openServiceEditModal=i,window.showDeleteModal=r,window.updateContainer=E,window.deleteService=f})(),(function(){function n(e){return e.toLowerCase().replace(/\s+/g,"-").replace(/[^a-z0-9-]/g,"").replace(/-+/g,"-").replace(/^-|-$/g,"")}function i(){return SITE.defaults?.sslType||(SITE.configurationType==="public"?"letsencrypt":"caddy-managed")}function g(){const e=document.getElementById("service-subdomain-input").value||"subdomain",d=document.getElementById("service-ip-input").value||p.lan||"localhost",v=document.getElementById("service-port-input").value||DC.DEFAULTS.SERVICE_PORT,o=document.getElementById("ssl-type-select").value,u=document.getElementById("ca-name-input").value||"sami-ca",w=document.getElementById("existing-ca-select").value,a=document.getElementById("enable-auth").checked,I=document.getElementById("enable-cors").checked,B=document.getElementById("custom-headers-input").value,L=document.getElementById("upstream-path-input").value||"/",A=document.getElementById("health-check-input").value,h=document.getElementById("timeout-input").value||30,T=document.getElementById("dns-preview");T&&(T.textContent=`${buildDomain(e)} \u2192 ${d}`);const $=document.getElementById("url-preview");$&&($.textContent=buildServiceUrl(e));const b={subdomain:e,port:v,ip:d,sslType:o,caName:u,existingCa:w,enableAuth:a,enableCors:I,customHeaders:B,upstreamPath:L,healthCheck:A,timeout:h},l=window.generateCaddyConfig(b),S=document.getElementById("caddy-config-preview");S&&(S.value=l)}const p={localhost:"127.0.0.1",lan:"",tailscale:""};async function r(){try{const o=await fetch("/api/v1/network/ips",{signal:AbortSignal.timeout(2e3)});if(o.ok){const u=await o.json();u.lan&&(p.lan=u.lan),u.tailscale&&(p.tailscale=u.tailscale)}}catch{}const e=document.getElementById("quick-ip-lan"),d=document.getElementById("quick-ip-tailscale");e&&(p.lan?(e.dataset.ip=p.lan,e.textContent=`LAN (${p.lan})`,e.title=`LAN IP: ${p.lan}`):e Line truncated
<span style="color: #4caf50;">\u2713 Connected</span>
<span style="color: var(--muted); margin-left: 6px;">${I.self?.hostname} (${I.self?.ip})</span>
`,w.disabled=!1):I.installed?(u.innerHTML='<span style="color: #ff9800;">\u26A0 Not connected</span>',w.disabled=!0):(u.innerHTML='<span style="color: var(--muted);">Not available</span>',w.disabled=!0)}catch{u.innerHTML='<span style="color: var(--muted);">Could not check</span>',w.disabled=!0}w.checked=!1,g()}function t(){const e=document.getElementById("service-type-local"),d=document.getElementById("service-type-external"),v=document.getElementById("local-service-config"),o=document.getElementById("external-service-config"),u=document.getElementById("tab-local"),w=document.getElementById("tab-external");function a(){e.checked?(v.style.display="grid",o.style.display="none",u&&(u.style.background="var(--accent)",u.style.color="var(--bg)"),w&&(w.style.background="transparent",w.style.color="var(--muted)")):(v.style.display="none",o.style.display="block",w&&(w.style.background="var(--accent)",w.style.color="var(--bg)"),u&&(u.style.background="transparent",u.style.color="var(--muted)"))}e?.addEventListener("change",a),d?.addEventListener("change",a)}function m(){const e=document.getElementById("service-name-input"),d=document.getElementById("service-subdomain-input"),v=document.getElementById("subdomain-preview");let o=!1;e?.addEventListener("input",()=>{const L=n(e.value);!o&&d&&(d.value=L),v&&(v.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()}),d?.addEventListener("input",()=>{o=d.value!==n(e?.value||"");const L=d.value.trim()||n(e?.value||"");v&&(v.textContent=L?`\u2192 ${buildDomain(L)}`:""),g()});const u=document.getElementById("external-service-name"),w=document.getElementById("external-service-subdomain"),a=document.getElementById("external-subdomain-preview"),I=document.getElementById("external-domain-preview");let B=!1;u?.addEventListener("input",()=>{const L=n(u.value);!B&&w&&(w.value=L);const A=w?.value||L;a&&(a.textContent=A?`\u2192 ${buildDomain(A)}`:""),I&&(I.textContent=A?buildDomain(A):"")}),w?.addEventListener("input",()=>{B=w.value!==n(u?.value||"");const L=w.value.trim()||n(u?.value||"");a&&(a.textContent=L?`\u2192 ${buildDomain(L)}`:""),I&&(I.textContent=L?buildDomain(L):"")})}async function s(){const e=document.getElementById("external-service-name").value.trim(),d=document.getElementById("external-service-url").value.trim(),v=(document.getElementById("external-service-subdomain").value.trim()||n(e)).toLowerCase(),o=document.getElementById("external-service-logo").value.trim(),u=document.getElementById("external-service-icon").value.trim(),w=document.getElementById("external-create-dns").checked,a=document.getElementById("external-create-caddy").checked,I=document.getElementById("external-proxy-ip").value.trim()||SITE.dnsIp||"localhost",B=document.getElementById("external-preserve-host").checked,L=document.getElementById("external-follow-redirects").checked;if(!e||!d){showNotification("Please fill in Name and External URL","warning");return}if(!v){showNotification("Could not derive subdomain from name. Please set one in Options.","warning");return}if(!d.startsWith("http://")&&!d.startsWith("https://")){showNotification("External URL must start with http:// or https://","warning");return}const A=buildDomain(v);try{const h={dns:null,caddy:null,dashboard:!1};if(w)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:I,ttl:DC.DEFAULTS.TTL,server:SITE.dnsIp})})).json();h.dns=C.success?"created":C.error||"failed"}catch(x){h.dns=x.message}else h.dns="no admin token (configure in \u{1F511} Tokens)";if(a)try{const S={subdomain:v,externalUrl:d,preserveHost:B,followRedirects:L,sslType:"caddy-managed",caddyfilePath:DC.DEFAULTS.CADDYFILE,reloadCaddy:!0},C=await(await secureFetch("/api/v1/site/external",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();h.caddy=C.success?"created":C.error||"failed"}catch(S){h.caddy=S.message}const T={id:v,name:e,url:`https://${A}`,externalUrl:d,logo:o||u||"\u{1F310}",isExternal:!0,isCustom:!0};window.APPS.push(T),h.dashboard=!0;const $=["plex","router","chat","sync","torrent","radarr","sonarr","prowlarr","portainer","requests","jellyfin","emby"],b=window.APPS.filter(S=>!$.includes(S.id));safeSet("custom-services",JSON.stringify(b));try{await secureFetch("/api/v1/services",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(window.APPS)})}catch(S){console.warn("Failed to save to services.json:",S)}window.buildGrid(),window.refreshAll(),c();const l=[`External service "${e}" added!`];w&&l.push(`DNS: ${h.dns==="created"?"\u2713":"\u26A0 "+h.dns}`),a&&l.push(`Caddy: ${h.caddy==="created"?"\u2713":"\u26A0 "+h.caddy}`),l.push(`Access at: https://${A}`),showNotification(l.join(" | "),"success",6e3)}catch(h){console.error("Failed to create external service:",h),showNotification(`Failed to create external service: ${h.message}`,"error")}}function c(){closeModal("a Line truncated
+1367
View File
File diff suppressed because it is too large. Load diff
+220
View File
@@ -0,0 +1,220 @@
(function(){function k(){const a=safeGet("custom-services");if(a)try{JSON.parse(a).forEach(i=>{window.APPS.find(r=>r.id===i.id)||window.APPS.push(i)})}catch(c){console.warn("Failed to load custom services:",c)}}k();function n(){const a=document.querySelectorAll(".top .card");a.forEach((c,i)=>{c.style.transitionDelay=`${Math.min(i*60,300)}ms`}),requestAnimationFrame(()=>{a.forEach(c=>c.classList.add("loaded"))})}function u(){if(!("serviceWorker"in navigator)||!window.isSecureContext&&location.hostname!=="localhost"&&location.hostname!=="127.0.0.1")return;const a=()=>{navigator.serviceWorker.register("/sw.js",{updateViaCache:"none"}).catch(c=>{console.warn("[init] Service worker registration failed:",c)})};document.readyState==="complete"?a():window.addEventListener("load",a,{once:!0})}let l=!1;async function m(){if(l){console.warn("[init] initializeDashboard called again, skipping duplicate");return}if(l=!0,await window.loadServices(),window.buildGrid(),n(),window.refreshAll(),setInterval(window.refreshAll,DC.POLL.DASHBOARD),typeof window.refreshCredsButtons=="function"&&window.refreshCredsButtons(),typeof window._updateAuthCard=="function")try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();c.success&&window._updateAuthCard(c.config.enabled&&c.config.isSetUp,c.config.sessionDuration)}catch{}if(window.__dashcaddySiteConfigLoaded)try{await window.__dashcaddySiteConfigLoaded}catch{}p(),q()&&v()}function v(){if(document.querySelector('script[src="/dist/onboarding.js"]'))return;const a=document.createElement("script");if(a.src="/dist/onboarding.js",a.defer=!0,document.head.appendChild(a),!document.querySelector('link[href="/css/driver.min.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/driver.min.css",document.head.appendChild(c)}if(!document.querySelector('link[href="/css/onboarding.css"]')){const c=document.createElement("link");c.rel="stylesheet",c.href="/css/onboarding.css",document.head.appendChild(c)}}function q(){if(typeof SITE<"u"&&SITE.onboardingCompleted)return!1;try{const a=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));return!a||!a.tourCompleted&&a.currentStep===0}catch{return!0}}function g(){const a=document.querySelectorAll(".tools-section");if(!a.length)return;let c={};try{c=JSON.parse(localStorage.getItem("toolbar-sections")||"{}")}catch{}a.forEach(i=>{const r=i.dataset.section,h=i.querySelector(".tools-section-header");h&&(c[r]&&(i.classList.add("open"),h.setAttribute("aria-expanded","true")),h.addEventListener("click",S=>{S.preventDefault();const y=i.classList.toggle("open");h.setAttribute("aria-expanded",y?"true":"false");const w={};document.querySelectorAll(".tools-section").forEach(f=>{w[f.dataset.section]=f.classList.contains("open")}),localStorage.setItem("toolbar-sections",JSON.stringify(w))}))})}g();function p(){if(document.getElementById("restart-tour-btn"))return;let a=typeof SITE<"u"&&SITE.onboardingCompleted;try{const r=JSON.parse(localStorage.getItem("dashcaddy_onboarding"));a=a||!!(r&&r.tourCompleted)}catch{}const c=a?document.querySelector('.tools-section[data-section="admin"] .tools-section-items'):document.querySelector(".tools-primary");if(!c)return;const i=document.createElement("button");i.id="restart-tour-btn",i.textContent=a?"Help Tour":"\u{1F393} Help Tour",i.title="Restart the onboarding tour",i.onclick=()=>{if(window.DashCaddyOnboarding)window.DashCaddyOnboarding.restartTour();else{v();const r=setInterval(()=>{window.DashCaddyOnboarding&&(clearInterval(r),window.DashCaddyOnboarding.restartTour())},100);setTimeout(()=>clearInterval(r),5e3)}},c.appendChild(i)}window.initializeDashboard=m,window.loadCustomServices=k,u(),(async()=>{try{const c=await(await fetch("/api/v1/totp/config",{cache:"no-store"})).json();if(c.success&&c.config.enabled&&(await fetch("/api/v1/totp/check-session",{cache:"no-store"})).status===401){window._showTotpOverlay();return}}catch(a){console.warn("TOTP check failed, proceeding normally:",a)}m()})()})(),(function(){"use strict";const k=["#app-selector-modal","#app-deploy-modal","#weather-modal","#token-management-modal","#service-edit-modal","#notifications-modal","#backup-modal","#stats-modal","#arr-setup-modal","#add-service-modal","#error-log-modal","#logs-modal","#dns-template-modal"];let n=null,u=null,l=null;function m(){try{v(),document.addEventListener("keydown",q),console.log("[Keyboard Shortcuts] Initialized"),console.log("[Keyboard Shortcuts] Press Ctrl+K to open quick search"),console.log("[Keyboard Shortcuts] Press Escape to close modals")}catch(t){console.warn("[Keyboard Shortcuts] Failed to initialize:",t.message)}}function v(){n=document.createElement("div"),n.id="quick-search-modal",n.className="quick-search-modal",n.innerHTML=`
<div class="quick-search-content">
<div class="quick-search-input-wrapper">
<span class="quick-search-icon">\u{1F50D}</span>
<input type="text" id="quick-search-input" placeholder="Search services, apps, or actions..." autocomplete="off">
<span class="quick-search-shortcut">ESC</span>
</div>
<div id="quick-search-results" class="quick-search-results"></div>
<div class="quick-search-footer">
<span><kbd>\u2191\u2193</kbd> Navigate</span>
<span><kbd>Enter</kbd> Select</span>
<span><kbd>Esc</kbd> Close</span>
</div>
</div>
`;const t=document.createElement("style");t.textContent=`
.quick-search-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 100000;
justify-content: center;
align-items: flex-start;
padding-top: 15vh;
backdrop-filter: blur(4px);
}
.quick-search-modal.show {
display: flex;
}
.quick-search-content {
background: var(--card-base, #1a1a2e);
border: 1px solid var(--border, #333);
border-radius: 12px;
width: 90%;
max-width: 600px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
.quick-search-input-wrapper {
display: flex;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid var(--border, #333);
gap: 12px;
}
.quick-search-icon {
font-size: 20px;
opacity: 0.6;
}
#quick-search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--fg, #fff);
font-size: 18px;
font-family: inherit;
}
#quick-search-input::placeholder {
color: var(--muted, #666);
}
.quick-search-shortcut {
background: var(--card-hover, #2a2a4e);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
color: var(--muted, #666);
font-family: monospace;
}
.quick-search-results {
max-height: 400px;
overflow-y: auto;
}
.quick-search-category {
padding: 8px 20px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
color: var(--muted, #666);
background: var(--card-hover, #2a2a4e);
letter-spacing: 0.5px;
}
.quick-search-item {
display: flex;
align-items: center;
padding: 12px 20px;
cursor: pointer;
transition: background 0.15s;
gap: 12px;
}
.quick-search-item:hover,
.quick-search-item.selected {
background: var(--accent, #3498db);
}
.quick-search-item-icon {
font-size: 24px;
width: 32px;
text-align: center;
}
.quick-search-item-content {
flex: 1;
}
.quick-search-item-title {
font-weight: 500;
color: var(--fg, #fff);
}
.quick-search-item-description {
font-size: 12px;
color: var(--muted, #aaa);
margin-top: 2px;
}
.quick-search-item-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
background: var(--card-hover, #2a2a4e);
color: var(--muted, #888);
}
.quick-search-empty {
padding: 40px 20px;
text-align: center;
color: var(--muted, #666);
}
.quick-search-footer {
display: flex;
justify-content: center;
gap: 24px;
padding: 12px 20px;
border-top: 1px solid var(--border, #333);
background: var(--card-hover, #2a2a4e);
font-size: 12px;
color: var(--muted, #666);
}
.quick-search-footer kbd {
background: var(--card-base, #1a1a2e);
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
margin-right: 4px;
}
`,document.head.appendChild(t),document.body.appendChild(n),u=document.getElementById("quick-search-input"),l=document.getElementById("quick-search-results"),u.addEventListener("input",r),u.addEventListener("keydown",w),n.addEventListener("click",e=>{e.target===n&&p()})}function q(t){try{if((t.ctrlKey||t.metaKey)&&t.key==="k"){t.preventDefault(),g();return}if(t.key==="Escape"){if(n&&n.classList.contains("show")){p();return}a()}}catch(e){console.warn("[Keyboard Shortcuts] Error handling keydown:",e.message)}}function g(){try{n.classList.add("show"),u.value="",u.focus(),c()}catch(t){console.warn("[Keyboard Shortcuts] Error opening quick search:",t.message)}}function p(){try{n.classList.remove("show"),u.value="",l.innerHTML=""}catch(t){console.warn("[Keyboard Shortcuts] Error closing quick search:",t.message)}}function a(){for(const t of k){const e=document.querySelector(t);if(e&&(e.classList.contains("show")||e.style.display==="flex"))return e.classList.remove("show"),e.style.display="none",!0}return!1}function c(){const t=`
<div class="quick-search-category">Quick Actions</div>
<div class="quick-search-item" data-action="refresh">
<span class="quick-search-item-icon">\u{1F504}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">Refresh Dashboard</div>
<div class="quick-search-item-description">Refresh all service statuses</div>
</div>
</div>
<div class="quick-search-item" data-action="reload-caddy">
<span class="quick-search-item-icon">\u26A1</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">Reload Caddy</div>
<div class="quick-search-item-description">Reload Caddy configuration</div>
</div>
</div>
<div class="quick-search-item" data-action="add-service">
<span class="quick-search-item-icon">\u2795</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">Add Service</div>
<div class="quick-search-item-description">Open service configuration modal</div>
</div>
</div>
<div class="quick-search-item" data-action="app-selector">
<span class="quick-search-item-icon">\u{1F4F1}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">App Selector</div>
<div class="quick-search-item-description">Deploy new applications</div>
</div>
</div>
<div class="quick-search-category">Services</div>
${i()}
`;l.innerHTML=t,y()}function i(){const t=document.querySelectorAll(".card[data-app], #cards .card");let e="";return t.forEach(s=>{const d=s.querySelector(".name")?.textContent||"Unknown",o=s.dataset.status||"unknown",b=s.dataset.app||"";d&&d!=="--"&&(e+=`
<div class="quick-search-item" data-action="open-service" data-service="${b}">
<span class="quick-search-item-icon">${o==="on"?"\u{1F7E2}":"\u{1F534}"}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">${d}</div>
<div class="quick-search-item-description">Click to open service</div>
</div>
<span class="quick-search-item-badge">${o.toUpperCase()}</span>
</div>
`)}),e||'<div class="quick-search-empty">No services found</div>'}function r(t){try{const e=t.target.value.toLowerCase().trim();if(!e){c();return}const s=h(e);S(s)}catch(e){console.warn("[Keyboard Shortcuts] Error handling search input:",e.message)}}function h(t){const e={actions:[],services:[]};return[{id:"refresh",title:"Refresh Dashboard",icon:"\u{1F504}",keywords:"refresh reload update status"},{id:"reload-caddy",title:"Reload Caddy",icon:"\u26A1",keywords:"reload caddy proxy config"},{id:"add-service",title:"Add Service",icon:"\u2795",keywords:"add new service create"},{id:"app-selector",title:"App Selector",icon:"\u{1F4F1}",keywords:"app deploy install docker container"},{id:"backup",title:"Backup & Restore",icon:"\u{1F4BE}",keywords:"backup restore export import"},{id:"stats",title:"Container Stats",icon:"\u{1F4CA}",keywords:"stats resources cpu memory"},{id:"logs",title:"View Logs",icon:"\u{1F4CB}",keywords:"logs error debug"},{id:"tokens",title:"Manage Tokens",icon:"\u{1F511}",keywords:"tokens api keys credentials"},{id:"notifications",title:"Notifications",icon:"\u{1F514}",keywords:"alerts notifications discord telegram"},{id:"theme",title:"Change Theme",icon:"\u{1F3A8}",keywords:"theme dark light appearance"},{id:"tour",title:"Help Tour",icon:"\u{1F393}",keywords:"help tour guide onboarding"}].forEach(o=>{(o.title.toLowerCase().includes(t)||o.keywords.includes(t))&&e.actions.push(o)}),document.querySelectorAll(".card[data-app], #cards .card").forEach(o=>{const b=o.querySelector(".name")?.textContent||"",x=o.dataset.app||"",E=o.dataset.status||"unknown";(b.toLowerCase().includes(t)||x.toLowerCase().includes(t))&&e.services.push({id:x,title:b,status:E,icon:E==="on"?"\u{1F7E2}":"\u{1F534}"})}),e}function S(t){let e="";t.actions.length>0&&(e+='<div class="quick-search-category">Actions</div>',t.actions.forEach(s=>{e+=`
<div class="quick-search-item" data-action="${s.id}">
<span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">${s.title}</div>
</div>
</div>
`})),t.services.length>0&&(e+='<div class="quick-search-category">Services</div>',t.services.forEach(s=>{e+=`
<div class="quick-search-item" data-action="open-service" data-service="${s.id}">
<span class="quick-search-item-icon">${s.icon}</span>
<div class="quick-search-item-content">
<div class="quick-search-item-title">${s.title}</div>
</div>
<span class="quick-search-item-badge">${s.status.toUpperCase()}</span>
</div>
`})),e||(e='<div class="quick-search-empty">No results found</div>'),l.innerHTML=e,y()}function y(){l.querySelectorAll(".quick-search-item").forEach((e,s)=>{e.addEventListener("click",()=>f(e)),s===0&&e.classList.add("selected")})}function w(t){try{const e=l.querySelectorAll(".quick-search-item"),s=l.querySelector(".quick-search-item.selected"),d=Array.from(e).indexOf(s);if(t.key==="ArrowDown"){t.preventDefault(),s&&s.classList.remove("selected");const o=(d+1)%e.length;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else if(t.key==="ArrowUp"){t.preventDefault(),s&&s.classList.remove("selected");const o=d<=0?e.length-1:d-1;e[o]?.classList.add("selected"),e[o]?.scrollIntoView({block:"nearest"})}else t.key==="Enter"&&(t.preventDefault(),s&&f(s))}catch(e){console.warn("[Keyboard Shortcuts] Error handling search navigation:",e.message)}}function f(t){try{const e=t.dataset.action,s=t.dataset.service;switch(p(),e){case"refresh":document.getElementById("refresh")?.click();break;case"reload-caddy":document.getElementById("reload-caddy-top")?.click();break;case"add-service":document.getElementById("add-service")?.click();break;case"app-selector":document.getElementById("add-service-btn")?.click();break;case"backup":document.getElementById("backup-restore-btn")?.click();break;case"stats":document.getElementById("container-stats-btn")?.click();break;case"logs":document.getElementById("view-error-logs")?.click();break;case"tokens":document.getElementById("manage-tokens")?.click();break;case"notifications":document.getElementById("manage-notifications")?.click();break;case"theme":document.getElementById("theme")?.click();break;case"tour":document.getElementById("restart-tour-btn")?.click();break;case"open-service":if(s){const d=document.querySelector(`[data-app="${s}"] [id$="-open"], [data-app="${s}"] button:not(.restart-btn):not(.logs-btn):not(.settings-btn)`);if(d)d.click();else{const o=document.querySelector(`[data-app="${s}"]`);o&&o.click()}}break;default:console.log("[Keyboard Shortcuts] Unknown action:",e)}}catch(e){console.warn("[Keyboard Shortcuts] Error executing action:",e.message)}}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),window.DashCaddyKeyboardShortcuts={openQuickSearch:g,closeQuickSearch:p}})();
+218
View File
@@ -0,0 +1,218 @@
this.driver=this.driver||{},this.driver.js=(function(v){"use strict";let T={};function P(o={}){T={animate:!0,allowClose:!0,overlayOpacity:.7,smoothScroll:!1,disableActiveInteraction:!1,showProgress:!1,stagePadding:10,stageRadius:5,popoverOffset:10,showButtons:["next","previous","close"],disableButtons:[],overlayColor:"#000",...o}}function e(o){return o?T[o]:T}function t(o,r,n,s){return(o/=s/2)<1?n/2*o*o+r:-n/2*(--o*(o-2)-1)+r}function a(o){const r='a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])';return o.flatMap(n=>{const s=n.matches(r),i=Array.from(n.querySelectorAll(r));return[...s?[n]:[],...i]}).filter(n=>getComputedStyle(n).pointerEvents!=="none"&&A(n))}function p(o){if(!o||m(o))return;const r=e("smoothScroll");o.scrollIntoView({behavior:!r||y(o)?"auto":"smooth",inline:"center",block:"center"})}function y(o){if(!o||!o.parentElement)return;const r=o.parentElement;return r.scrollHeight>r.clientHeight}function m(o){const r=o.getBoundingClientRect();return r.top>=0&&r.left>=0&&r.bottom<=(window.innerHeight||document.documentElement.clientHeight)&&r.right<=(window.innerWidth||document.documentElement.clientWidth)}function A(o){return!!(o.offsetWidth||o.offsetHeight||o.getClientRects().length)}let L={};function E(o,r){L[o]=r}function d(o){return o?L[o]:L}function K(){L={}}let F={};function R(o,r){F[o]=r}function O(o){var r;(r=F[o])==null||r.call(F)}function se(){F={}}function ae(o,r,n,s){let i=d("__activeStagePosition");const u=i||n.getBoundingClientRect(),b=s.getBoundingClientRect(),S=t(o,u.x,b.x-u.x,r),c=t(o,u.y,b.y-u.y,r),w=t(o,u.width,b.width-u.width,r),l=t(o,u.height,b.height-u.height,r);i={x:S,y:c,width:w,height:l},J(i),E("__activeStagePosition",i)}function Y(o){if(!o)return;const r=o.getBoundingClientRect(),n={x:r.x,y:r.y,width:r.width,height:r.height};E("__activeStagePosition",n),J(n)}function le(){const o=d("__activeStagePosition"),r=d("__overlaySvg");if(!o)return;if(!r){console.warn("No stage svg found.");return}const n=window.innerWidth,s=window.innerHeight;r.setAttribute("viewBox",`0 0 ${n} ${s}`)}function de(o){const r=ce(o);document.body.appendChild(r),ee(r,n=>{n.target.tagName==="path"&&O("overlayClick")}),E("__overlaySvg",r)}function J(o){const r=d("__overlaySvg");if(!r){de(o);return}const n=r.firstElementChild;if(n?.tagName!=="path")throw new Error("no path element found in stage svg");n.setAttribute("d",Q(o))}function ce(o){const r=window.innerWidth,n=window.innerHeight,s=document.createElementNS("http://www.w3.org/2000/svg","svg");s.classList.add("driver-overlay","driver-overlay-animated"),s.setAttribute("viewBox",`0 0 ${r} ${n}`),s.setAttribute("xmlSpace","preserve"),s.setAttribute("xmlnsXlink","http://www.w3.org/1999/xlink"),s.setAttribute("version","1.1"),s.setAttribute("preserveAspectRatio","xMinYMin slice"),s.style.fillRule="evenodd",s.style.clipRule="evenodd",s.style.strokeLinejoin="round",s.style.strokeMiterlimit="2",s.style.zIndex="10000",s.style.position="fixed",s.style.top="0",s.style.left="0",s.style.width="100%",s.style.height="100%";const i=document.createElementNS("http://www.w3.org/2000/svg","path");return i.setAttribute("d",Q(o)),i.style.fill=e("overlayColor")||"rgb(0,0,0)",i.style.opacity=`${e("overlayOpacity")}`,i.style.pointerEvents="auto",i.style.cursor="auto",s.appendChild(i),s}function Q(o){const r=window.innerWidth,n=window.innerHeight,s=e("stagePadding")||0,i=e("stageRadius")||0,u=o.width+s*2,b=o.height+s*2,S=Math.min(i,u/2,b/2),c=Math.floor(Math.max(S,0)),w=o.x-s+c,l=o.y-s,h=u-c*2,g=b-c*2;return`M${r},0L0,0L0,${n}L${r},${n}L${r},0Z
M${w},${l} h${h} a${c},${c} 0 0 1 ${c},${c} v${g} a${c},${c} 0 0 1 -${c},${c} h-${h} a${c},${c} 0 0 1 -${c},-${c} v-${g} a${c},${c} 0 0 1 ${c},-${c} z`}function pe(){const o=d("__overlaySvg");o&&o.remove()}function ue(){const o=document.getElementById("driver-dummy-element");if(o)return o;let r=document.createElement("div");return r.id="driver-dummy-element",r.style.width="0",r.style.height="0",r.style.pointerEvents="none",r.style.opacity="0",r.style.position="fixed",r.style.top="50%",r.style.left="50%",document.body.appendChild(r),r}function X(o){const{element:r}=o;let n=typeof r=="string"?document.querySelector(r):r;n||(n=ue()),he(n,o)}function me(){const o=d("__activeElement"),r=d("__activeStep");o&&(Y(o),le(),ne(o,r))}function he(o,r){const n=Date.now(),s=d("__activeStep"),i=d("__activeElement")||o,u=!i||i===o,b=o.id==="driver-dummy-element",S=i.id==="driver-dummy-element",c=e("animate"),w=r.onHighlightStarted||e("onHighlightStarted"),l=r?.onHighlighted||e("onHighlighted"),h=s?.onDeselected||e("onDeselected"),g=e(),C=d();!u&&h&&h(S?void 0:i,s,{config:g,state:C}),w&&w(b?void 0:o,r,{config:g,state:C});const k=!u&&c;let x=!1;we(),E("previousStep",s),E("previousElement",i),E("activeStep",r),E("activeElement",o);const f=()=>{if(d("__transitionCallback")!==f)return;const D=Date.now()-n,B=400-D<=400/2;r.popover&&B&&!x&&k&&(te(o,r),x=!0),e("animate")&&D<400?ae(D,400,i,o):(Y(o),l&&l(b?void 0:o,r,{config:e(),state:d()}),E("__transitionCallback",void 0),E("__previousStep",s),E("__previousElement",i),E("__activeStep",r),E("__activeElement",o)),window.requestAnimationFrame(f)};E("__transitionCallback",f),window.requestAnimationFrame(f),p(o),!k&&r.popover&&te(o,r),i.classList.remove("driver-active-element","driver-no-interaction"),i.removeAttribute("aria-haspopup"),i.removeAttribute("aria-expanded"),i.removeAttribute("aria-controls"),e("disableActiveInteraction")&&o.classList.add("driver-no-interaction"),o.classList.add("driver-active-element"),o.setAttribute("aria-haspopup","dialog"),o.setAttribute("aria-expanded","true"),o.setAttribute("aria-controls","driver-popover-content")}function ge(){var o;(o=document.getElementById("driver-dummy-element"))==null||o.remove(),document.querySelectorAll(".driver-active-element").forEach(r=>{r.classList.remove("driver-active-element","driver-no-interaction"),r.removeAttribute("aria-haspopup"),r.removeAttribute("aria-expanded"),r.removeAttribute("aria-controls")})}function z(){const o=d("__resizeTimeout");o&&window.cancelAnimationFrame(o),E("__resizeTimeout",window.requestAnimationFrame(me))}function ve(o){var r;if(!d("isInitialized")||!(o.key==="Tab"||o.keyCode===9))return;const n=d("__activeElement"),s=(r=d("popover"))==null?void 0:r.wrapper,i=a([...s?[s]:[],...n?[n]:[]]),u=i[0],b=i[i.length-1];if(o.preventDefault(),o.shiftKey){const S=i[i.indexOf(document.activeElement)-1]||b;S?.focus()}else{const S=i[i.indexOf(document.activeElement)+1]||u;S?.focus()}}function Z(o){var r;((r=e("allowKeyboardControl"))==null||r)&&(o.key==="Escape"?O("escapePress"):o.key==="ArrowRight"?O("arrowRightPress"):o.key==="ArrowLeft"&&O("arrowLeftPress"))}function ee(o,r,n){const s=(i,u)=>{const b=i.target;o.contains(b)&&((!n||n(b))&&(i.preventDefault(),i.stopPropagation(),i.stopImmediatePropagation()),u?.(i))};document.addEventListener("pointerdown",s,!0),document.addEventListener("mousedown",s,!0),document.addEventListener("pointerup",s,!0),document.addEventListener("mouseup",s,!0),document.addEventListener("click",i=>{s(i,r)},!0)}function fe(){window.addEventListener("keyup",Z,!1),window.addEventListener("keydown",ve,!1),window.addEventListener("resize",z),window.addEventListener("scroll",z)}function ye(){window.removeEventListener("keyup",Z),window.removeEventListener("resize",z),window.removeEventListener("scroll",z)}function we(){const o=d("popover");o&&(o.wrapper.style.display="none")}function te(o,r){var n,s;let i=d("popover");i&&document.body.removeChild(i.wrapper),i=Te(),document.body.appendChild(i.wrapper);const{title:u,description:b,showButtons:S,disableButtons:c,showProgress:w,nextBtnText:l=e("nextBtnText")||"Next &rarr;",prevBtnText:h=e("prevBtnText")||"&larr; Previous",progressText:g=e("progressText")||"{current} of {total}"}=r.popover||{};i.nextButton.innerHTML=l,i.previousButton.innerHTML=h,i.progress.innerHTML=g,u?(i.title.innerHTML=u,i.title.style.display="block"):i.title.style.display="none",b?(i.description.innerHTML=b,i.description.style.display="block"):i.description.style.display="none";const C=S||e("showButtons"),k=w||e("showProgress")||!1,x=C?.includes("next")||C?.includes("previous")||k;i.closeButton.style.display=C.includes("close")?"block":"none",x?(i.footer.style.display="flex",i.progress.style.display=k?"block":"none",i.nextButton.style.display=C.includes("next")?"block":"none",i.previousButton.style.display=C.includes("previous")?"block":"none"):i.footer.style.display="none";const f=c||e("disableButtons")||[];f!=null&&f.includes("next")&&(i.nextButton.disabled=!0,i. Line truncated
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;
`,e.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(e),setTimeout(()=>{e.parentNode&&e.parentNode.removeChild(e)},1e4),!0}handleStorageUnavailable(){this.logError("Storage Unavailable","Local storage is not available");const e={data:{},getItem(t){return this.data[t]||null},setItem(t,a){this.data[t]=a},removeItem(t){delete this.data[t]},clear(){this.data={}}};return console.warn("[ErrorHandler] Using in-memory storage - progress will not persist"),e}sendToErrorTracking(e){}}v.ErrorHandler=T,console.log("[ErrorHandler] Module loaded")})(window),(function(v){"use strict";class T{constructor(e="dashcaddy_onboarding"){this.storageKey=e,this.storageVersion="1.0",this.installOnboardingCompleted=typeof SITE<"u"&&SITE.onboardingCompleted===!0,this._initializeStorage(),this._updateLastVisit()}_initializeStorage(){const e=this._getStorage();if(!e||e.version!==this.storageVersion){const t={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(t)}}_getStorage(){try{const e=localStorage.getItem(this.storageKey);return e?JSON.parse(e):null}catch(e){return console.error("[ProgressTracker] Error reading from storage:",e),null}}_setStorage(e){try{localStorage.setItem(this.storageKey,JSON.stringify(e))}catch(t){console.error("[ProgressTracker] Error writing to storage:",t),this._handleStorageError(t)}}_handleStorageError(e){try{sessionStorage.setItem(this.storageKey,JSON.stringify(this._getStorage())),console.warn("[ProgressTracker] Falling back to session storage")}catch(t){console.error("[ProgressTracker] Session storage also unavailable:",t)}}_updateLastVisit(){const e=this._getStorage();e&&(e.lastVisit=new Date().toISOString(),this._setStorage(e))}isTooltipCompleted(e){const t=this._getStorage();return t?t.completedTooltips.includes(e):!1}markTooltipCompleted(e){const t=this._getStorage();t&&(t.completedTooltips.includes(e)||(t.completedTooltips.push(e),t.tooltipTimestamps||(t.tooltipTimestamps={}),t.tooltipTimestamps[e]=new Date().toISOString(),this._setStorage(t)))}isTourCompleted(){const e=this._getStorage();return e?e.tourCompleted===!0:!1}isInstallOnboardingCompleted(){return this.installOnboardingCompleted===!0}async markInstallOnboardingCompleted(){if(!this.installOnboardingCompleted){this.installOnboardingCompleted=!0,typeof SITE<"u"&&(SITE.onboardingCompleted=!0);try{await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({onboardingCompleted:!0})})}catch(e){console.error("[ProgressTracker] Failed to persist install onboarding state:",e)}}}markTourCompleted(){const e=this._getStorage();e&&(e.tourCompleted=!0,e.completionTimestamp=new Date().toISOString(),this._setStorage(e))}getCurrentStep(){const e=this._getStorage();return e&&e.currentStep||0}setCurrentStep(e){const t=this._getStorage();t&&(t.currentStep=e,this._setStorage(t))}resetProgress(){const e={version:this.storageVersion,tourCompleted:!1,completedTooltips:[],currentStep:0,completionTimestamp:null,dnsSetupDeferred:!1,lastVisit:new Date().toISOString()};this._setStorage(e)}getCompletionTimestamp(){const e=this._getStorage();return!e||!e.completionTimestamp?null:new Date(e.completionTimestamp)}isDnsSetupDeferred(){const e=this._getStorage();return e?e.dnsSetupDeferred===!0:!1}markDnsSetupDeferred(){const e=this._getStorage();e&&(e.dnsSetupDeferred=!0,this._setStorage(e))}getTooltipTimestamp(e){const t=this._getStorage();return!t||!t.tooltipTimestamps||!t.tooltipTimestamps[e]?null:new Date(t.tooltipTimestamps[e])}getCompletedTooltips(){const e=this._getStorage();return e?e.completedTooltips||[]:[]}getLastVisit(){const e=this._getStorage();return!e||!e.lastVisit?null:new Date(e.lastVisit)}}v.ProgressTracker=T,console.log("[ProgressTracker] Module loaded")})(window),(function(v){"use strict";const T={dark:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(0, 0, 0, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},light:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent-strong)",overlayColor:"rgba(0, 0, 0, 0.5)",borderColor:"var(--border)",highlightColor:"var(--accent-strong)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},blue:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(25, 8, 172, 0.7)",borderColor:"var(--border)",highlightColor:"var(--accent)",fontFamily:"'Sami Grotesk', 'Inter', 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif"},nord:{backgroundColor:"var(--card-base)",textColor:"var(--fg)",primaryColor:"var(--accent)",overlayColor:"rgba(46, 52, 64, 0.7)",borderColor:"var(--border)",highlightC Line truncated
.driver-popover {
background: ${t.backgroundColor} !important;
color: ${t.textColor} !important;
border: 1px solid ${t.borderColor} !important;
border-radius: 12px !important;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4) !important;
font-family: ${t.fontFamily} !important;
}
.driver-popover-title {
color: ${t.textColor} !important;
font-weight: 600 !important;
font-family: ${t.fontFamily} !important;
}
.driver-popover-description {
color: ${t.textColor} !important;
font-family: ${t.fontFamily} !important;
}
.driver-popover-footer button {
background: ${t.primaryColor} !important;
color: ${t.backgroundColor} !important;
border: none !important;
font-family: ${t.fontFamily} !important;
font-weight: 500 !important;
}
.driver-popover-footer button:hover {
opacity: 0.9 !important;
}
.driver-popover-close-btn {
color: ${t.textColor} !important;
}
.driver-overlay {
background: ${t.overlayColor} !important;
}
.driver-highlighted-element {
outline: 2px solid ${t.highlightColor} !important;
outline-offset: 4px !important;
}
.driver-popover-progress-text {
color: ${t.textColor} !important;
opacity: 0.7 !important;
font-family: ${t.fontFamily} !important;
}
`,document.head.appendChild(p)}getAvailableThemes(){return Object.keys(T)}isThemeAvailable(t){return T.hasOwnProperty(t)}}v.ThemeAdapter=P,console.log("[ThemeAdapter] Module loaded")})(window),(function(v){"use strict";function T(a){const p=[];if((!a.id||typeof a.id!="string")&&p.push("Tooltip must have a valid string id"),a.element||p.push("Tooltip must have an element selector or HTMLElement"),!a.popover||typeof a.popover!="object")p.push("Tooltip must have a popover object");else{if((!a.popover.title||typeof a.popover.title!="string")&&p.push("Tooltip popover must have a valid string title"),(!a.popover.description||typeof a.popover.description!="string")&&p.push("Tooltip popover must have a valid string description"),a.popover.position){const m=["top","bottom","left","right","center"];m.includes(a.popover.position)||p.push(`Invalid position: ${a.popover.position}. Must be one of: ${m.join(", ")}`)}if(a.popover.align){const m=["start","center","end"];m.includes(a.popover.align)||p.push(`Invalid align: ${a.popover.align}. Must be one of: ${m.join(", ")}`)}a.popover.showButtons&&!Array.isArray(a.popover.showButtons)&&p.push("showButtons must be an array"),["onNext","onPrevious","onClose","onSetupNow","onLater"].forEach(m=>{a.popover[m]&&typeof a.popover[m]!="function"&&p.push(`${m} must be a function`)})}return a.condition&&typeof a.condition!="function"&&p.push("condition must be a function"),a.priority!==void 0&&typeof a.priority!="number"&&p.push("priority must be a number"),{valid:p.length===0,errors:p}}function P(a){if(!Array.isArray(a))return{valid:!1,errors:[{tooltip:null,errors:["tooltips must be an array"]}]};const p=[],y=new Set;return a.forEach((m,A)=>{const L=T(m);L.valid||p.push({tooltip:m.id||`index ${A}`,errors:L.errors}),m.id&&(y.has(m.id)&&p.push({tooltip:m.id,errors:[`Duplicate tooltip ID: ${m.id}`]}),y.add(m.id))}),{valid:p.length===0,errors:p}}class e extends Error{constructor(p,y=null){super(p),this.name="TooltipError",this.tooltipId=y}}function t(a){if(!a.valid){const p=a.errors.map(y=>`${y.tooltip}: ${y.errors.join(", ")}`).join(`
`);throw console.error("[TooltipDefinitions] Validation errors:",p),new e(`Tooltip validation failed:
${p}`)}}v.TooltipValidation={validateTooltipDefinition:T,validateTooltipDefinitions:P,handleValidationErrors:t,TooltipError:e},console.log("[TooltipDefinitions] Validation module loaded")})(window);const TOOLTIP_DEFINITIONS=[{id:"welcome",element:"#brand",popover:{title:"Welcome to DashCaddy!",description:`
<p>Your unified control panel for <strong>Docker</strong>, <strong>Caddy</strong>, and <strong>DNS</strong> &mdash; all in one place.</p>
<p>This tour will walk you through every section so you know exactly where everything is.</p>
<p style="margin-top: 8px; font-size: 0.85rem; opacity: 0.7;">You can click your logo anytime to customize it.</p>
`,position:"bottom",align:"start",showButtons:["next"],showProgress:!0},priority:1},{id:"infrastructure",element:".top",popover:{title:"Infrastructure Overview",description:`
<p>This top row shows the backbone of your setup:</p>
<ul>
<li><strong>DNS1 / DNS2 / DNS3</strong> &mdash; your Technitium DNS servers</li>
<li><strong>Internet</strong> &mdash; live connectivity check with packet indicators</li>
<li><strong>Auth</strong> &mdash; TOTP authentication status</li>
<li><strong>DashCA</strong> &mdash; your certificate authority for your custom domain</li>
</ul>
<p style="font-size: 0.85rem; opacity: 0.7;">These core services are always pinned here, separate from your app grid.</p>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:2},{id:"service-cards",element:"#cards",popover:{title:"Your App Grid",description:`
<p>Every app you deploy appears here as a card. Each one shows:</p>
<ul>
<li><strong>Status dot</strong> &mdash; green = online, red = offline (pulses when down)</li>
<li><strong>ON/OFF badge</strong> &mdash; current state at a glance</li>
<li><strong>Response time</strong> &mdash; how fast the service responds (color-coded)</li>
<li><strong>Uptime bar</strong> &mdash; historical uptime percentage</li>
</ul>
<p style="font-size: 0.85rem; opacity: 0.7;">Hover over a card to see action buttons: Logs, Restart, Update, Settings, and Open.</p>
`,position:"top",showButtons:["previous","next"],showProgress:!0},priority:3},{id:"app-selector",element:"#add-service-btn",popover:{title:"App Selector &mdash; Deploy in One Click",description:`
<p>Browse <strong>50+ self-hosted apps</strong> organized by category:</p>
<ul>
<li>Media (Plex, Jellyfin, Emby, Overseerr)</li>
<li>Downloads (*arr stack, qBittorrent, Transmission)</li>
<li>Productivity (Nextcloud, Vaultwarden, Gitea)</li>
<li>Monitoring (Uptime Kuma, Grafana, Prometheus)</li>
</ul>
<p>Each app deploys with Docker, Caddy reverse proxy, and DNS &mdash; fully configured automatically.</p>
<p style="margin-top: 6px; padding: 6px 10px; border-radius: 6px; background: rgba(241,196,15,0.12); border: 1px solid rgba(241,196,15,0.25); font-size: 0.85rem;">
<strong style="color: #f1c40f;">&#9733; Premium:</strong> <strong>Recipes</strong> let you deploy entire stacks (e.g., full media server) in one click with pre-wired configs.
</p>
`,position:"top",showButtons:["previous","next"],showProgress:!0},priority:4,condition:()=>document.getElementById("add-service-btn")!==null},{id:"smart-arr",element:"#arr-setup-btn",popover:{title:"Smart Arr Connect",description:`
<p>Automatically wire up your entire media stack:</p>
<ul>
<li>Detects running Arr services (Radarr, Sonarr, Prowlarr, etc.)</li>
<li>Connects them together with the right API keys</li>
<li>Links Plex/Jellyfin to Overseerr for request management</li>
</ul>
<p style="font-size: 0.85rem; opacity: 0.7;">No manual API key copying &mdash; DashCaddy handles it all.</p>
`,position:"top",showButtons:["previous","next"],showProgress:!0},priority:5,condition:()=>document.getElementById("arr-setup-btn")!==null},{id:"add-manual",element:"#add-service",popover:{title:"Add App Manually",description:`
<p>Already have a service running? Add it to your dashboard manually.</p>
<p>You can add:</p>
<ul>
<li><strong>Docker containers</strong> &mdash; auto-detects running containers</li>
<li><strong>External services</strong> &mdash; any URL you want to monitor</li>
<li><strong>Test services</strong> &mdash; try the UI without deploying anything</li>
</ul>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:6},{id:"theme-selector",element:"#theme",popover:{title:"Themes",description:`
<p>Switch between <strong>7 built-in themes</strong>:</p>
<p>Dark, Light, Blue, Nord, Dracula, Solarized Dark, and Solarized Light.</p>
<p>Your preference is saved automatically. You can also build custom themes with the Theme Builder (in Admin &rarr; settings).</p>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:7},{id:"toolbar-status",element:'.tools-section[data-section="status"]',popover:{title:"Status Tools",description:`
<p>Click <strong>Status</strong> to expand these tools:</p>
<ul>
<li><strong>Monitor</strong> &mdash; live CPU, memory, and disk usage for every container</li>
<li><strong>Health</strong> &mdash; service health dashboard with uptime history and alerts</li>
<li><strong>Updates</strong> &mdash; check for and apply Docker image updates</li>
</ul>
<p style="font-size: 0.85rem; opacity: 0.7;">These sections remember whether you left them open or closed.</p>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:8},{id:"toolbar-tools",element:'.tools-section[data-section="tools"]',popover:{title:"Operational Tools",description:`
<p>Click <strong>Tools</strong> for day-to-day operations:</p>
<ul>
<li><strong>Logs</strong> &mdash; view error logs from your API server and services</li>
<li><strong>Alerts</strong> &mdash; configure notification rules (email, webhook, etc.)</li>
<li><strong>Audit</strong> &mdash; full audit trail of every action taken in DashCaddy</li>
</ul>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:9},{id:"toolbar-admin",element:'.tools-section[data-section="admin"]',popover:{title:"Admin &amp; Configuration",description:`
<p>Click <strong>Admin</strong> for setup and maintenance:</p>
<ul>
<li><strong>Tokens</strong> &mdash; manage API keys for DNS and other integrations</li>
<li><strong>Export / Import</strong> &mdash; backup and restore your dashboard configuration</li>
<li><strong>Backup</strong> &mdash; full system backup and restore</li>
<li><strong>License</strong> &mdash; activate your license code to unlock premium features</li>
<li><strong>API</strong> &mdash; interactive API documentation (125+ endpoints)</li>
</ul>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:10},{id:"weather",element:"#weather-widget",popover:{title:"Weather Widget",description:`
<p>Shows current conditions for your location. Click the <strong>gear icon</strong> to configure your city or switch temperature units.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">Uses Open-Meteo &mdash; no API key required.</p>
`,position:"bottom",showButtons:["previous","next"],showProgress:!0},priority:11,condition:()=>document.getElementById("weather-widget")!==null},{id:"reload-caddy",element:"#reload-caddy-top",popover:{title:"Reload Caddy",description:`
<p>After changing Caddy configuration (adding reverse proxy rules, SSL settings, etc.), click here to apply the changes live.</p>
<p style="font-size: 0.85rem; opacity: 0.7;">This is a graceful reload &mdash; existing connections are not dropped.</p>
`,position:"bottom",align:"end",showButtons:["previous","next"],showProgress:!0},priority:12,condition:()=>document.getElementById("reload-caddy-top")!==null},{id:"tour-complete",element:"#brand",popover:{title:"You're All Set!",description:`
<p>That covers the essentials. A few tips to get the most out of DashCaddy:</p>
<ul>
<li><strong>Click any card</strong> to open that service directly</li>
<li><strong>Keyboard shortcuts</strong> &mdash; press <code>?</code> anytime to see them all</li>
<li><strong>DashCA</strong> &mdash; visit your CA page to install the root certificate on any device</li>
</ul>
<div style="margin-top: 10px; padding: 10px 12px; border-radius: 8px; background: linear-gradient(135deg, rgba(241,196,15,0.1), rgba(243,156,18,0.08)); border: 1px solid rgba(241,196,15,0.25);">
<p style="margin: 0 0 6px; font-weight: 600; color: #f1c40f;">&#9733; Unlock Premium</p>
<p style="margin: 0; font-size: 0.85rem;">Premium adds powerful features for serious homelabbers:</p>
<ul style="margin: 6px 0 0; font-size: 0.85rem;">
<li><strong>Auto-Login SSO</strong> &mdash; sign into every app automatically, no more password juggling</li>
<li><strong>Recipes</strong> &mdash; deploy full stacks (media server, dev environment) in one click</li>
<li><strong>Docker Swarm</strong> &mdash; orchestrate multi-node clusters from your dashboard</li>
</ul>
<p style="margin: 8px 0 0; font-size: 0.82rem; opacity: 0.8;">Activate in <strong>Admin &rarr; License</strong></p>
</div>
<p style="margin-top: 10px; font-size: 0.85rem; opacity: 0.7;">You can restart this tour anytime from <strong>Admin &rarr; Help Tour</strong>.</p>
`,position:"bottom",align:"start",showButtons:["previous","close"],showProgress:!0},priority:13}];function getTooltipDefinitions(){return TOOLTIP_DEFINITIONS}function getTooltipById(v){return TOOLTIP_DEFINITIONS.find(T=>T.id===v)||null}function getActiveTooltips(){return TOOLTIP_DEFINITIONS.filter(v=>{if(v.condition&&typeof v.condition=="function")try{return v.condition()}catch(T){return console.error(`[TooltipDefinitions] Error evaluating condition for ${v.id}:`,T),!1}return!0})}function getSortedTooltips(){return getActiveTooltips().sort((T,P)=>{const e=T.priority||999,t=P.priority||999;return e-t})}function getNewFeatureTooltips(){return getActiveTooltips().filter(T=>T.isNewFeature===!0).sort((T,P)=>{const e=T.priority||999,t=P.priority||999;return e-t})}window.TooltipDefinitions={TOOLTIP_DEFINITIONS,getTooltipDefinitions,getTooltipById,getActiveTooltips,getSortedTooltips,getNewFeatureTooltips},console.log("[TooltipDefinitions] Definitions loaded:",TOOLTIP_DEFINITIONS.length,"tooltips"),(function(v){"use strict";class T{constructor(e){this.progressTracker=e,this.modal=null,this.onTemplateSelected=null,console.log("[DnsTemplateSelector] Module loaded")}getDnsTemplates(){return[{id:"technitium",name:"Technitium DNS Server",description:"Modern DNS server with web UI for managing private zones",icon:"\u{1F310}",difficulty:"Easy",features:["Web-based management interface","Private zone management for .sami domain","DHCP server integration","DNS-over-HTTPS and DNS-over-TLS support"],recommended:!0},{id:"bind9",name:"BIND9 DNS Server",description:"Industry-standard DNS server - powerful and flexible",icon:"\u{1F527}",difficulty:"Advanced",features:["Industry standard DNS server","Full RFC compliance","Advanced zone management","DNSSEC support"],recommended:!1},{id:"pihole",name:"Pi-hole",description:"Network-wide ad blocker with DNS capabilities",icon:"\u{1F6E1}\uFE0F",difficulty:"Intermediate",features:["Ad blocking at DNS level","Web interface for management","DHCP server included","Query logging and statistics"],recommended:!1},{id:"powerdns",name:"PowerDNS",description:"High-performance DNS server with SQL backend",icon:"\u26A1",difficulty:"Intermediate",features:["SQL database backend","RESTful API for automation","Geographic load balancing","DNSSEC support"],recommended:!1},{id:"coredns",name:"CoreDNS",description:"Cloud-native DNS server - lightweight and flexible",icon:"\u2601\uFE0F",difficulty:"Intermediate",features:["Plugin-based architecture","Kubernetes-native","Lightweight and fast","Prometheus metrics"],recommended:!1}]}showTemplateSelector(){this.modal||this.createModal(),this.populateTemplates(),this.modal.style.display="flex",document.body.style.overflow="hidden"}createModal(){const e=document.createElement("div");e.id="dns-template-modal",e.className="dns-template-modal",e.innerHTML=`
<div class="dns-template-modal-content">
<div class="dns-template-header">
<h2>\u{1F310} Choose a DNS Server</h2>
<p>Setting up a DNS server is essential for managing your private .sami domain</p>
<button class="dns-template-close" aria-label="Close">&times;</button>
</div>
<div class="dns-template-grid" id="dns-template-grid">
<!-- Templates will be inserted here -->
</div>
<div class="dns-template-footer">
<button class="dns-template-later-btn" id="dns-setup-later">Set up later</button>
</div>
</div>
`,document.body.appendChild(e),this.modal=e,e.querySelector(".dns-template-close").addEventListener("click",()=>this.close()),e.querySelector("#dns-setup-later").addEventListener("click",()=>this.handleSetupLater()),e.addEventListener("click",t=>{t.target===e&&this.close()}),document.addEventListener("keydown",t=>{t.key==="Escape"&&e.style.display==="flex"&&this.close()})}populateTemplates(){const e=document.getElementById("dns-template-grid");if(!e)return;const t=this.getDnsTemplates();e.innerHTML="",t.forEach(a=>{const p=this.createTemplateCard(a);e.appendChild(p)})}createTemplateCard(e){const t=document.createElement("div");t.className="dns-template-card",e.recommended&&t.classList.add("recommended");const a=e.difficulty.toLowerCase();return t.innerHTML=`
${e.recommended?'<div class="recommended-badge">Recommended</div>':""}
<div class="dns-template-icon">${e.icon}</div>
<h3>${e.name}</h3>
<p class="dns-template-description">${e.description}</p>
<div class="dns-template-difficulty difficulty-${a}">
${e.difficulty}
</div>
<ul class="dns-template-features">
${e.features.slice(0,3).map(y=>`<li>${y}</li>`).join("")}
</ul>
<button class="dns-template-select-btn" data-template-id="${e.id}">
Select ${e.name}
</button>
`,t.querySelector(".dns-template-select-btn").addEventListener("click",()=>this.handleTemplateSelection(e)),t}handleTemplateSelection(e){console.log(`[DnsTemplateSelector] Template selected: ${e.id}`),this.close(),this.onTemplateSelected?this.onTemplateSelected(e):this.openAppSelector(e.id)}handleSetupLater(){console.log("[DnsTemplateSelector] DNS setup deferred"),this.progressTracker&&this.progressTracker.markDnsSetupDeferred(),this.close(),this.showNotification("DNS setup deferred. You can set it up later from the App Selector.")}openAppSelector(e){const t=document.querySelector('[onclick*="showAppSelector"]');t?(t.click(),setTimeout(()=>{const a=document.querySelector("#app-search");a&&(a.value=e,a.dispatchEvent(new Event("input",{bubbles:!0})))},300)):this.showNotification(`To deploy ${e}, use the App Selector and search for "${e}"`)}showNotification(e){const t=document.createElement("div");t.className="dns-template-notification",t.textContent=e,t.style.cssText=`
position: fixed;
top: 20px;
right: 20px;
background: var(--card-base);
color: var(--fg);
padding: 15px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10001;
max-width: 300px;
`,document.body.appendChild(t),setTimeout(()=>{t.style.opacity="0",t.style.transition="opacity 0.3s",setTimeout(()=>t.remove(),300)},3e3)}close(){this.modal&&(this.modal.style.display="none",document.body.style.overflow="")}}v.DnsTemplateSelector=T,console.log("[DnsTemplateSelector] Module loaded")})(window),(function(v){"use strict";class T{constructor(e,t,a){this.progressTracker=e,this.themeAdapter=t,this.dnsTemplateSelector=a,this.driver=null,this.currentStepIndex=0,this.isActive=!1,this.resizeHandler=null,this.layoutChangeHandler=null}async initializeDriver(){const e=v.driver?.js?.driver||v.driver?.driver||v.driver;if(typeof e!="function")return console.error("[TourManager] Driver.js not loaded or invalid. window.driver:",v.driver),!1;const t=this.themeAdapter.getDriverTheme();return this.driver=e({showProgress:!0,showButtons:["next","previous","close"],allowClose:!0,overlayClickNext:!1,overlayOpacity:.6,stagePadding:12,stageRadius:12,allowKeyboardControl:!0,popoverClass:"dashcaddy-popover",animate:!0,smoothScroll:!0,onDestroyed:()=>this.onTourComplete(),onDestroyStarted:()=>{this.progressTracker.isTourCompleted()||this.onTourSkip()}}),this.themeAdapter.applyTheme(this.driver),this.themeAdapter.onThemeChange(()=>{this.themeAdapter.applyTheme(this.driver)}),this.setupDynamicRepositioning(),!0}shouldAutoStart(){return!this.progressTracker.isInstallOnboardingCompleted()&&!this.progressTracker.isTourCompleted()&&this.progressTracker.getCurrentStep()===0}async startTour(){if(!this.driver&&!await this.initializeDriver())return;const e=v.TooltipDefinitions.getSortedTooltips(),t=this.progressTracker.getCompletedTooltips(),a=e.filter(y=>!t.includes(y.id));if(a.length===0){console.log("[TourManager] No tooltips to show"),this.progressTracker.markTourCompleted();return}const p=a.map((y,m)=>{const A=m===0,L=m===a.length-1,E={element:y.element,popover:{title:y.popover.title,description:y.popover.description,side:y.popover.position||"bottom",align:y.popover.align||"start",showButtons:this._getButtonsForStep(y,A,L),showProgress:y.popover.showProgress!==!1,onNextClick:()=>{this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()},onPrevClick:()=>{this.progressTracker.setCurrentStep(Math.max(0,m-1)),this.currentStepIndex=Math.max(0,m-1),this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}};return y.id==="dns-priority"&&this.dnsTemplateSelector&&(E.popover.onSetupNowClick=()=>{console.log("[TourManager] Opening DNS template selector"),this.dnsTemplateSelector.showTemplateSelector(),this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()},E.popover.onLaterClick=()=>{console.log("[TourManager] DNS setup deferred"),this.progressTracker.markDnsSetupDeferred(),this.progressTracker.markTooltipCompleted(y.id),this.progressTracker.setCurrentStep(m+1),this.currentStepIndex=m+1,this.driver.moveNext()}),E});this.isActive=!0,this.driver.setSteps(p),this.driver.drive()}async resumeTour(){this.progressTracker.getCurrentStep()>0?await this.startTour():await this.startTour()}skipTour(){this.driver&&this.driver.destroy(),this.cleanupDynamicRepositioning(),this.isActive=!1}async restartTour(){this.progressTracker.resetProgress(),await this.startTour()}async showTooltip(e){const t=v.TooltipDefinitions.getTooltipById(e);if(!t){console.error(`[TourManager] Tooltip not found: ${e}`);return}this.driver||await this.initializeDriver();const a={element:t.element,popover:{title:t.popover.title,description:t.popover.description,side:t.popover.position||"bottom",align:t.popover.align||"start"}};this.driver.highlight(a)}async showWhatsNew(){if(!this.driver&&!await this.initializeDriver())return;const e=v.TooltipDefinitions.getNewFeatureTooltips();if(e.length===0){console.log("[TourManager] No new features to show");return}console.log(`[TourManager] Showing ${e.length} new features`);const t=e.map((a,p)=>{const y=p===0,m=p===e.length-1;return{element:a.element,popover:{title:`\u2728 NEW: ${a.popover.title}`,description:a.popover.description,side:a.popover.position||"bottom",align:a.popover.align||"start",showButtons:this._getButtonsForStep(a,y,m),showProgress:!0,onNextClick:()=>{this.driver.moveNext()},onPrevClick:()=>{this.driver.movePrevious()},onCloseClick:()=>{this.skipTour()}}}});this.isActive=!0,this.driver.setSteps(t),this.driver.drive()}setupDynamicRepositioning(){let e;this.resizeHandler=()=>{clearTimeout(e),e=setTimeout(()=>{this.isActive&&this.driver&&(console.log("[TourManager] Window resized, repositioning tooltip"),this.driver.refresh())},150)},this.layoutChangeHandler=()=>{this.isActive&&this.driver&&(console.log("[TourManager] Layout changed, repositioning tooltip"),setTimeout(()=>{this.driver&&this.driver.refresh()},100))},v.addEventListener("resize",this.resizeHandler),this.themeAdapter.onThemeChange(this.layoutChangeHandler)}cleanupDynamicRepositioning(){ Line truncated
Possible issues:
- Driver.js library failed to load
- JavaScript errors during initialization`))},E=document.getElementById("restart-tour-btn");if(E){E.onclick=L;return}const d=document.createElement("button");d.id="restart-tour-btn",d.textContent="Help Tour",d.title="Restart the onboarding tour",d.onclick=L,A.appendChild(d)}function y(){return typeof(window.driver?.js?.driver||window.driver?.driver||window.driver)!="function"?(console.warn("[Onboarding] Driver.js not loaded yet, will retry... window.driver:",window.driver),!1):!0}function m(){let A=0;const L=10;function E(){y()?a():(A++,A<L?setTimeout(E,500):(console.error("[Onboarding] Driver.js failed to load after multiple attempts"),t?t.handleDriverLoadFailure():new ErrorHandler().handleDriverLoadFailure()))}E()}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",m):m(),console.log("[Onboarding] System loaded")})();