[grade=A] fix: sync frontend i18n.js to 31 languages, fix race conditions and stale translation bug
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

- Expand SUPPORTED_LANGS from 5 to all 31 languages matching backend
- Expand LANG_NAMES to include all 31 native language names
- Add RTL_LANGS set (ar, fa, ur) for multi-language RTL support
- Fix applyTranslations() to always write resolved value (clears stale
  translations when switching back to English)
- Fix loadTranslations() to clear translations on fetch failure/error
- Add monotonic _langRequestId token to prevent out-of-order async
  resolution race (A->B->A scenario)
- Wrap localStorage access in try/catch for privacy mode environments
- Validate stored language code against SUPPORTED_LANGS on init
- Always set document.documentElement.dir/lang on init (not just RTL)
- Add scrollable dropdown for 31 languages (max-height: 320px)
- Fix backend i18n route to use LANGUAGE_META instead of hardcoded 5-lang map
- Rebuild dist bundles

Codex grade: A (urn:ump:kicey7d7dnockmlk547cmm4qbdvygcj3g6waasrqv2yckbzem5bq)
1775/1775 tests pass
This commit is contained in:
Hermes committed 2026-08-13 13:22:31 -07:00
1 parent 3b3c4f8b8e
commit 56c976a935
4 files changed
+253 -217

No files matched your search

+170 -170
View File
@@ -90,19 +90,19 @@
<button id="logo-save" class="btn-accent">Save</button>
</div>
</div>
</div>`);const b=document.getElementById("logo-modal"),L=document.getElementById("logo-preview-dark"),R=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),$=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),N=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),B=document.querySelector("#brand .brand-logo-light"),S=document.querySelector(".top-row"),H=document.getElementById("dashboard-title"),C=DC.NAME;let y=null,O=null,h=null,z="left",A=C;$?.addEventListener("change",()=>{$.checked?(P.style.display="none",N.style.display="",y=null,O=null):(P.style.display="flex",N.style.display="none",h=null)});function j(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{j(t.target.files[0],e=>{y=e,L.src=e,I.textContent="New dark logo ready to save"})}),v?.addEventListener("change",t=>{j(t.target.files[0],e=>{O=e,R.src=e,I.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{j(t.target.files[0],e=>{h=e,L.src=e,R.src=e,I.textContent="New logo ready to save (both themes)"})});function u(t){S.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function m(t){A=t||C,document.title=A;const e=document.querySelector(".dashboard-title");e&&(e.textContent=A)}async function x(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(w.src=e.customLogoDark,L.src=e.customLogoDark),e.customLogoLight&&(B.src=e.customLogoLight,R.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(w.src=e.customLogo,B.src=e.customLogo,L.src=e.customLogo,R.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(z=e.position,u(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{z=t.dataset.pos,u(z)})}),document.getElementById("brand")?.addEventListener("click",()=>{y=null,O=null,h=null,D&&(D.value=""),v&&(v.value=""),T&&(T.value=""),$&&($.checked=!1),P.style.display="flex",N.style.display="none",L.src=w.src,R.src=B.src;const t=w.src.includes("custom-logo")||B.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",u(z),H.value=A,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=H.value.trim()||C,e={position:z,dashboardTitle:t};$?.checked&&h?(e.dataDark=h,e.dataLight=h):(y&&(e.dataDark=y),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(w.src=o.pathDark+i,L.src=o.pathDark+i),o.pathLight&&(B.src=o.pathLight+i,R.src=o.pathLight+i),u(z),m(t),b.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
</div>`);const h=document.getElementById("logo-modal"),L=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),D=document.getElementById("logo-same-both"),R=document.getElementById("logo-dual-uploads"),H=document.getElementById("logo-single-upload"),P=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),C=document.getElementById("logo-upload-single"),x=document.querySelector("#brand .brand-logo-dark"),$=document.querySelector("#brand .brand-logo-light"),E=document.querySelector(".top-row"),M=document.getElementById("dashboard-title"),S=DC.NAME;let B=null,U=null,T=null,b="left",N=S;D?.addEventListener("change",()=>{D.checked?(R.style.display="none",H.style.display="",B=null,U=null):(R.style.display="flex",H.style.display="none",T=null)});function O(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}P?.addEventListener("change",t=>{O(t.target.files[0],e=>{B=e,L.src=e,I.textContent="New dark logo ready to save"})}),v?.addEventListener("change",t=>{O(t.target.files[0],e=>{U=e,A.src=e,I.textContent="New light logo ready to save"})}),C?.addEventListener("change",t=>{O(t.target.files[0],e=>{T=e,L.src=e,A.src=e,I.textContent="New logo ready to save (both themes)"})});function p(t){E.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function u(t){N=t||S,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function f(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(x.src=e.customLogoDark,L.src=e.customLogoDark),e.customLogoLight&&($.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(x.src=e.customLogo,$.src=e.customLogo,L.src=e.customLogo,A.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(b=e.position,p(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{b=t.dataset.pos,p(b)})}),document.getElementById("brand")?.addEventListener("click",()=>{B=null,U=null,T=null,P&&(P.value=""),v&&(v.value=""),C&&(C.value=""),D&&(D.checked=!1),R.style.display="flex",H.style.display="none",L.src=x.src,A.src=$.src;const t=x.src.includes("custom-logo")||$.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",p(b),M.value=N,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=M.value.trim()||S,e={position:b,dashboardTitle:t};D?.checked&&T?(e.dataDark=T,e.dataLight=T):(B&&(e.dataDark=B),U&&(e.dataLight=U));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(x.src=o.pathDark+i,L.src=o.pathDark+i),o.pathLight&&($.src=o.pathLight+i,A.src=o.pathLight+i),p(b),u(t),h.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",B.src="/assets/dashcaddy-logo-light.png",L.src="/assets/dashcaddy-logo-dark.png",R.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",y=null,O=null,h=null,H.value=C,m(C),z="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),d=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),M=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function k(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),M.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}r?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const a=new FileReader;a.onload=o=>{d=o.target.result,g.src=d,M.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(t.ok){const e=await t.json();c.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),M.textContent="Using custom favicon",d=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),k(),x();const f=document.getElementById("settings-timezone");f&&(new MutationObserver(()=>{b.classList.contains("show")&&f.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(f,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=f.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(b,L){const R=Intl.supportedValuesOf("timeZone"),I=L||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const $ of R){const P=document.createElement("option");P.value=$,P.textContent=$.replace(/_/g," "),$===I&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",L=null;async function R(){try{const m=await fetch("/api/v1/config");if(m.ok&&(L=await m.json(),L&&L.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(m){console.warn("Could not fetch server config, checking localStorage fallback:",m.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}R();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function $(u){document.querySelectorAll(".setup-step").forEach(x=>{x.style.display="none"});const m=document.getElementById(u);m&&(m.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let m='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",M=document.getElementById("setup-ca-name")?.value?.trim()||"",r=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;m+=`
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(x.src="/assets/dashcaddy-logo-dark.png",$.src="/assets/dashcaddy-logo-light.png",L.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",B=null,U=null,T=null,M.value=S,u(S),b="left",p("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),d=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),z=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function w(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),z.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}r?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const a=new FileReader;a.onload=o=>{d=o.target.result,g.src=d,z.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(t.ok){const e=await t.json();c.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),z.textContent="Using custom favicon",d=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),w(),f();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{h.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(h,L){const A=Intl.supportedValuesOf("timeZone"),I=L||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const D of A){const R=document.createElement("option");R.value=D,R.textContent=D.replace(/_/g," "),D===I&&(R.selected=!0),h.appendChild(R)}},(function(){let h="homelab",L=null;async function A(){try{const u=await fetch("/api/v1/config");if(u.ok&&(L=await u.json(),L&&L.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(u){console.warn("Could not fetch server config, checking localStorage fallback:",u.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function D(p){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const u=document.getElementById(p);u&&(u.style.display="block")}function R(){const p=document.getElementById("setup-summary-content");if(!p)return;let u='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",z=document.getElementById("setup-ca-name")?.value?.trim()||"",r=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;u+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>TLD:</strong> ${g}</div>
<div><strong>Certificate Authority:</strong> ${M}</div>
<div><strong>Certificate Authority:</strong> ${z}</div>
<div><strong>DNS Server:</strong> ${r}:${c}</div>
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div>
</div>
</div>
`}else if(b==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";m+=`
`}else if(h==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";u+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
@@ -112,22 +112,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div>
</div>
</div>
`}else if(b==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",M=document.getElementById("setup-public-email")?.value?.trim()||"",r=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=r==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;m+=`
`}else if(h==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",z=document.getElementById("setup-public-email")?.value?.trim()||"",r=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=r==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;u+=`
<div>
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
<div><strong>Domain:</strong> ${g}</div>
<div><strong>SSL:</strong> Let's Encrypt</div>
<div><strong>Email:</strong> ${M}</div>
<div><strong>Email:</strong> ${z}</div>
<div><strong>Routing:</strong> ${r==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
<div><strong>Example URLs:</strong> ${c}</div>
</div>
</div>
`}const x=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";m+=`
`}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";u+=`
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${x.replace(/_/g," ")}</div>
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
</div>
`,m+="</div>",u.innerHTML=m,$("setup-step-summary")}async function N(u){try{const m=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return m.ok?(await m.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${m.status}`),{function:"saveConfigToServer"}),!1)}catch(m){return errorHandler.logError("[SetupWizard] Save Config",m,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(b==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const M=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:M,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else b==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):b==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const m=await N(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const x=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",g=m?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${x}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(u){u.preventDefault();const m=document.querySelector('input[name="config-type"]:checked');m&&(b=m.value),$(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await N({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(u){const m=u.target.value||".home",x=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");x&&(x.textContent=m),g&&(g.textContent=m)});const B=document.getElementById("setup-homelab-back");B&&(B.onclick=function(u){u.preventDefault(),$("setup-step-1")});const S=document.getElementById("setup-homelab-next");S&&(S.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-tld")?.value?.trim()||"",x=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!m||!m.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!x){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const H=document.getElementById("setup-simple-back");H&&(H.onclick=function(u){u.preventDefault(),$("setup-step-1")});const C=document.getElementById("setup-simple-next");C&&(C.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var m=document.getElementById("dns-requirement-note");m&&(m.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const y=document.getElementById("setup-public-back");y&&(y.onclick=function(u){u.preventDefault(),$("setup-step-1")});const O=document.getElementById("setup-public-next");O&&(O.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-public-domain")?.value?.trim()||"",x=document.getElementById("setup-public-email")?.value?.trim()||"";if(!m){showNotification("Please enter your domain name","warning");return}if(!x||!x.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const h=document.getElementById("setup-summary-back");h&&(h.onclick=function(u){u.preventDefault(),b==="homelab"?$("setup-step-homela Line truncated
`,u+="</div>",p.innerHTML=u,D("setup-step-summary")}async function H(p){try{const u=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(p)});return u.ok?(await u.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${u.status}`),{function:"saveConfigToServer"}),!1)}catch(u){return errorHandler.logError("[SetupWizard] Save Config",u,{function:"saveConfigToServer"}),!1}}async function P(){const p={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(h==="homelab"){p.tld=document.getElementById("setup-tld")?.value?.trim()||".home",p.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const z=document.getElementById("setup-dns-provider")?.value||"technitium";p.dns={provider:z,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},p.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else h==="simple"?(p.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",p.defaults={dnsType:"none",sslType:"none",targetIP:p.defaultIP}):h==="public"&&(p.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",p.email=document.getElementById("setup-public-email")?.value?.trim()||"",p.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p.defaults={dnsType:p.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const u=await H(p);safeSet("dashcaddy-config",JSON.stringify(p)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",g=u?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(p){p.preventDefault();const u=document.querySelector('input[name="config-type"]:checked');u&&(h=u.value),D(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const C=document.getElementById("setup-skip");C&&(C.onclick=async function(p){p.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await H({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const x=document.getElementById("setup-tld");x&&(x.oninput=function(p){const u=p.target.value||".home",f=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");f&&(f.textContent=u),g&&(g.textContent=u)});const $=document.getElementById("setup-homelab-back");$&&($.onclick=function(p){p.preventDefault(),D("setup-step-1")});const E=document.getElementById("setup-homelab-next");E&&(E.onclick=function(p){p.preventDefault();const u=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!u||!u.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}R()});const M=document.getElementById("setup-simple-back");M&&(M.onclick=function(p){p.preventDefault(),D("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(p){p.preventDefault(),R()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(p){p.onchange=function(){var u=document.getElementById("dns-requirement-note");u&&(u.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const B=document.getElementById("setup-public-back");B&&(B.onclick=function(p){p.preventDefault(),D("setup-step-1")});const U=document.getElementById("setup-public-next");U&&(U.onclick=function(p){p.preventDefault();const u=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!u){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}R()});const T=document.getElementById("setup-summary-back");T&&(T.onclick=function(p){p.preventDefault(),h==="homelab"?D("setup-step-homela Line truncated
<div class="app-selector-content">
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
<div id="app-selector-grid" class="app-selector-grid"></div>
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
</button>
</div>
</div>
</div>`);const L="custom-apps";let R=null,I=null;const $=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function N(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return R=c.templates,I=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function D(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(r){try{const d=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function T(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!R&&!await N()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[d,k]of Object.entries(R)){const f=k.category||"Other";r[f]||(r[f]=[]),r[f].push({id:d,...k})}const c=I?Object.keys(I):Object.keys(r).sort();for(const d of c){const k=r[d];if(!k||k.length===0)continue;k.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const f=document.createElement("div");f.className="app-category-header";const t=I?.[d]||{};f.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(d)}`,t.color&&(f.style.borderBottomColor=t.color),P.appendChild(f),k.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
</div>`);const L="custom-apps";let A=null,I=null;const D=document.getElementById("app-selector-modal"),R=document.getElementById("app-selector-grid");async function H(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return A=c.templates,I=c.categories,!0}catch(r){h.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function P(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return h.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(r){try{const d=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){h.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function C(){if(R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!await H()){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}R.innerHTML="";const r={};for(const[d,w]of Object.entries(A)){const y=w.category||"Other";r[y]||(r[y]=[]),r[y].push({id:d,...w})}const c=I?Object.keys(I):Object.keys(r).sort();for(const d of c){const w=r[d];if(!w||w.length===0)continue;w.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const t=I?.[d]||{};y.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(d)}`,t.color&&(y.style.borderBottomColor=t.color),R.appendChild(y),w.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",s=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
<div class="app-option-name">${escapeHtml(e.name)}</div>
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
${n}${s}
`,o?a.onclick=()=>w(e,a):a.onclick=()=>B(e),P.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function w(r,c){const d="widget-"+r.id+"-enabled",f=!(safeGet(d)!=="false");safeSet(d,String(f));const t=r.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=f?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=f?"ON":"OFF",e.style.background=f?"#2ecc7130":"#e74c3c30",e.style.color=f?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${f?"enabled":"disabled"}`,"success",2e3)}async function B(r){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),k=document.getElementById("deploy-subdomain"),f=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${r.name} container:
`,o?a.onclick=()=>x(e,a):a.onclick=()=>$(e),R.appendChild(a)})}window.renderRecipeCards&&await window.renderRecipeCards(R)}function x(r,c){const d="widget-"+r.id+"-enabled",y=!(safeGet(d)!=="false");safeSet(d,String(y));const t=r.widgetSelector;if(t){const a=document.querySelector(t);a&&(a.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function $(r){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),w=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),t=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),a=document.getElementById("deploy-tailscale-only"),o=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${r.name} container:
Container: ${V.name}
Status: ${V.status}
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
Would you like to use this existing container?
Click OK to configure DNS/Caddy for the existing container.
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}d.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");k.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const G=r.subpathSupport||"strip";G==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):G==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),p=document.querySelector(`input[name="dns-type"][value="${s}"]`),E=document.querySelector(`input[name="ssl-type"][value="${l}"]`);p?p.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,E?E.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const G=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1"),W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const G=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){G.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},W.appendChild(Z)})}else G.style.display="none"}catch{G.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const G=r.mediaMount?.containerPath,W=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(G&&V.endsWith(":"+G)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}d.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");w.value=i;const n=document.getElementById("subpath-compat-warning");if(n)if(SITE.routingMode==="subdirectory"){const G=r.subpathSupport||"strip";G==="none"?(n.style.display="block",n.innerHTML='<span style="color: #ff9800;">&#9888; <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):G==="strip"?(n.style.display="block",n.innerHTML='<span style="color: var(--muted);">&#9432; '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):n.style.display="none"}else n.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),m=document.querySelector(`input[name="dns-type"][value="${s}"]`),k=document.querySelector(`input[name="ssl-type"][value="${l}"]`);m?m.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,k?k.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,t.value=SITE.defaults.targetIP||"localhost",a.checked=!1;const j=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const G=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1"),W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const G=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){G.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},W.appendChild(Z)})}else G.style.display="none"}catch{G.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const G=r.mediaMount?.containerPath,W=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(G&&V.endsWith(":"+G)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const G=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await D(G);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(G))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const G=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await P(G);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(G))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
<span style="color: #e74c3c;">Port ${escapeHtml(G)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?o.innerHTML=`
<span style="color: #4caf50;">Connected</span>
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
`:W.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const G=k.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${G}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(G)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||G;K=SITE.domain?`${te}://${G}.${SITE.domain}`:`${te}://${G}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}f.textContent=K}k.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(G=>{G.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(G=>{G.onchange=ae}),ae(),$.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function S(r){const c=r.appTemplate,d=safeGetJSON(L,[]),k=c._useExisting&&c._existingContainer,f=d.find(t=>t.id===r.subdomain);if(!(f&&!k&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(f){const t=d.indexOf(f);d.splice(t,1),safeSet(L,JSON.stringify(d))}if(k)r.port=c._existingContainer.primaryPort;else{const t=r.port||c.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await v(c.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
`:W.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const G=w.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${G}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(G)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||G;K=SITE.domain?`${te}://${G}.${SITE.domain}`:`${te}://${G}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}y.textContent=K}w.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(G=>{G.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(G=>{G.onchange=ae}),ae(),D.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function E(r){const c=r.appTemplate,d=safeGetJSON(L,[]),w=c._useExisting&&c._existingContainer,y=d.find(t=>t.id===r.subdomain);if(!(y&&!w&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(y){const t=d.indexOf(y);d.splice(t,1),safeSet(L,JSON.stringify(d))}if(w)r.port=c._existingContainer.primaryPort;else{const t=r.port||c.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await P(t);if(!e.available){const a=await v(c.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
Would you like to use port ${a} instead?`))r.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(k?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const t={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};k&&(t.config.useExisting=!0,t.config.existingContainerId=c._existingContainer.id,t.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(t.config.port=c._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:a.containerId,url:a.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};d.push(o),safeSet(L,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${c.name} configured with existing container!
Would you like to use port ${a} instead?`))r.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(w?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const t={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};w&&(t.config.useExisting=!0,t.config.existingContainerId=c._existingContainer.id,t.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(t.config.port=c._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:a.containerId,url:a.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};d.push(o),safeSet(L,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.id===c.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${c.name} configured with existing container!
URL: ${a.url}`:`${c.name} deployed successfully!
URL: ${a.url}`;a.warning&&(i+=`
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,a.url&&a.url.startsWith("https://")&&H(a.url,c.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){b.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${t.message}`,"error",8e3)}}}async function H(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const k=12,f=async()=>{d++;try{const t=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<k?setTimeout(f,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(f,3e3)}function C(){safeGetJSON(L,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),$.classList.add("show")}),wireModal($,document.getElementById("app-selector-cancel"));const y=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{y.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(y.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(f=>{d.push({hostPath:f.value.trim(),containerPath:f.dataset.containerPath})});const k={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!k.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}y.classList.remove("show"),S(k)}),wireModal(y);const O=document.getElementById("folder-browser-modal"),h=document.getElementById("folder-browser-path"),z=document.getElementById("folder-browser-list"),A=document.getElementById("folder-browser-selected"),j=document.getElementById("folder-browser-selected-list");let u="",m=[],x=null;window.openFolderBrowser=function(r){x=r,m=r.value.split(",").map(c=>c.trim()).filter(c=>c),u="",M(),g(""),O.classList.add("show")};async function g(r){h.textContent=r||"Select a drive...",z.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!d.success){z.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}u=d.path||"",h.textContent=u||"Select a drive...";let k="";d.parent&&d.parent!==d.path&&(k+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,a.url&&a.url.startsWith("https://")&&M(a.url,c.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){h.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${t.message}`,"error",8e3)}}}async function M(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const w=12,y=async()=>{d++;try{const t=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<w?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(L,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{C(),D.classList.add("show")}),wireModal(D,document.getElementById("app-selector-cancel"));const B=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{B.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(B.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{d.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const w={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!w.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}B.classList.remove("show"),E(w)}),wireModal(B);const U=document.getElementById("folder-browser-modal"),T=document.getElementById("folder-browser-path"),b=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let p="",u=[],f=null;window.openFolderBrowser=function(r){f=r,u=r.value.split(",").map(c=>c.trim()).filter(c=>c),p="",z(),g(""),U.classList.add("show")};async function g(r){T.textContent=r||"Select a drive...",b.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(r)}`)).json();if(!d.success){b.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}p=d.path||"",T.textContent=p||"Select a drive...";let w="";d.parent&&d.parent!==d.path&&(w+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
<span style="color: var(--muted);">.. Parent Directory</span>
</div>`),d.items.length===0&&!d.parent?k+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?k+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(f=>{const t=f.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(f.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";k+=`<div class="folder-item" data-path="${escapeHtml(f.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
</div>`),d.items.length===0&&!d.parent?w+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?w+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(y=>{const t=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=u.includes(y.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";w+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
<span style="font-size: 1.2rem;">${t}</span>
<span style="flex: 1;">${escapeHtml(f.name)}</span>
<span style="flex: 1;">${escapeHtml(y.name)}</span>
${e?'<span style="color: var(--success);">\u2713</span>':""}
</div>`}),z.innerHTML=k,z.querySelectorAll(".folder-item").forEach(f=>{f.addEventListener("click",()=>{g(f.dataset.path)}),f.addEventListener("mouseenter",()=>{f.style.background="var(--card-bg)"}),f.addEventListener("mouseleave",()=>{const t=m.includes(f.dataset.path);f.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){z.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function M(){if(m.length===0){A.style.display="none";return}A.style.display="block",j.innerHTML=m.map(r=>`
</div>`}),b.innerHTML=w,b.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{g(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const t=u.includes(y.dataset.path);y.style.background=t?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){b.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function z(){if(u.length===0){N.style.display="none";return}N.style.display="block",O.innerHTML=u.map(r=>`
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
${escapeHtml(r)}
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
</span>
`).join("")}window.removeSelectedFolder=function(r){m=m.filter(c=>c!==r),M(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!m.includes(u)&&(m.push(u),M(),g(u))}),wireModal(O,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{x&&(x.value=m.join(", ")),O.classList.remove("show")}),C()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
`).join("")}window.removeSelectedFolder=function(r){u=u.filter(c=>c!==r),z(),g(p)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{p&&!u.includes(p)&&(u.push(p),z(),g(p))}),wireModal(U,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{f&&(f.value=u.join(", ")),U.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
@@ -445,38 +445,38 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="recipe-next" class="btn-accent-solid">Next</button>
</div>
</div>
</div>`);let b=null,L=null,R=null,I=1,$=!1;const P=document.getElementById("recipe-deploy-modal"),N=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(P,N);async function T(){try{const u=await fetch("/api/v1/recipes/templates"),m=await u.json();if(m.success)return b=m.templates,L=m.categories,!0;if(u.status===403)return $=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function w(){try{$=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{$=!1}return $}window.renderRecipeCards=async function(u){await w();let m;if($&&b?m=b:m=B(),!m||m.length===0)return;const x=document.createElement("div");x.className="app-category-header",x.innerHTML="\u{1F9EA} Recipes",x.style.borderBottomColor="#8e44ad",u.appendChild(x);const g=Array.isArray(m)?m:Object.values(m);g.sort((M,r)=>(r.popularity||0)-(M.popularity||0));for(const M of g){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${M.componentCount||M.components?.length||"?"} apps</div>`,d=$?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
</div>`);let h=null,L=null,A=null,I=1,D=!1;const R=document.getElementById("recipe-deploy-modal"),H=document.getElementById("recipe-cancel"),P=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(R,H);async function C(){try{const p=await fetch("/api/v1/recipes/templates"),u=await p.json();if(u.success)return h=u.templates,L=u.categories,!0;if(p.status===403)return D=!1,!1}catch(p){console.warn("Failed to fetch recipe templates:",p.message)}return!1}async function x(){try{D=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{D=!1}return D}window.renderRecipeCards=async function(p){await x();let u;if(D&&h?u=h:u=$(),!u||u.length===0)return;const f=document.createElement("div");f.className="app-category-header",f.innerHTML="\u{1F9EA} Recipes",f.style.borderBottomColor="#8e44ad",p.appendChild(f);const g=Array.isArray(u)?u:Object.values(u);g.sort((z,r)=>(r.popularity||0)-(z.popularity||0));for(const z of g){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${z.componentCount||z.components?.length||"?"} apps</div>`,d=D?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
${d}
<div class="app-option-icon">${escapeHtml(M.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(M.name)}</div>
<div class="app-option-desc">${escapeHtml(M.description||"")}</div>
<div class="app-option-icon">${escapeHtml(z.icon||"\u{1F9EA}")}</div>
<div class="app-option-name">${escapeHtml(z.name)}</div>
<div class="app-option-desc">${escapeHtml(z.description||"")}</div>
${c}
`,r.onclick=()=>{if(!$){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}S(M)},u.appendChild(r)}};function B(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function S(u){R=u,I=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,H(),C(),P.classList.add("show")}function H(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const m=parseInt(u.dataset.step);u.classList.toggle("active",m===I),u.classList.toggle("completed",m<I)});for(let u=1;u<=4;u++){const m=document.getElementById(`recipe-step-${u}`);m&&(m.style.display=u===I?"":"none")}D.style.display=I>1&&I<4?"":"none",I===4?(v.style.display="none",N.textContent="Close"):I===3?(v.textContent="\u{1F680} Deploy",v.style.display="",N.textContent="Cancel"):(v.textContent="Next",v.style.display="",N.textContent="Cancel")}function C(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const m=R.components||[];for(const x of m){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const M=x.required,r=x.internal;g.innerHTML=`
<input type="checkbox" ${M?"checked disabled":"checked"} data-component-id="${x.id}"
`,r.onclick=()=>{if(!D){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}E(z)},p.appendChild(r)}};function $(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function E(p){A=p,I=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${p.name}`,M(),S(),R.classList.add("show")}function M(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(p=>{const u=parseInt(p.dataset.step);p.classList.toggle("active",u===I),p.classList.toggle("completed",u<I)});for(let p=1;p<=4;p++){const u=document.getElementById(`recipe-step-${p}`);u&&(u.style.display=p===I?"":"none")}P.style.display=I>1&&I<4?"":"none",I===4?(v.style.display="none",H.textContent="Close"):I===3?(v.textContent="\u{1F680} Deploy",v.style.display="",H.textContent="Cancel"):(v.textContent="Next",v.style.display="",H.textContent="Cancel")}function S(){const p=document.getElementById("recipe-component-list");p.innerHTML="";const u=A.components||[];for(const f of u){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const z=f.required,r=f.internal;g.innerHTML=`
<input type="checkbox" ${z?"checked disabled":"checked"} data-component-id="${f.id}"
style="width: 18px; height: 18px; accent-color: var(--accent);" />
<div style="flex: 1;">
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(x.role||x.id)}</div>
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(f.role||f.id)}</div>
<div style="font-size: 0.78rem; color: var(--muted);">
${x.templateRef?escapeHtml(x.templateRef):"Built-in"}
${M?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${f.templateRef?escapeHtml(f.templateRef):"Built-in"}
${z?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
</div>
${x.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(x.note)}</div>`:""}
${f.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(f.note)}</div>`:""}
</div>
`,u.appendChild(g)}}function y(){const u=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),x=R.sharedVolumes;if(x&&Object.keys(x).length>0){u.style.display="",m.innerHTML="";for(const[g,M]of Object.entries(x)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(M.label||g)}</label>
<input type="text" data-volume-key="${g}" value="${escapeHtml(M.defaultPath||"")}"
placeholder="${escapeHtml(M.defaultPath||"/path")}"
`,p.appendChild(g)}}function B(){const p=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),f=A.sharedVolumes;if(f&&Object.keys(f).length>0){p.style.display="",u.innerHTML="";for(const[g,z]of Object.entries(f)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(z.label||g)}</label>
<input type="text" data-volume-key="${g}" value="${escapeHtml(z.defaultPath||"")}"
placeholder="${escapeHtml(z.defaultPath||"/path")}"
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
<div class="form-hint-sm">${escapeHtml(M.description||"")}</div>
`,m.appendChild(r)}}else u.style.display="none"}function O(){const u=document.getElementById("recipe-review-content"),m=h(),x=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};x.forEach(d=>{g[d.dataset.volumeKey]=d.value});const M=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(R.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(R.description||"")}</div>
<div class="form-hint-sm">${escapeHtml(z.description||"")}</div>
`,u.appendChild(r)}}else p.style.display="none"}function U(){const p=document.getElementById("recipe-review-content"),u=T(),f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),g={};f.forEach(d=>{g[d.dataset.volumeKey]=d.value});const z=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;p.innerHTML=`
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(A.name)}</div>
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(A.description||"")}</div>
<div style="margin-bottom: 12px;">
<strong>Components (${m.length}):</strong>
<strong>Components (${u.length}):</strong>
<div style="display: grid; gap: 4px; margin-top: 6px;">
${m.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
${u.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
\u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
</div>`).join("")}
</div>
@@ -484,31 +484,31 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
${Object.keys(g).length>0?`<div style="margin-bottom: 12px;">
<strong>Volumes:</strong>
${Object.entries(g).map(([d,k])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(k)}</div>`).join("")}
${Object.entries(g).map(([d,w])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(w)}</div>`).join("")}
</div>`:""}
<div style="font-size: 0.85rem; color: var(--muted);">
Timezone: ${escapeHtml(M)} &bull; IP: ${escapeHtml(r)} ${c?"&bull; Tailscale only":""}
Timezone: ${escapeHtml(z)} &bull; IP: ${escapeHtml(r)} ${c?"&bull; Tailscale only":""}
</div>
${R.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(R.network.name)}</code></div>`:""}
`}function h(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;u.forEach(g=>{g.checked&&m.add(g.dataset.componentId)});const x=R.components||[];return x.filter(g=>g.required).forEach(g=>m.add(g.id)),x.filter(g=>m.has(g.id))}async function z(){const u=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",u.innerHTML="";const x=h();for(const c of x){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
${A.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(A.network.name)}</code></div>`:""}
`}function T(){const p=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;p.forEach(g=>{g.checked&&u.add(g.dataset.componentId)});const f=A.components||[];return f.filter(g=>g.required).forEach(g=>u.add(g.id)),f.filter(g=>u.has(g.id))}async function b(){const p=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",p.innerHTML="";const f=T();for(const c of f){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
`,u.appendChild(d)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),M={};g.forEach(c=>{M[c.dataset.volumeKey]=c.value});const r={selectedComponents:x.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:M},componentOverrides:{}};for(const c of x)A(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:R.id,config:r})})).json();if(d.success){for(const k of d.deployed||[])A(k.id,"success",k.url?`Running \u2192 ${k.url}`:"Running");for(const k of d.errors||[])A(k.componentId,"error",k.error);m.style.display="",m.innerHTML=`
`,p.appendChild(d)}const g=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),z={};g.forEach(c=>{z[c.dataset.volumeKey]=c.value});const r={selectedComponents:f.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:z},componentOverrides:{}};for(const c of f)N(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:A.id,config:r})})).json();if(d.success){for(const w of d.deployed||[])N(w.id,"success",w.url?`Running \u2192 ${w.url}`:"Running");for(const w of d.errors||[])N(w.componentId,"error",w.error);u.style.display="",u.innerHTML=`
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
<strong>Setup tips:</strong>
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(k=>`<li>${escapeHtml(k)}</li>`).join("")}</ul>
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(w=>`<li>${escapeHtml(w)}</li>`).join("")}</ul>
</div>`:""}
</div>
`,showNotification(`${R.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
`,showNotification(`${A.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
<strong>Network error:</strong> ${escapeHtml(c.message)}
</div>`}}function A(u,m,x){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const M=g.querySelector(".recipe-progress-icon"),r=g.querySelector(".recipe-progress-status");m==="deploying"?(M.textContent="\u23F3",r.style.color="var(--accent)"):m==="success"?(M.textContent="\u2705",r.style.color="var(--ok-fg)"):m==="error"&&(M.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=x}v.addEventListener("click",()=>{if(I===3){I=4,H(),z();return}I<3&&(I++,H(),I===2&&y(),I===3&&O())}),D.addEventListener("click",()=>{I>1&&I<4&&(I--,H())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const m={};u.forEach(x=>{const g=x.dataset.recipeId;m[g]||(m[g]=[]),m[g].push(x)});for(const[x,g]of Object.entries(m))g.length<2||g.forEach((M,r)=>{if(M.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=M.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=x.replace(/-/g," "),M.style.position="relative",M.appendChild(c))}})},window.manageRecipe=async function(u,m){const x=`/api/v1/recipes/${u}/${m}`,g=m==="remove"?"DELETE":"POST",M=m==="remove"?`/api/v1/recipes/${u}`:x;if(!(m==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(M,{method:g})).json();c.success?(showNotification(`Recipe ${m}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const j=document.createElement("style");j.textContent=`
</div>`}}function N(p,u,f){const g=document.getElementById(`recipe-progress-${p}`);if(!g)return;const z=g.querySelector(".recipe-progress-icon"),r=g.querySelector(".recipe-progress-status");u==="deploying"?(z.textContent="\u23F3",r.style.color="var(--accent)"):u==="success"?(z.textContent="\u2705",r.style.color="var(--ok-fg)"):u==="error"&&(z.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=f}v.addEventListener("click",()=>{if(I===3){I=4,M(),b();return}I<3&&(I++,M(),I===2&&B(),I===3&&U())}),P.addEventListener("click",()=>{I>1&&I<4&&(I--,M())}),window.groupRecipeCards=function(){const p=document.querySelectorAll(".service-card[data-recipe-id]");if(p.length===0)return;const u={};p.forEach(f=>{const g=f.dataset.recipeId;u[g]||(u[g]=[]),u[g].push(f)});for(const[f,g]of Object.entries(u))g.length<2||g.forEach((z,r)=>{if(z.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=z.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=f.replace(/-/g," "),z.style.position="relative",z.appendChild(c))}})},window.manageRecipe=async function(p,u){const f=`/api/v1/recipes/${p}/${u}`,g=u==="remove"?"DELETE":"POST",z=u==="remove"?`/api/v1/recipes/${p}`:f;if(!(u==="remove"&&!confirm(`Remove the entire ${p} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(z,{method:g})).json();c.success?(showNotification(`Recipe ${u}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
.recipe-step {
flex: 1;
text-align: center;
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
.recipe-step-panel {
min-height: 180px;
}
`,document.head.appendChild(j),w()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),L=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const R=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await R.json();if(R.ok&&I.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=L,b.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(R){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${R.message}`,"error"),setTimeout(()=>{b.textContent=L,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const b=document.getElementById("error-log-modal"),L=document.getElementById("error-log-content"),R=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),$=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function N(){L.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const T=await(await fetch("/api/v1/error-logs")).json();T.success&&T.logs?T.logs.length===0?L.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':L.innerHTML=T.logs.map(w=>`
`,document.head.appendChild(O),x()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),L=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await A.json();if(A.ok&&I.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=L,h.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(A){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{h.textContent=L,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),L=document.getElementById("error-log-content"),A=document.getElementById("view-error-logs"),I=document.getElementById("error-log-refresh"),D=document.getElementById("error-log-clear"),R=document.getElementById("error-log-close");async function H(){L.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const C=await(await fetch("/api/v1/error-logs")).json();C.success&&C.logs?C.logs.length===0?L.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':L.innerHTML=C.logs.map(x=>`
<div class="log-entry error">
<span class="log-timestamp">${new Date(w.timestamp).toLocaleString()}</span>
<span class="log-timestamp">${new Date(x.timestamp).toLocaleString()}</span>
<span class="log-level">ERROR</span>
<div class="log-message">
<strong>${escapeHtml(w.context)}</strong>: ${escapeHtml(w.error)}
${w.details?`<br><small style="opacity: 0.7;">${escapeHtml(w.details)}</small>`:""}
<strong>${escapeHtml(x.context)}</strong>: ${escapeHtml(x.error)}
${x.details?`<br><small style="opacity: 0.7;">${escapeHtml(x.details)}</small>`:""}
</div>
</div>
`).join(""):L.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){L.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function D(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),N()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}R?.addEventListener("click",()=>{b.classList.add("show"),N()}),I?.addEventListener("click",N),$?.addEventListener("click",D),wireModal(b,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
`).join(""):L.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){L.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function P(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),H()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}A?.addEventListener("click",()=>{h.classList.add("show"),H()}),I?.addEventListener("click",H),D?.addEventListener("click",P),wireModal(h,R)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
<div>
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="cl-close-btn" class="btn-secondary">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("container-logs-modal"),L=document.getElementById("cl-container-select"),R=document.getElementById("cl-log-content"),I=document.getElementById("cl-log-search"),$=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),N=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),B=document.getElementById("cl-stream-status"),S=document.getElementById("cl-stream-indicator"),H=document.getElementById("cl-stream-text"),C=document.getElementById("cl-line-count"),y=document.getElementById("cl-filter-count"),O=document.getElementById("cl-image"),h=document.getElementById("cl-status"),z=document.getElementById("cl-created");let A=null,j=[],u=[],m=null,x=!1,g=null;function M(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function r(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const p=s.stream==="stderr"?"log-stderr":"log-stdout",E=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${p}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
</div>`);const h=document.getElementById("container-logs-modal"),L=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),I=document.getElementById("cl-log-search"),D=document.getElementById("cl-log-tail"),R=document.getElementById("cl-refresh"),H=document.getElementById("cl-stream"),P=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),C=document.getElementById("cl-close"),x=document.getElementById("cl-close-btn"),$=document.getElementById("cl-stream-status"),E=document.getElementById("cl-stream-indicator"),M=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),B=document.getElementById("cl-filter-count"),U=document.getElementById("cl-image"),T=document.getElementById("cl-status"),b=document.getElementById("cl-created");let N=null,O=[],p=[],u=null,f=!1,g=null;function z(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function r(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const m=s.stream==="stderr"?"log-stderr":"log-stdout",k=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
<div class="log-entry ${m}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${E}</span>
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${k}</span>
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(s.text)}</span>
</div>
`}function d(s,l=""){if(!s||s.length===0){R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',C.textContent="0 lines",y.textContent="0 filtered";return}if(j=s,u=l?s.filter(p=>p.text&&p.text.toLowerCase().includes(l.toLowerCase())):s,C.textContent=`${s.length} lines`,y.textContent=l?`${u.length} of ${s.length} shown`:`${s.length} shown`,u.length===0){R.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}R.innerHTML=u.map((p,E)=>c(p,E)).join(""),R.scrollTop=R.scrollHeight}async function k(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],p=L.value;L.innerHTML='<option value="">Select a container...</option>',l.forEach(E=>{const U=document.createElement("option");U.value=E.id,U.textContent=`${E.name} (${E.image.split(":")[0]}) - ${E.status}`,U.dataset.name=E.name,U.dataset.image=E.image,U.dataset.status=E.status,U.dataset.created=E.created,L.appendChild(U)}),p&&L.querySelector(`option[value="${p}"]`)&&(L.value=p,f(p))}catch(s){console.error("Failed to load containers:",s)}}function f(s){const l=L.querySelector(`option[value="${s}"]`);l&&(O.textContent=l.dataset.image||"-",h.textContent=l.dataset.status||"-",h.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",z.textContent=M(l.dataset.created))}async function t(){const s=L.value;if(!s){R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),A=s,f(s);const l=$.value,p=I.value.trim();R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const E=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,U=await getJSON(E);U.logs&&U.logs.length>0?d(U.logs,p):(R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',C.textContent="0 lines",y.textContent="0 filtered")}catch(E){R.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(E.message)}</div>`}}function e(){const s=L.value;if(!s)return;a(),x=!0,N.textContent="\u23F9 Stop",B.style.display="flex",S.textContent="\u{1F7E2}",H.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;m=new EventSource(l),m.onopen=()=>{S.textContent="\u{1F7E2}",H.textContent="Connected - streaming logs"},m.onmessage=p=>{try{const E=JSON.parse(p.data);if(E.error){S.textContent="\u{1F534}",H.textContent=`Error: ${E.error}`;return}j.push(E),u.push(E),C.textContent=`${j.length} lines`,y.textContent=`${u.length} shown`;const U=I.value.trim();if(!U||E.text&&E.text.toLowerCase().includes(U.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(E,u.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",R.appendChild(F),R.scrollTop=R.scrollHeight}}catch(E){console.error("Error parsing log:",E)}},m.onerror=()=>{S.textContent="\u{1F534}",H.textContent="Disconnected",x=!1,N.textContent="\u25B6 Stream"},b._eventSource=m}function a(){m&&(m.close(),m=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),x=!1,N.textContent="\u25B6 Stream",B.style.display="none"}function o(){if(!j||j.length===0){showNotification("No logs to download","error");return}const s=L.querySelector(`option[value="${A}"]`)?.dataset.name||A,l=new Date().toISOString().replace(/[:.]/g,"-"),p=`${s}-logs-${l}.txt`,E=j.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
`),U=new Blob([E],{type:"text/plain"}),_=URL.createObjectURL(U),F=document.createElement("a");F.href=_,F.download=p,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${j.length} log lines`,"success")}L?.addEventListener("change",()=>{t()}),$?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),N?.addEventListener("click",()=>{x?a():e()}),D?.addEventListener("click",()=>{o()}),v?.addEventListener("click",()=>{I.value="",d(j,"")}),I?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{d(j,I.value.trim())},300)}),I?.addEventListener("keydown",s=>{s.key==="Escape"&&(I.value="",d(j,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),k()});function n(){a(),b.classList.remove("show")}T?.addEventListener("click",n),w?.addEventListener("click",n),document.addEventListener("keydown",s=>{s.key==="Escape"&&b.classList.contains("show")&&n()}),b.addEventListener("click",s=>{s.target===b&&n()}),window.openContainerLogsModal=function(s,l){b.classList.add("show"),k().then(()=>{const p=Array.from(L.options).find(E=>E.value===s||E.dataset.name===l);p?(L.value=p.value,f(p.value),t()):s?(A=s,O.textContent=l||s,h.textContent="-",z.textContent="-",t()):R.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
`}function d(s,l=""){if(!s||s.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",B.textContent="0 filtered";return}if(O=s,p=l?s.filter(m=>m.text&&m.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,B.textContent=l?`${p.length} of ${s.length} shown`:`${s.length} shown`,p.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}A.innerHTML=p.map((m,k)=>c(m,k)).join(""),A.scrollTop=A.scrollHeight}async function w(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],m=L.value;L.innerHTML='<option value="">Select a container...</option>',l.forEach(k=>{const j=document.createElement("option");j.value=k.id,j.textContent=`${k.name} (${k.image.split(":")[0]}) - ${k.status}`,j.dataset.name=k.name,j.dataset.image=k.image,j.dataset.status=k.status,j.dataset.created=k.created,L.appendChild(j)}),m&&L.querySelector(`option[value="${m}"]`)&&(L.value=m,y(m))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=L.querySelector(`option[value="${s}"]`);l&&(U.textContent=l.dataset.image||"-",T.textContent=l.dataset.status||"-",T.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",b.textContent=z(l.dataset.created))}async function t(){const s=L.value;if(!s){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=s,y(s);const l=D.value,m=I.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const k=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,j=await getJSON(k);j.logs&&j.logs.length>0?d(j.logs,m):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",B.textContent="0 filtered")}catch(k){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(k.message)}</div>`}}function e(){const s=L.value;if(!s)return;a(),f=!0,H.textContent="\u23F9 Stop",$.style.display="flex",E.textContent="\u{1F7E2}",M.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;u=new EventSource(l),u.onopen=()=>{E.textContent="\u{1F7E2}",M.textContent="Connected - streaming logs"},u.onmessage=m=>{try{const k=JSON.parse(m.data);if(k.error){E.textContent="\u{1F534}",M.textContent=`Error: ${k.error}`;return}O.push(k),p.push(k),S.textContent=`${O.length} lines`,B.textContent=`${p.length} shown`;const j=I.value.trim();if(!j||k.text&&k.text.toLowerCase().includes(j.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(k,p.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(k){console.error("Error parsing log:",k)}},u.onerror=()=>{E.textContent="\u{1F534}",M.textContent="Disconnected",f=!1,H.textContent="\u25B6 Stream"},h._eventSource=u}function a(){u&&(u.close(),u=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),f=!1,H.textContent="\u25B6 Stream",$.style.display="none"}function o(){if(!O||O.length===0){showNotification("No logs to download","error");return}const s=L.querySelector(`option[value="${N}"]`)?.dataset.name||N,l=new Date().toISOString().replace(/[:.]/g,"-"),m=`${s}-logs-${l}.txt`,k=O.map(q=>{const J=q.timestamp||"",X=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${q.text}`}).join(`
`),j=new Blob([k],{type:"text/plain"}),_=URL.createObjectURL(j),F=document.createElement("a");F.href=_,F.download=m,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${O.length} log lines`,"success")}L?.addEventListener("change",()=>{t()}),D?.addEventListener("change",()=>{t()}),R?.addEventListener("click",()=>{t()}),H?.addEventListener("click",()=>{f?a():e()}),P?.addEventListener("click",()=>{o()}),v?.addEventListener("click",()=>{I.value="",d(O,"")}),I?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{d(O,I.value.trim())},300)}),I?.addEventListener("keydown",s=>{s.key==="Escape"&&(I.value="",d(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),w()});function n(){a(),h.classList.remove("show")}C?.addEventListener("click",n),x?.addEventListener("click",n),document.addEventListener("keydown",s=>{s.key==="Escape"&&h.classList.contains("show")&&n()}),h.addEventListener("click",s=>{s.target===h&&n()}),window.openContainerLogsModal=function(s,l){h.classList.add("show"),w().then(()=>{const m=Array.from(L.options).find(k=>k.value===s||k.dataset.name===l);m?(L.value=m.value,y(m.value),t()):s?(N=s,U.textContent=l||s,T.textContent="-",b.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
<h3>\u{1F4BE} Container Snapshots</h3>
<p class="modal-subtitle">
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="snapshot-close">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("snapshot-modal"),L=document.getElementById("snapshot-btn"),R=document.getElementById("snapshot-close"),I=document.getElementById("snapshot-container-select"),$=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),N=document.getElementById("snapshot-create-status");let D=null;async function v(){try{const C=await(await fetch("/api/v1/containers")).json();if(!C.success||!C.containers)return;I.innerHTML='<option value="">-- Select a container --</option>';for(const y of C.containers){const O=document.createElement("option");O.value=y.id,O.textContent=`${y.name||y.id} (${y.image||"unknown"})`,O.dataset.name=y.name,O.dataset.image=y.image,O.dataset.status=y.status,O.dataset.created=y.created,I.appendChild(O)}}catch(H){console.error("Failed to load containers:",H)}}function T(H){if(!H||!H.value){$.style.display="none",D=null;return}D=H.value,document.getElementById("snapshot-image").textContent=H.dataset.image||"-",document.getElementById("snapshot-status").textContent=H.dataset.status||"-",document.getElementById("snapshot-created").textContent=H.dataset.created?new Date(H.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=H.value.substring(0,12),$.style.display=""}async function w(){if(!D){N.textContent="Please select a container first",N.style.color="var(--bad-fg)";return}const H=document.getElementById("snapshot-name").value.trim();if(!H){N.textContent="Please enter a snapshot name",N.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",N.textContent="";try{const O=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:H,leaveRunning:C})})).json();O.success?(N.textContent=`\u2713 Snapshot "${H}" created successfully`,N.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(N.textContent=`\u2717 Failed: ${O.error||"Unknown error"}`,N.style.color="var(--bad-fg)")}catch(y){N.textContent=`\u2717 Error: ${y.message}`,N.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function B(){b.classList.add("show"),v()}function S(){b.classList.remove("show"),$.style.display="none",D=null,I.selectedIndex=0}L?.addEventListener("click",B),R?.addEventListener("click",S),wireModal(b,R),I?.addEventListener("change",H=>{const C=I.options[I.selectedIndex];T(C)}),P?.addEventListener("click",w),b?.querySelectorAll(".panel-tab").forEach(H=>{H.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(C=>C.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(C=>C.classList.remove("active")),H.classList.add("active"),b.querySelector(`#${H.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
</div>`);const h=document.getElementById("snapshot-modal"),L=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),I=document.getElementById("snapshot-container-select"),D=document.getElementById("snapshot-details"),R=document.getElementById("snapshot-create-btn"),H=document.getElementById("snapshot-create-status");let P=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;I.innerHTML='<option value="">-- Select a container --</option>';for(const B of S.containers){const U=document.createElement("option");U.value=B.id,U.textContent=`${B.name||B.id} (${B.image||"unknown"})`,U.dataset.name=B.name,U.dataset.image=B.image,U.dataset.status=B.status,U.dataset.created=B.created,I.appendChild(U)}}catch(M){console.error("Failed to load containers:",M)}}function C(M){if(!M||!M.value){D.style.display="none",P=null;return}P=M.value,document.getElementById("snapshot-image").textContent=M.dataset.image||"-",document.getElementById("snapshot-status").textContent=M.dataset.status||"-",document.getElementById("snapshot-created").textContent=M.dataset.created?new Date(M.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=M.value.substring(0,12),D.style.display=""}async function x(){if(!P){H.textContent="Please select a container first",H.style.color="var(--bad-fg)";return}const M=document.getElementById("snapshot-name").value.trim();if(!M){H.textContent="Please enter a snapshot name",H.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;R.disabled=!0,R.textContent="Creating...",H.textContent="";try{const U=await(await fetch(`/api/v1/containers/${encodeURIComponent(P)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:M,leaveRunning:S})})).json();U.success?(H.textContent=`\u2713 Snapshot "${M}" created successfully`,H.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(H.textContent=`\u2717 Failed: ${U.error||"Unknown error"}`,H.style.color="var(--bad-fg)")}catch(B){H.textContent=`\u2717 Error: ${B.message}`,H.style.color="var(--bad-fg)"}finally{R.disabled=!1,R.textContent="\u{1F4BE} Create Snapshot"}}function $(){h.classList.add("show"),v()}function E(){h.classList.remove("show"),D.style.display="none",P=null,I.selectedIndex=0}L?.addEventListener("click",$),A?.addEventListener("click",E),wireModal(h,A),I?.addEventListener("change",M=>{const S=I.options[I.selectedIndex];C(S)}),R?.addEventListener("click",x),h?.querySelectorAll(".panel-tab").forEach(M=>{M.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),M.classList.add("active"),h.querySelector(`#${M.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
<h3>\u{1F3AC} Smart Arr Connect</h3>
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="arr-setup-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("arr-setup-modal"),L=document.getElementById("arr-setup-btn"),R=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),$=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),N=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),B=document.getElementById("smart-results-content"),S=document.getElementById("smart-plex-libraries"),H=document.getElementById("smart-retry-btn");let C=null;const y={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},O={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function h(g){$.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",N.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function z(g){const M={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},r=M[g]||M.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function A(){h("detect"),v.style.display="none";try{if(C=await(await fetch("/api/v1/arr/smart-detect")).json(),!C.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(C.error)}</div>`,v.style.display="block";return}let M='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(C.services)){const k=y[c]||"\u{1F4E6}",f=O[c]||c,t=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",a=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";M+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${k}</span>
</div>`);const h=document.getElementById("arr-setup-modal"),L=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),D=document.getElementById("smart-phase-detect"),R=document.getElementById("smart-phase-credentials"),H=document.getElementById("smart-phase-progress"),P=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),C=document.getElementById("smart-credential-inputs"),x=document.getElementById("smart-progress-steps"),$=document.getElementById("smart-results-content"),E=document.getElementById("smart-plex-libraries"),M=document.getElementById("smart-retry-btn");let S=null;const B={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},U={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function T(g){D.style.display=g==="detect"?"block":"none",R.style.display=g==="credentials"?"block":"none",H.style.display=g==="progress"?"block":"none",P.style.display=g==="results"?"block":"none"}function b(g){const z={connected:{bg:"var(--ok-fg)",icon:"&#10003;",text:"Connected"},needs_key:{bg:"#f39c12",icon:"&#128273;",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"&mdash;",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"&#10007;",text:"Error"}},r=z[g]||z.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function N(){T("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let z='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const w=B[c]||"\u{1F4E6}",y=U[c]||c,t=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",a=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";z+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${w}</span>
<div style="flex: 1;">
<div style="font-weight: 500; font-size: 0.9rem;">${f}</div>
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
${t} ${e} ${a}
</div>
</div>
${z(d.status)}
</div>`}M+="</div>";const r=C.summary;M+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${b(d.status)}
</div>`}z+="</div>";const r=S.summary;z+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected &middot;
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` &middot; <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
</div>`,v.innerHTML=M,v.style.display="block",j(C),setTimeout(()=>{h("credentials")},800)}catch(g){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,v.style.display="block"}}function j(g){let M="";const r=g.services,c=["radarr","sonarr","prowlarr"];for(const f of c){const t=r[f];if(!t||t.status==="not_found"&&!t.url)continue;const e=y[f],a=O[f],o=t.status==="connected";M+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
</div>`,v.innerHTML=z,v.style.display="block",O(S),setTimeout(()=>{T("credentials")},800)}catch(g){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,v.style.display="block"}}function O(g){let z="";const r=g.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const t=r[y];if(!t||t.status==="not_found"&&!t.url)continue;const e=B[y],a=U[y],o=t.status==="connected";z+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<span style="font-size: 1.1rem;">${e}</span>
<span style="font-weight: 500;">${a}</span>
<span id="smart-${f}-status" style="margin-left: auto; font-size: 0.75rem;">
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
${o?'<span style="color: var(--ok-fg);">&#10003; Connected</span>':""}
</span>
</div>
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
<input type="text" id="smart-${f}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${f}/"
<input type="text" id="smart-${y}-url" value="${escapeHtml(t.url||"")}" placeholder="https://seedbox.com/${y}/"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
<div>
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
<input type="password" id="smart-${f}-key" placeholder="${o?"(saved)":"Paste API key"}"
<input type="password" id="smart-${y}-key" placeholder="${o?"(saved)":"Paste API key"}"
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
</div>
</div>
<button onclick="smartTestConnection('${f}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const d=r.plex;if(d){const f=d.status==="connected";M+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${f?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
</div>`}const d=r.plex;if(d){const y=d.status==="connected";z+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
<span style="font-weight: 500;">Plex</span>
${z(d.status)}
${b(d.status)}
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
</div>
</div>`}const k=r.seerr;if(k){const f=k.status==="connected";let t="";if(k.configuredServices){const e=k.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
</div>`}const w=r.seerr;if(w){const y=w.status==="connected";let t="";if(w.configuredServices){const e=w.configuredServices;t=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
Configured: ${e.radarr?"&#10003; Radarr":"&#10007; Radarr"} &middot;
${e.sonarr?"&#10003; Sonarr":"&#10007; Sonarr"} &middot;
${e.plex?"&#10003; Plex":"&#10007; Plex"}
</div>`}M+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${f?"var(--ok-fg)":"var(--border)"};">
</div>`}z+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
<span style="font-weight: 500;">Seerr</span>
${z(k.status)}
${b(w.status)}
</div>
${t}
</div>`}T.innerHTML=M}window.smartTestConnection=async function(g){const M=document.getElementById(`smart-${g}-url`),r=document.getElementById(`smart-${g}-key`),c=document.getElementById(`smart-${g}-status`),d=M?.value.trim(),k=r?.value.trim();if(!d||!k){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:d,apiKey:k})})).json();t.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(f){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(f.message)}</span>`}};async function u(){h("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),d=document.getElementById(`smart-${r}-key`)?.value.trim();d&&c?g[r]={apiKey:d,url:c}:d&&(g[r]={apiKey:d})}const M={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(M)})).json();let d="";for(const k of c.steps||[]){const f=k.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=k.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${f}
<span>${escapeHtml(k.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(k.details||"")}</span>
</div>`}w.innerHTML=d,setTimeout(()=>m(c),500)}catch(r){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function m(g){h("results");const M=g.summary||{},r=M.failed===0&&M.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",d=r?"&#10003;":"&#9888;",k=r?"All Connected!":`${escapeHtml(String(M.succeeded))}/${escapeHtml(String(M.totalSteps))} Steps Succeeded`;let f=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
</div>`}C.innerHTML=z}window.smartTestConnection=async function(g){const z=document.getElementById(`smart-${g}-url`),r=document.getElementById(`smart-${g}-key`),c=document.getElementById(`smart-${g}-status`),d=z?.value.trim(),w=r?.value.trim();if(!d||!w){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const t=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:g,url:d,apiKey:w})})).json();t.success?c.innerHTML=`<span style="color: var(--ok-fg);">&#10003; ${escapeHtml(t.appName||"Connected")} v${escapeHtml(t.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(t.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">&#10007; ${escapeHtml(y.message)}</span>`}};async function p(){T("progress"),x.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const g={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),d=document.getElementById(`smart-${r}-key`)?.value.trim();d&&c?g[r]={apiKey:d,url:c}:d&&(g[r]={apiKey:d})}const z={services:Object.keys(g).length>0?g:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(z)})).json();let d="";for(const w of c.steps||[]){const y=w.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>',t=w.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
${y}
<span>${escapeHtml(w.step)}</span>
<span style="margin-left: auto; font-size: 0.75rem; color: ${t};">${escapeHtml(w.details||"")}</span>
</div>`}x.innerHTML=d,setTimeout(()=>u(c),500)}catch(r){x.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function u(g){T("results");const z=g.summary||{},r=z.failed===0&&z.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",d=r?"&#10003;":"&#9888;",w=r?"All Connected!":`${escapeHtml(String(z.succeeded))}/${escapeHtml(String(z.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
<div style="font-size: 1.5rem; color: ${c};">${d}</div>
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${k}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(M.succeeded))} succeeded, ${escapeHtml(String(M.failed))} failed</div>
</div>`;f+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';f+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${w}</div>
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(z.succeeded))} succeeded, ${escapeHtml(String(z.failed))} failed</div>
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const t of g.steps||[]){const e=t.status==="success"?'<span style="color: var(--ok-fg);">&#10003;</span>':'<span style="color: var(--bad-fg);">&#10007;</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
${e} ${escapeHtml(t.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(t.details||"")}</span>
</div>`}f+="</div>",B.innerHTML=f,H.style.display=M.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&x()}async function x(){try{const M=await(await fetch("/api/v1/plex/libraries")).json();if(M.success&&M.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(M.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of M.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
</div>`}y+="</div>",$.innerHTML=y,M.style.display=z.failed>0?"block":"none",g.steps?.some(t=>t.step.includes("Plex")&&t.status==="success")&&f()}async function f(){try{const z=await(await fetch("/api/v1/plex/libraries")).json();if(z.success&&z.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(z.serverName)} Libraries</h4>
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of z.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
${d} <strong>${escapeHtml(c.title)}</strong>
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
</div>`}r+="</div></div>",S.innerHTML=r,S.style.display="block"}}catch{}}L?.addEventListener("click",()=>{b.classList.add("show"),S.style.display="none",A()}),wireModal(b,R),I?.addEventListener("click",u),H?.addEventListener("click",u)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
</div>`}r+="</div></div>",E.innerHTML=r,E.style.display="block"}}catch{}}L?.addEventListener("click",()=>{h.classList.add("show"),E.style.display="none",N()}),wireModal(h,A),I?.addEventListener("click",p),M?.addEventListener("click",p)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F514} Notification Settings</h3>
@@ -1000,15 +1000,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="notifications-save" class="btn-accent">Save Settings</button>
</div>
</div>
</div>`);const L=document.getElementById("notifications-modal"),R=document.getElementById("manage-notifications"),I=document.getElementById("notifications-save"),$=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(S=>{const H=document.getElementById(`${S}-enabled`),C=document.getElementById(`${S}-config`);H?.addEventListener("change",()=>{C.style.display=H.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),N=document.getElementById("health-check-config");P?.addEventListener("change",()=>{N.style.opacity=P.checked?"1":"0.5"});async function D(){try{const H=await(await fetch("/api/v1/notifications/config")).json();if(H.success){const C=H.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=C.events?.resourceAlert!==!1}}catch(S){b.logError("[Notifications] Load Config",S,{function:"loadConfig"})}}async function v(){try{const H=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");H.success&&H.history?.length>0?C.innerHTML=H.history.map(y=>{const O=new Date(y.timestamp).toLocaleString();return`
</div>`);const L=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),I=document.getElementById("notifications-save"),D=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(E=>{const M=document.getElementById(`${E}-enabled`),S=document.getElementById(`${E}-config`);M?.addEventListener("change",()=>{S.style.display=M.checked?"block":"none"})});const R=document.getElementById("health-check-enabled"),H=document.getElementById("health-check-config");R?.addEventListener("change",()=>{H.style.opacity=R.checked?"1":"0.5"});async function P(){try{const M=await(await fetch("/api/v1/notifications/config")).json();if(M.success){const S=M.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(E){h.logError("[Notifications] Load Config",E,{function:"loadConfig"})}}async function v(){try{const M=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");M.success&&M.history?.length>0?S.innerHTML=M.history.map(B=>{const U=new Date(B.timestamp).toLocaleString();return`
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[y.type]||"var(--muted)"}">${y.type==="success"?"\u2713":y.type==="error"?"\u2717":"\u2139"}</span>
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[B.type]||"var(--muted)"}">${B.type==="success"?"\u2713":B.type==="error"?"\u2717":"\u2139"}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(y.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div>
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(B.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${U}</div>
</div>
</div>
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(S){b.logError("[Notifications] Load History",S,{function:"loadHistory"})}}async function T(){try{const S={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();C.success?(showNotification("Notification settings saved","success",3e3),L.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}async function w(S){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:S})})).json();C.success?showNotification(`Test ${S} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch(H){showNotification(`Error: ${H.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const H=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();H.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(H.lastCheck).toLocaleString()} (${H.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}),R?.addEventListener("click",()=>{L.classList.add("show"),D(),v()}),I?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const S=document.getElementById("notifications-send-test"),H=S.textContent;S.textContent="Sending...",S.disabled=!0;try{const y=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();y.success?(showNotification("Test notification sent!","success",3e3),B()):showNotification(`Test failed: ${y.results?.map(O=>`${O.provider}: ${O.error||"ok"}`).join(", ")}`,"error",5e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}finally{S.textContent=H,S.disabled=!1}});async function B(){try{const H=await(await fetch("/api/v1/notifications/status")).json();if(H.success&&H.lastSent){const C=document.getElementById("last-notification-sent");C&&(C.textContent=`Last sent: ${new Date(H.lastSent).toLocaleString()}`)}}catch{}}wireModal(L,$)})(),(function(){document.addEventListener("click",b=>{const L=b.target.closest(".panel-tab");if(!L)return;const R=L.dataset.panel;if(!R)return;const I=L.closest(".panel-tabs"),$=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach(N=>N.classList.remove("active")),L.classList.add("active"),$.querySelectorAll(".panel-section").forEach(N=>N.classList.remove("active"));const P=$.querySelector("#"+R);P&&P.classList.add("active")})})(),(function(){var b=["dashcaddy_site_config","dashca Line truncated
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(E){h.logError("[Notifications] Load History",E,{function:"loadHistory"})}}async function C(){try{const E={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(E)})).json();S.success?(showNotification("Notification settings saved","success",3e3),L.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}async function x(E){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:E})})).json();S.success?showNotification(`Test ${E} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(M){showNotification(`Error: ${M.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>x("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>x("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>x("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>x("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const M=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();M.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(M.lastCheck).toLocaleString()} (${M.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{L.classList.add("show"),P(),v()}),I?.addEventListener("click",C),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const E=document.getElementById("notifications-send-test"),M=E.textContent;E.textContent="Sending...",E.disabled=!0;try{const B=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();B.success?(showNotification("Test notification sent!","success",3e3),$()):showNotification(`Test failed: ${B.results?.map(U=>`${U.provider}: ${U.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{E.textContent=M,E.disabled=!1}});async function $(){try{const M=await(await fetch("/api/v1/notifications/status")).json();if(M.success&&M.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(M.lastSent).toLocaleString()}`)}}catch{}}wireModal(L,D)})(),(function(){document.addEventListener("click",h=>{const L=h.target.closest(".panel-tab");if(!L)return;const A=L.dataset.panel;if(!A)return;const I=L.closest(".panel-tabs"),D=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach(H=>H.classList.remove("active")),L.classList.add("active"),D.querySelectorAll(".panel-section").forEach(H=>H.classList.remove("active"));const R=D.querySelector("#"+A);R&&R.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca Line truncated
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>\u{1F4BE} Backup & Restore</h3>
<p class="modal-subtitle">
@@ -1114,7 +1114,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
<button id="backup-cancel">Close</button>
</div>
</div>
</div>`);var P=document.getElementById("backup-modal"),N=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),B=document.getElementById("backup-file-name"),S=document.getElementById("backup-preview"),H=document.getElementById("backup-preview-content"),C=document.getElementById("backup-do-restore-btn"),y=document.getElementById("backup-result"),O=document.getElementById("backup-schedules-container"),h=document.getElementById("backup-history-container"),z=document.getElementById("backup-disk-container"),A=document.getElementById("pointintime-container"),j=null;N?.addEventListener("click",function(){P.classList.add("show"),y&&(y.style.display="none"),S&&(S.style.display="none"),B&&(B.style.display="none"),j=null}),wireModal(P,D),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=L();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var s=Object.keys(a.browserState).length,l=a.themes?Object.keys(a.themes).length:0;y.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),y.style.display="block",y.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",y.style.border="1px solid var(--ok-fg)"}catch(p){y.innerHTML="\u274C Export failed: "+escapeHtml(p.message),y.style.display="block",y.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",y.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){B.textContent="\u{1F4C4} "+a.name,B.style.display="block",y.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(I(i)){j=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",H.innerHTML=n,S.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await s.json();if(l.success){j=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var p in l.preview.files){var E=l.preview.files[p],U=E.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(E.description)+"</span>"}n+="</div>",l.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),H.innerHTML=n,S.style.display="block"}else y.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),y.style.display="block",y.style.background="color-mix(in srgb, #f39c12 15%, transparent)",y.style.border="1px solid #f39c12",S.style.display="none"}catch(_){y.innerHTML="\u274CLine truncated
</div>`);var R=document.getElementById("backup-modal"),H=document.getElementById("backup-restore-btn"),P=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),C=document.getElementById("backup-select-file"),x=document.getElementById("backup-file-input"),$=document.getElementById("backup-file-name"),E=document.getElementById("backup-preview"),M=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),B=document.getElementById("backup-result"),U=document.getElementById("backup-schedules-container"),T=document.getElementById("backup-history-container"),b=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),O=null;H?.addEventListener("click",function(){R.classList.add("show"),B&&(B.style.display="none"),E&&(E.style.display="none"),$&&($.style.display="none"),O=null}),wireModal(R,P),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=L();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var s=Object.keys(a.browserState).length,l=a.themes?Object.keys(a.themes).length:0;B.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),B.style.display="block",B.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",B.style.border="1px solid var(--ok-fg)"}catch(m){B.innerHTML="\u274C Export failed: "+escapeHtml(m.message),B.style.display="block",B.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",B.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),C?.addEventListener("click",function(){x.click()}),x?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){$.textContent="\u{1F4C4} "+a.name,$.style.display="block",B.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(I(i)){O=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",M.innerHTML=n,E.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await s.json();if(l.success){O=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var m in l.preview.files){var k=l.preview.files[m],j=k.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+j+" "+escapeHtml(k.description)+"</span>"}n+="</div>",l.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),M.innerHTML=n,E.style.display="block"}else B.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),B.style.display="block",B.style.background="color-mix(in srgb, #f39c12 15%, transparent)",B.style.border="1px solid #f39c12",E.style.display="none"}catch(_){B.innerHTML="\u274CLine truncated
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
@@ -1196,7 +1196,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="stats-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("stats-modal"),L=document.getElementById("container-stats-btn"),R=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),$=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),N=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let T=null,w=null;function B(d){if(d===0||!d)return"0 B";const k=1024,f=["B","KB","MB","GB"],t=Math.floor(Math.log(d)/Math.log(k));return parseFloat((d/Math.pow(k,t)).toFixed(1))+" "+f[t]}function S(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function H(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function C(){try{let d=null,k=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,k=!0,w=e.stats)}catch{}if(!k){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const a of e.stats)d[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};w=d}}if(!d||Object.keys(d).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(d)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=S(o),s=H(i),l=a.memory?.usage||a.memory?.used||0,p=a.memory?.limit||0,E=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;f+=`
</div>`);const h=document.getElementById("stats-modal"),L=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),D=document.getElementById("stats-auto-refresh"),R=document.getElementById("stats-container"),H=document.getElementById("stats-aggregated-container"),P=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let C=null,x=null;function $(d){if(d===0||!d)return"0 B";const w=1024,y=["B","KB","MB","GB"],t=Math.floor(Math.log(d)/Math.log(w));return parseFloat((d/Math.pow(w,t)).toFixed(1))+" "+y[t]}function E(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function M(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,w=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,w=!0,x=e.stats)}catch{}if(!w){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const a of e.stats)d[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};x=d}}if(!d||Object.keys(d).length===0){R.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(d)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=E(o),s=M(i),l=a.memory?.usage||a.memory?.used||0,m=a.memory?.limit||0,k=a.network?.rxBytes||a.network?.rx||0,j=a.network?.txBytes||a.network?.tx||0,_=e.aggregated;y+=`
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
@@ -1221,19 +1221,19 @@ This will replace current configuration, credentials, and data. Containers will
</div>
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${B(l)} / ${B(p)}</div>
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${$(l)} / ${$(m)}</div>
</div>
<div>
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
<div style="font-size: 0.8rem;">
<span style="color: #3498db;">\u2193 ${B(E)}</span>
<span style="color: #3498db;">\u2193 ${$(k)}</span>
<span style="color: var(--muted); margin: 0 4px;">/</span>
<span style="color: #e74c3c;">\u2191 ${B(U)}</span>
<span style="color: #e74c3c;">\u2191 ${$(j)}</span>
</div>
</div>
</div>
</div>`}f+="</div>",P.innerHTML=f,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function y(){if(!N)return;const d=w;if(!d||Object.keys(d).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let k='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[f,t]of Object.entries(d)){const e=t.aggregated;e&&(k+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||f}</div>
</div>`}y+="</div>",R.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){R.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function B(){if(!H)return;const d=x;if(!d||Object.keys(d).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let w='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,t]of Object.entries(d)){const e=t.aggregated;e&&(w+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||y}</div>
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
@@ -1241,18 +1241,18 @@ This will replace current configuration, credentials, and data. Containers will
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
</div>
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
</div>`)}k+="</div>",N.innerHTML=k}async function O(){if(!D)return;D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=w;if(!d||Object.keys(d).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let k=!1;try{k=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{k=!1}let f=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(f=s.history||[])}catch{}let t={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(t=s.configs||{})}catch{}const a=Object.entries(d).map(([n,s])=>{const l=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
</div>`)}w+="</div>",H.innerHTML=w}async function U(){if(!P)return;P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=x;if(!d||Object.keys(d).length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let w=!1;try{w=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{w=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let t={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(t=s.configs||{})}catch{}const a=Object.entries(d).map(([n,s])=>{const l=t[n]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
<tr data-container="${n}">
<td style="font-weight: 600; padding: 8px;">${s.name||n}</td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${k?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${k?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${k?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${k?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${w?"":"disabled"} /></td>
<td style="padding: 4px 8px;">
<button class="alert-test-btn btn-xs" data-container="${n}" data-name="${s.name||n}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
</td>
</tr>
`}).join(""),o=f.map(n=>{const s=new Date(n.timestamp).toLocaleString(),l=n.notified?"\u2713":"\u2014";return`
`}).join(""),o=y.map(n=>{const s=new Date(n.timestamp).toLocaleString(),l=n.notified?"\u2713":"\u2014";return`
<tr>
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
<td style="padding: 6px 8px; font-weight: 500;">${n.containerName||n.containerId}</td>
@@ -1261,7 +1261,7 @@ This will replace current configuration, credentials, and data. Containers will
<td style="padding: 6px 8px; text-align: center;">${l}</td>
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${n.autoRestartTriggered?"#f39c12":"var(--muted)"};">${n.autoRestartTriggered?"\u21BB":""}</td>
</tr>
`}).join(""),i=k?`
`}).join(""),i=w?`
<div style="margin-bottom: 20px;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
@@ -1292,7 +1292,7 @@ This will replace current configuration, credentials, and data. Containers will
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
</div>
`;D.innerHTML=`
`;P.innerHTML=`
${i}
<div>
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
@@ -1314,21 +1314,21 @@ This will replace current configuration, credentials, and data. Containers will
</div>
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
</div>
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;n[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),p=document.getElementById("save-all-alerts");p.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{p.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),b.classList.remove("show"),z(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),z(),typeof openLicenseModal=="function"&&openLicenseModal()})}function h(){T&&clearInterval(T),$?.checked&&(T=setInterval(C,DC.POLL.STATS))}function z(){T&&(clearInterval(T),T=null)}L?.addEventListener("click",()=>{b.classList.add("show"),C(),h()}),R?.addEventListener("click",()=>{b.classList.remove("show"),z()}),b?.addEventListener("click",d=>{d.target===b&&(b.classList.remove("show"),z())}),I?.addEventListener("click",C),$?.addEventListener("change",()=>{$.checked?h():z()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",y),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",O);const A=document.getElementById("stats-history-container"),j=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let m="1h";function x(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function M(d,k,f,t,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=d.map(k).filter(q=>q!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,s=600,l=80,p=4,E=(s-p*2)/Math.max(a.length-1,1),U=a.map((q,J)=>{const X=p+J*E,Q=l-p-(q-i)/n*(l-p*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],F=a.reduce((q,J)=>q+J,0)/a.length;return`
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;n[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),m=document.getElementById("save-all-alerts");m.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{m.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),h.classList.remove("show"),b(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),b(),typeof openLicenseModal=="function"&&openLicenseModal()})}function T(){C&&clearInterval(C),D?.checked&&(C=setInterval(S,DC.POLL.STATS))}function b(){C&&(clearInterval(C),C=null)}L?.addEventListener("click",()=>{h.classList.add("show"),S(),T()}),A?.addEventListener("click",()=>{h.classList.remove("show"),b()}),h?.addEventListener("click",d=>{d.target===h&&(h.classList.remove("show"),b())}),I?.addEventListener("click",S),D?.addEventListener("change",()=>{D.checked?T():b()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",B),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",U);const N=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),p=document.querySelectorAll(".stats-range-btn");let u="1h";function f(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function z(d,w,y,t,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=d.map(w).filter(q=>q!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,s=600,l=80,m=4,k=(s-m*2)/Math.max(a.length-1,1),j=a.map((q,J)=>{const X=m+J*k,Q=l-m-(q-i)/n*(l-m*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),_=a[a.length-1],F=a.reduce((q,J)=>q+J,0)/a.length;return`
<div style="margin-bottom: 14px;">
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${e} \xB7 avg ${F.toFixed(1)}${e} \xB7 max ${o.toFixed(1)}${e}</span>
</div>
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
<polyline fill="none" stroke="${f}" stroke-width="1.5" points="${U}" />
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${j}" />
</svg>
</div>
`}function r(){if(!A)return;const d=w||{},k=A.value,f=Object.entries(d);if(f.length===0){A.innerHTML='<option value="">No containers</option>';return}A.innerHTML=f.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),k&&d[k]&&(A.value=k)}async function c(){if(!j||!A)return;const d=A.value;if(!d){j.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const k=Date.now(),f=k-x(m);j.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${f}&endTime=${k}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){j.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?U=>U.cpu?.percent:U=>U.cpu?.avg,s=i?U=>U.memory?.percent:U=>U.memory?.avgPercent,l=i?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,p=i?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let E=`
`}function r(){if(!N)return;const d=x||{},w=N.value,y=Object.entries(d);if(y.length===0){N.innerHTML='<option value="">No containers</option>';return}N.innerHTML=y.map(([t,e])=>`<option value="${escapeHtml(t)}">${escapeHtml(e.name||t)}</option>`).join(""),w&&d[w]&&(N.value=w)}async function c(){if(!O||!N)return;const d=N.value;if(!d){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const w=Date.now(),y=w-f(u);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${y}&endTime=${w}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const a=e.samples||[],o=e.tier||"raw";if(a.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${g(o)}.</div>`;return}const i=o==="raw",n=i?j=>j.cpu?.percent:j=>j.cpu?.avg,s=i?j=>j.memory?.percent:j=>j.memory?.avgPercent,l=i?j=>j.network?.rxMB||0:j=>j.network?.rxMB||0,m=i?j=>j.network?.txMB||0:j=>j.network?.txMB||0;let k=`
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(f).toLocaleString()} \u2192 ${new Date(k).toLocaleString()}
${a.length} samples \xB7 ${escapeHtml(g(o))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date(w).toLocaleString()}
</div>
`;E+=M(a,n,"#2ecc71","CPU","%"),E+=M(a,s,"#3498db","Memory","%"),E+=M(a,l,"#9b59b6","Network RX"," MB"),E+=M(a,p,"#e67e22","Network TX"," MB"),j.innerHTML=E}catch(t){j.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}u.forEach(d=>{d.addEventListener("click",()=>{u.forEach(k=>k.classList.remove("active")),d.classList.add("active"),m=d.dataset.range,c()})}),A?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
`;k+=z(a,n,"#2ecc71","CPU","%"),k+=z(a,s,"#3498db","Memory","%"),k+=z(a,l,"#9b59b6","Network RX"," MB"),k+=z(a,m,"#e67e22","Network TX"," MB"),O.innerHTML=k}catch(t){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(t.message)}</div>`}}p.forEach(d=>{d.addEventListener("click",()=>{p.forEach(w=>w.classList.remove("active")),d.classList.add("active"),u=d.dataset.range,c()})}),N?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
<h3>\u{1F3E5} Health Check Dashboard</h3>
<p class="modal-subtitle">
@@ -1452,7 +1452,7 @@ This will replace current configuration, credentials, and data. Containers will
<button id="health-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("health-modal"),L=document.getElementById("health-check-btn"),R=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),$=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),N=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),B=document.getElementById("health-form-cancel"),S=document.getElementById("health-form-save"),H="dashcaddy-health-settings",C={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},y=document.getElementById("health-global-save"),O=document.getElementById("health-global-reset"),h=document.getElementById("health-global-status"),z=document.getElementById("health-setting-retention"),A=document.getElementById("health-setting-interval"),j=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),m=document.getElementById("health-setting-disk-threshold");function x(){try{const i=safeGet(H),n=i?JSON.parse(i):{};return Object.assign({},C,n)}catch{return Object.assign({},C)}}function g(){const i=x();z&&(z.value=i.retentionDays),A&&(A.value=i.pollingInterval),j&&(j.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),m&&(m.value=i.diskUsageThreshold)}function M(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(z?.value)||C.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(A?.value)||C.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(j?.value)||C.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||C.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(m?.value)||C.diskUsageThreshold))};try{safeSet(H,JSON.stringify(i)),g(),h&&(h.textContent="Saved \u2713",h.style.color="var(--ok-fg)",setTimeout(()=>{h&&(h.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){h&&(h.textContent="Save failed",h.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function r(){try{safeSet(H,JSON.stringify(C))}catch{}g(),h&&(h.textContent="Reset to defaults \u2713",h.style.color="var(--ok-fg)",setTimeout(()=>{h&&(h.textContent="")},2500))}g(),y?.addEventListener("click",M),O?.addEventListener("click",r);let c=null;function d(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function k(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function f(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const s=Object.values(n.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of s){const E=p.status==="up",U=E?"var(--dot-ok)":"var(--dot-bad)",_=p.uptime?.["24h"]??"-",F=p.uptime?.["7d"]??"-",q=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",J=p.timestamp?timeAgo(p.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${E?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px;">${q}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
</div>`);const h=document.getElementById("health-modal"),L=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),D=document.getElementById("health-status-container"),R=document.getElementById("health-incidents-container"),H=document.getElementById("health-config-container"),P=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),C=document.getElementById("health-config-form"),x=document.getElementById("health-form-title"),$=document.getElementById("health-form-cancel"),E=document.getElementById("health-form-save"),M="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},B=document.getElementById("health-global-save"),U=document.getElementById("health-global-reset"),T=document.getElementById("health-global-status"),b=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),p=document.getElementById("health-setting-max-entries"),u=document.getElementById("health-setting-disk-threshold");function f(){try{const i=safeGet(M),n=i?JSON.parse(i):{};return Object.assign({},S,n)}catch{return Object.assign({},S)}}function g(){const i=f();b&&(b.value=i.retentionDays),N&&(N.value=i.pollingInterval),O&&(O.value=i.statsPollingInterval),p&&(p.value=i.maxEntriesPerService),u&&(u.value=i.diskUsageThreshold)}function z(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(b?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(p?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(u?.value)||S.diskUsageThreshold))};try{safeSet(M,JSON.stringify(i)),g(),T&&(T.textContent="Saved \u2713",T.style.color="var(--ok-fg)",setTimeout(()=>{T&&(T.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){T&&(T.textContent="Save failed",T.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function r(){try{safeSet(M,JSON.stringify(S))}catch{}g(),T&&(T.textContent="Reset to defaults \u2713",T.style.color="var(--ok-fg)",setTimeout(()=>{T&&(T.textContent="")},2500))}g(),B?.addEventListener("click",z),U?.addEventListener("click",r);let c=null;function d(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function w(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function y(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const s=Object.values(n.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of s){const k=m.status==="up",j=k?"var(--dot-ok)":"var(--dot-bad)",_=m.uptime?.["24h"]??"-",F=m.uptime?.["7d"]??"-",q=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",J=m.timestamp?timeAgo(m.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${j}; margin-right: 6px;"></span>${k?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px;">${q}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
@@ -1462,14 +1462,14 @@ This will replace current configuration, credentials, and data. Containers will
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(i){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await i.json(),l=await n.json();let p="";const E=s.success&&s.incidents?s.incidents:[];if(E.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+E.length+")</h4>";for(const _ of E)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
</div>`}else j.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){j.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(i){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(i.message)}</div>`}}async function t(){try{const[i,n]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await i.json(),l=await n.json();let m="";const k=s.success&&s.incidents?s.incidents:[];if(k.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+k.length+")</h4>";for(const _ of k)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
<span>${k(_.severity)}</span>
<span>${w(_.severity)}</span>
</div>
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=l.success&&l.history?l.history:[];if(U.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of U){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,p+=`<td style="padding: 6px;">${k(_.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,p+=`<td style="padding: 6px;">${q}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,p+="</tr>"}p+="</table>"}P.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),s=n.success&&n.status?Object.values(n.status):[];if(s.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of s){const E=p.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${E?"var(--ok-fg)":"var(--bad-fg)"};">${E?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",N.innerHTML=l}catch(i){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,s,l,p,E,U){c=i||null,w.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=E||99.9,document.getElementById("health-form-slow").value=U||5e3,T.style.display="",v.style.display="none"}function o(){T.style.display="none",v.style.display="",c=null}v?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),B?.addEventListener("click",o),S?.addEventListener("click",async()=>{const i=c||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
</div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const j=l.success&&l.history?l.history:[];if(j.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of j){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,m+=`<td style="padding: 6px;">${w(_.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,m+=`<td style="padding: 6px;">${q}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,m+="</tr>"}m+="</table>"}R.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),s=n.success&&n.status?Object.values(n.status):[];if(s.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of s){const k=m.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${k?"var(--ok-fg)":"var(--bad-fg)"};">${k?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",H.innerHTML=l}catch(i){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,s,l,m,k,j){c=i||null,x.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=k||99.9,document.getElementById("health-form-slow").value=j||5e3,C.style.display="",v.style.display="none"}function o(){C.style.display="none",v.style.display="",c=null}v?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),$?.addEventListener("click",o),E?.addEventListener("click",async()=>{const i=c||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u2B06\uFE0F Update Management</h3>
<p class="modal-subtitle">
@@ -1543,17 +1543,17 @@ This will replace current configuration, credentials, and data. Containers will
<button id="updates-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("updates-modal"),L=document.getElementById("updates-btn"),R=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),$=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),N=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function v(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){$.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const s=(()=>{const l=window.APPS||[];for(const p of l)if(p.containerId===n.containerId||p.name===n.containerName||p.id===n.containerName)return p.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",$.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,$.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const E=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(E.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(E.error||"Update failed")}catch(p){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+p.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),$.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const E=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(E.success)n.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(E.error||"Rollback failed")}catch(p){n.textContent="Failed",showNotification("Rollback error: "+p.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(f){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function T(){const f=window._pendingUpdates||[];if(!f.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${f.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of f)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(p)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${U?"":" selected"}>Disabled</option>
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(p)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(p)}"${_?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(p)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",N.innerHTML=n,N.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,p=s.closest("tr"),E=p.querySelector(".auto-schedule").value,U=p.querySelector(".auto-rollback").checked,_=p.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!E,schedule:E||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(f){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}const H=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),y=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),h=document.getElementById("dashcaddy-changelog"),z=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),j=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let x=null;function g(f,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=f)}async function M(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;H.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{H.textContent="Unable to fetch version"}}async function r(f){f||(A.textContent="Checking...",A.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(x=e,e.success&&e.available&&e.remote){C.style.display="",y.style.display="",O.textContent="v"+e.remote.version,h.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else C.style.display="none",y.style.display="none",await M(),f||g("You are running the latest version.","success");f||(A.textContent="Check for Updates",A.disabled=!1)}catch(t){f||(g("Failed to check: "+t.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;z.textContent="Updating...",z.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),z.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(f){throw g("Update failed: "+f.message,"error"),z.textContent="Update Now",z.disabled=!1,f}}async function d(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
</div>`);const h=document.getElementById("updates-modal"),L=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),D=document.getElementById("updates-available-container"),R=document.getElementById("updates-history-container"),H=document.getElementById("updates-auto-container"),P=document.getElementById("updates-last-check");async function v(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',P.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const s=(()=>{const l=window.APPS||[];for(const m of l)if(m.containerId===n.containerId||m.name===n.containerName||m.id===n.containerName)return m.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",D.innerHTML=a,P.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,D.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const k=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(k.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(k.error||"Update failed")}catch(m){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+m.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),D.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const s=n.dataset.id,l=n.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const k=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(k.success)n.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(k.error||"Rollback failed")}catch(m){n.textContent="Failed",showNotification("Rollback error: "+m.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(y){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function C(){const y=window._pendingUpdates||[];if(!y.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
<select class="auto-schedule" data-id="${escapeHtml(m)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
<option value=""${j?"":" selected"}>Disabled</option>
<option value="daily"${j==="daily"?" selected":""}>Daily</option>
<option value="weekly"${j==="weekly"?" selected":""}>Weekly</option>
<option value="monthly"${j==="monthly"?" selected":""}>Monthly</option>
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(m)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(m)}"${_?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(m)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",H.innerHTML=n,H.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,m=s.closest("tr"),k=m.querySelector(".auto-schedule").value,j=m.querySelector(".auto-rollback").checked,_=m.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!k,schedule:k||"weekly",autoRollback:j,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const M=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),B=document.getElementById("dashcaddy-update-details"),U=document.getElementById("dashcaddy-new-version"),T=document.getElementById("dashcaddy-changelog"),b=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),p=document.getElementById("dashcaddy-status-bar"),u=document.getElementById("dashcaddy-history-container");let f=null;function g(y,t){p&&(p.style.display="block",p.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",p.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",p.textContent=y)}async function z(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;M.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{M.textContent="Unable to fetch version"}}async function r(y){y||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(f=e,e.success&&e.available&&e.remote){S.style.display="",B.style.display="",U.textContent="v"+e.remote.version,T.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else S.style.display="none",B.style.display="none",await z(),y||g("You are running the latest version.","success");y||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){y||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;b.textContent="Updating...",b.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),b.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(y){throw g("Update failed: "+y.message,"error"),b.textContent="Update Now",b.disabled=!1,y}}async function d(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){u.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
`+e.join(`
`)+`
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(f){g("Rollback failed: "+f.message,"error")}}A?.addEventListener("click",()=>r(!1)),z?.addEventListener("click",()=>c().catch(()=>{})),j?.addEventListener("click",k),I?.addEventListener("click",w),L?.addEventListener("click",()=>{b?.classList.add("show"),v()}),wireModal(b,R),window.openUpdateModal=function(f){b?.classList.add("show"),v().then(()=>{if(!f)return;const t=$.querySelector(`[data-app-id="${f}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",B),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",S),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{M(),d(),x||r(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=r,setTimeout(()=>r(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(y){g("Rollback failed: "+y.message,"error")}}N?.addEventListener("click",()=>r(!1)),b?.addEventListener("click",()=>c().catch(()=>{})),O?.addEventListener("click",w),I?.addEventListener("click",x),L?.addEventListener("click",()=>{h?.classList.add("show"),v()}),wireModal(h,A),window.openUpdateModal=function(y){h?.classList.add("show"),v().then(()=>{if(!y)return;const t=D.querySelector(`[data-app-id="${y}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",$),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",E),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{z(),d(),f||r(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=r,setTimeout(()=>r(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
<h3>\u{1F433} Docker Resources</h3>
<p class="modal-subtitle">Manage volumes, networks, and view disk usage.</p>
@@ -1602,7 +1602,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="dr-close">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("docker-resources-modal"),L=document.getElementById("docker-resources-btn"),R=document.getElementById("dr-close");function I(D){if(!D||D===0)return"0 B";const v=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,T)).toFixed(1)+" "+v[T]}async function $(){const D=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const B of T){const S=B.name==="buildkit"||B.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(B.name)}">${escapeHtml(B.name.length>40?B.name.substring(0,37)+"...":B.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(B.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(B.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',S||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(B.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",D.innerHTML=w,D.querySelectorAll(".dr-vol-del").forEach(B=>{B.addEventListener("click",async()=>{if(confirm(`Delete volume "${B.dataset.name}"? Data will be lost.`)){B.textContent="...",B.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(B.dataset.name)}?force=true`),$()}catch(S){showNotification("Delete failed: "+S.message,"error"),B.textContent="Delete",B.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),v=D.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),D.value="",showNotification(`Volume "${v}" created`,"success"),$()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const B of T){const S=["bridge","host","none"].includes(B.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(B.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(B.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(B.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${B.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',S||(w+=`<button class="dr-net-del" data-id="${escapeHtml(B.id)}" data-name="${escapeHtml(B.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",D.innerHTML=w,D.querySelectorAll(".dr-net-del").forEach(B=>{B.addEventListener("click",async()=>{if(confirm(`Delete network "${B.dataset.name}"?`)){B.textContent="...",B.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(B.dataset.id)}`),P()}catch(S){showNotification("Delete failed: "+S.message,"error"),B.textContent="Delete",B.disabled=!1}}})})}catch(v){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),T=D.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:v.value}),D.value="",showNotification(`Network "${T}" created`,"success"),P()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
</div>`);const h=document.getElementById("docker-resources-modal"),L=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function I(P){if(!P||P===0)return"0 B";const v=["B","KB","MB","GB","TB"],C=Math.floor(Math.log(Math.abs(P))/Math.log(1024));return(P/Math.pow(1024,C)).toFixed(1)+" "+v[C]}async function D(){const P=document.getElementById("dr-vol-list");try{const C=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(C.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of C){const E=$.name==="buildkit"||$.name.length===64;x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml($.name)}">${escapeHtml($.name.length>40?$.name.substring(0,37)+"...":$.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+='<td style="padding: 6px; text-align: right;">',E||(x+=`<button class="dr-vol-del" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",P.innerHTML=x,P.querySelectorAll(".dr-vol-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete volume "${$.dataset.name}"? Data will be lost.`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent($.dataset.name)}?force=true`),D()}catch(E){showNotification("Delete failed: "+E.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-vol-name"),v=P.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),P.value="",showNotification(`Volume "${v}" created`,"success"),D()}catch(C){showNotification("Create failed: "+C.message,"error")}});async function R(){const P=document.getElementById("dr-net-list");try{const C=(await getJSON("/api/v1/docker/networks")).networks||[];if(C.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let x='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const $ of C){const E=["bridge","host","none"].includes($.name);x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml($.name)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.driver)}</td>`,x+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml($.scope)}</td>`,x+=`<td style="padding: 6px; text-align: center;">${$.containers}</td>`,x+='<td style="padding: 6px; text-align: right;">',E||(x+=`<button class="dr-net-del" data-id="${escapeHtml($.id)}" data-name="${escapeHtml($.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),x+="</td></tr>"}x+="</table>",P.innerHTML=x,P.querySelectorAll(".dr-net-del").forEach($=>{$.addEventListener("click",async()=>{if(confirm(`Delete network "${$.dataset.name}"?`)){$.textContent="...",$.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent($.dataset.id)}`),R()}catch(E){showNotification("Delete failed: "+E.message,"error"),$.textContent="Delete",$.disabled=!1}}})})}catch(v){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const P=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),C=P.value.trim();if(!C){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:C,driver:v.value}),P.value="",showNotification(`Network "${C}" created`,"success"),R()}catch(x){showNotification("Create failed: "+x.message,"error")}});async fuLine truncated
<div class="weather-modal-content" style="min-width: 650px; max-width: 800px;">
<h3>\u{1F4E6} Import Docker Compose</h3>
<p class="modal-subtitle">Paste a docker-compose.yml to import and deploy services.</p>
@@ -1643,8 +1643,8 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<div id="compose-progress-content"></div>
</div>
</div>
</div>`);const b=document.getElementById("compose-import-modal"),L=document.getElementById("compose-import-btn"),R=document.getElementById("compose-cancel");wireModal(b,R);let I=null;function $(N){document.getElementById("compose-step-paste").style.display=N==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=N==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=N==="progress"?"":"none"}L?.addEventListener("click",()=>{$("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",N=>{const D=N.target.files[0];if(!D)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const N=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!N){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),T=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:N,stackName:D});I=w,I.stackName=D,P(w),$("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{v.textContent=T,v.disabled=!1}});function P(N){const D=document.getElementById("compose-preview-content");let v="";N.networks&&N.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${N.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),N.volumes&&N.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${N.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${N.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const T of N.services){const w=T.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),v+="</div>",!T.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(B=>`${B.host}:${B.container}`).join(", ")}</div>`),T.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const B=[];T.resources.cpus&&B.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&B.push(`Mem: ${T.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${B.join(", ")}</div>`}v+="</div>"}v+="</div>",D.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>$("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const N=document.getElementById("compose-deploy-btn");N.textContent="Deploying...",N.disabled=!0,$("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const w of v.results){const B=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${B} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(T+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>$("paste"))}finally{N.textContent="Deploy All",N.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
</div>`);const h=document.getElementById("compose-import-modal"),L=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(h,A);let I=null;function D(H){document.getElementById("compose-step-paste").style.display=H==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=H==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=H==="progress"?"":"none"}L?.addEventListener("click",()=>{D("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",H=>{const P=H.target.files[0];if(!P)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(P)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const H=document.getElementById("compose-yaml").value.trim(),P=document.getElementById("compose-stack-name").value.trim()||"stack";if(!H){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),C=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const x=await postJSON("/api/v1/apps/import-compose",{yaml:H,stackName:P});I=x,I.stackName=P,R(x),D("preview")}catch(x){showNotification("Parse failed: "+x.message,"error")}finally{v.textContent=C,v.disabled=!1}});function R(H){const P=document.getElementById("compose-preview-content");let v="";H.networks&&H.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${H.networks.map(C=>`<code>${escapeHtml(C)}</code>`).join(", ")}</div>`),H.volumes&&H.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${H.volumes.map(C=>`<code>${escapeHtml(C)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${H.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const C of H.services){const x=C.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${x}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(C.name)}`,C.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(C.reason)}</span>`),v+="</div>",!C.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(C.image)}</code></div>`,C.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${C.ports.map($=>`${$.host}:${$.container}`).join(", ")}</div>`),C.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${C.volumes.length}</div>`),Object.keys(C.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(C.environment).length}</div>`),C.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(C.envFileWarning)}</div>`),C.resources?.cpus||C.resources?.memory)){const $=[];C.resources.cpus&&$.push(`CPU: ${C.resources.cpus}`),C.resources.memory&&$.push(`Mem: ${C.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${$.join(", ")}</div>`}v+="</div>"}v+="</div>",P.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>D("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const H=document.getElementById("compose-deploy-btn");H.textContent="Deploying...",H.disabled=!0,D("progress");const P=document.getElementById("compose-progress-content");P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let C=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;C+='<div class="scroll-container" style="max-height: 350px;">';for(const x of v.results){const $=x.status==="deployed"||x.status==="created"?"\u2705":x.status==="exists"?"\u26A1":x.status==="skipped"?"\u23ED":"\u274C";C+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',C+=`${$} <strong>${escapeHtml(x.name)}</strong> (${x.type}) \u2014 ${escapeHtml(x.status)}`,x.error&&(C+=` <span style="color: var(--bad-fg);">${escapeHtml(x.error)}</span>`),x.subdomain&&(C+=` \u2192 <code>${escapeHtml(x.subdomain)}</code>`),x.reason&&(C+=` <span style="color: var(--muted);">(${escapeHtml(x.reason)})</span>`),C+="</div>"}C+="</div>",C+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>D("paste"))}finally{H.textContent="Deploy All",H.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
<h3 id="exec-title">Terminal</h3>
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
@@ -1652,11 +1652,11 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="exec-close">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("exec-modal"),L=document.getElementById("exec-terminal"),R=document.getElementById("exec-close");let I=null,$=null,P=null;function N(){if($){try{$.close()}catch{}$=null}if(I){try{I.dispose()}catch{}I=null}P=null,L.innerHTML=""}function D(v,T){if(N(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||v}`,b?.classList.add("show"),typeof Terminal>"u"){L.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(L),P&&setTimeout(()=>P.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";$=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(v)}`),$.binaryType="arraybuffer",$.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const S=P.proposeDimensions();S&&$.send(JSON.stringify({type:"resize",cols:S.cols,rows:S.rows}))}},$.onmessage=S=>{if(typeof S.data=="string"){try{const H=JSON.parse(S.data);if(H.type==="connected"){I.writeln(`\x1B[32mConnected (${H.shell})\x1B[0m\r
`);return}if(H.type==="error"){I.writeln(`\x1B[31mError: ${H.message}\x1B[0m`);return}if(H.type==="exit"){I.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(S.data)}else I.write(new Uint8Array(S.data))},$.onclose=()=>{I&&I.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},$.onerror=()=>{I&&I.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},I.onData(S=>{$&&$.readyState===WebSocket.OPEN&&$.send(S)}),I.onResize(({cols:S,rows:H})=>{$&&$.readyState===WebSocket.OPEN&&$.send(JSON.stringify({type:"resize",cols:S,rows:H}))});const B=()=>{P&&P.fit()};window.addEventListener("resize",B),b._resizeHandler=B}R?.addEventListener("click",()=>{N(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",v=>{v.target===b&&(N(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
</div>`);const h=document.getElementById("exec-modal"),L=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let I=null,D=null,R=null;function H(){if(D){try{D.close()}catch{}D=null}if(I){try{I.dispose()}catch{}I=null}R=null,L.innerHTML=""}function P(v,C){if(H(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${C||v}`,h?.classList.add("show"),typeof Terminal>"u"){L.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(R=new FitAddon.FitAddon,I.loadAddon(R)),I.open(L),R&&setTimeout(()=>R.fit(),50);const x=location.protocol==="https:"?"wss:":"ws:";D=new WebSocket(`${x}//${location.host}/ws/exec/${encodeURIComponent(v)}`),D.binaryType="arraybuffer",D.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),R){const E=R.proposeDimensions();E&&D.send(JSON.stringify({type:"resize",cols:E.cols,rows:E.rows}))}},D.onmessage=E=>{if(typeof E.data=="string"){try{const M=JSON.parse(E.data);if(M.type==="connected"){I.writeln(`\x1B[32mConnected (${M.shell})\x1B[0m\r
`);return}if(M.type==="error"){I.writeln(`\x1B[31mError: ${M.message}\x1B[0m`);return}if(M.type==="exit"){I.writeln(`\r
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(E.data)}else I.write(new Uint8Array(E.data))},D.onclose=()=>{I&&I.writeln(`\r
\x1B[33mDisconnected.\x1B[0m`)},D.onerror=()=>{I&&I.writeln(`\r
\x1B[31mConnection error.\x1B[0m`)},I.onData(E=>{D&&D.readyState===WebSocket.OPEN&&D.send(E)}),I.onResize(({cols:E,rows:M})=>{D&&D.readyState===WebSocket.OPEN&&D.send(JSON.stringify({type:"resize",cols:E,rows:M}))});const $=()=>{R&&R.fit()};window.addEventListener("resize",$),h._resizeHandler=$}A?.addEventListener("click",()=>{H(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",v=>{v.target===h&&(H(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=P})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
<h3>\u{1F4DC} Audit Log</h3>
<p class="modal-subtitle">
@@ -1692,7 +1692,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="audit-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("audit-modal"),L=document.getElementById("audit-log-btn"),R=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),$=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),N=document.getElementById("audit-log-container"),D=document.getElementById("audit-load-more");let v=0;const T=50;async function w(B){try{B||(v=0,N.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const S=P.value;let H=`/api/v1/audit-logs?limit=${T}&offset=${v}`;S&&(H+=`&action=${encodeURIComponent(S)}`);const y=await(await fetch(H)).json(),O=y.success&&y.entries?y.entries:[];if(O.length===0&&!B){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',D.style.display="none";return}let h="";B||(h='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',h+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const z of O){const A=z.outcome==="success";h+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',h+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(z.timestamp)}</td>`,h+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(z.ip||"-")}</td>`,h+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(z.action||"-")}</td>`,h+=`<td style="padding: 6px;">${escapeHtml(z.resource||"-")}</td>`,h+=`<td style="padding: 6px;"><span style="color: ${A?"var(--ok-fg)":"var(--bad-fg)"};">${A?"\u2713":"\u2717"}</span></td>`,h+="</tr>",z.details&&Object.keys(z.details).length>0&&(h+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(z.details,null,2))}</pre></td></tr>`)}if(!B)h+="</table>",N.innerHTML=h;else{const z=N.querySelector("table");z&&z.insertAdjacentHTML("beforeend",h)}v+=O.length,D.style.display=O.length>=T?"":"none",N.querySelectorAll(".audit-row").forEach(z=>{z.dataset.wired||(z.dataset.wired="true",z.addEventListener("click",()=>{const A=z.nextElementSibling;A&&A.classList.contains("audit-detail")&&(A.style.display=A.style.display==="none"?"":"none")}))})}catch(S){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(S.message)}</div>`}}L?.addEventListener("click",()=>{b?.classList.add("show"),w(!1)}),wireModal(b,R),I?.addEventListener("click",()=>w(!1)),P?.addEventListener("change",()=>w(!1)),D?.addEventListener("click",()=>w(!0)),$?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const S=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();S.success?w(!1):showNotification("Error: "+(S.error||"Clear failed"),"error")}catch(B){showNotification("Error: "+B.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
</div>`);const h=document.getElementById("audit-modal"),L=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),D=document.getElementById("audit-clear-btn"),R=document.getElementById("audit-filter"),H=document.getElementById("audit-log-container"),P=document.getElementById("audit-load-more");let v=0;const C=50;async function x($){try{$||(v=0,H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const E=R.value;let M=`/api/v1/audit-logs?limit=${C}&offset=${v}`;E&&(M+=`&action=${encodeURIComponent(E)}`);const B=await(await fetch(M)).json(),U=B.success&&B.entries?B.entries:[];if(U.length===0&&!$){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',P.style.display="none";return}let T="";$||(T='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',T+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const b of U){const N=b.outcome==="success";T+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',T+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(b.timestamp)}</td>`,T+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(b.ip||"-")}</td>`,T+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(b.action||"-")}</td>`,T+=`<td style="padding: 6px;">${escapeHtml(b.resource||"-")}</td>`,T+=`<td style="padding: 6px;"><span style="color: ${N?"var(--ok-fg)":"var(--bad-fg)"};">${N?"\u2713":"\u2717"}</span></td>`,T+="</tr>",b.details&&Object.keys(b.details).length>0&&(T+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(b.details,null,2))}</pre></td></tr>`)}if(!$)T+="</table>",H.innerHTML=T;else{const b=H.querySelector("table");b&&b.insertAdjacentHTML("beforeend",T)}v+=U.length,P.style.display=U.length>=C?"":"none",H.querySelectorAll(".audit-row").forEach(b=>{b.dataset.wired||(b.dataset.wired="true",b.addEventListener("click",()=>{const N=b.nextElementSibling;N&&N.classList.contains("audit-detail")&&(N.style.display=N.style.display==="none"?"":"none")}))})}catch(E){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(E.message)}</div>`}}L?.addEventListener("click",()=>{h?.classList.add("show"),x(!1)}),wireModal(h,A),I?.addEventListener("click",()=>x(!1)),R?.addEventListener("change",()=>x(!1)),P?.addEventListener("click",()=>x(!0)),D?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const E=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();E.success?x(!1):showNotification("Error: "+(E.error||"Clear failed"),"error")}catch($){showNotification("Error: "+$.message,"error")}})})(),(function(){injectModal("security-modal",`<div id="security-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
<h3>\u{1F6E1}\uFE0F Security Center</h3>
<p class="modal-subtitle">
@@ -1771,39 +1771,39 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
<button id="sec-cancel">Close</button>
</div>
</div>
</div>`);const b=document.getElementById("security-modal"),L=document.getElementById("security-center-btn"),R=document.getElementById("sec-cancel"),I=b.querySelectorAll(".sec-tab"),$=b.querySelectorAll(".sec-panel");let P=[],N=[],D=null;I.forEach(r=>{r.addEventListener("click",()=>{I.forEach(c=>c.classList.toggle("active",c===r)),$.forEach(c=>c.style.display=c.dataset.panel===r.dataset.tab?"":"none"),r.dataset.tab==="overview"&&B(),r.dataset.tab==="events"&&z(),r.dataset.tab==="hosts"&&m()})}),L&&L.addEventListener("click",()=>{b.classList.add("show"),B(),T()}),R.addEventListener("click",v),b.addEventListener("click",r=>{r.target===b&&v()});function v(){b.classList.remove("show"),w()}function T(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",r=>{try{P=JSON.parse(r.data).events||[],A()}catch{}}),D.addEventListener("security",r=>{try{const c=JSON.parse(r.data);P.unshift(c),P.length>500&&(P.length=500);const d=b.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?A():d==="overview"&&B()}catch{}}),D.onerror=()=>{}}catch(r){console.warn("[security] SSE failed:",r.message)}}function w(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&T()});async function B(){try{const r=new Date(Date.now()-864e5).toISOString(),[c,d,k]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(r)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(r)}`)]),f=(await c.json()).data||{},t=(await d.json()).data?.hosts||[],e=(await k.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${f.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${f.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${f.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,S("sec-top-actors",f.top_actors||[]),S("sec-top-targets",f.top_targets||[])}catch(r){console.warn("[security] refreshOverview failed:",r.message)}}function S(r,c){const d=document.getElementById(r);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(k=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(k.key))}</td><td style="text-align:right;color:var(--muted);">${k.count}</td></tr>`).join("")+"</table>"}const H=document.getElementById("sec-filter-source"),C=document.getElementById("sec-filter-severity"),y=document.getElementById("sec-filter-host"),O=document.getElementById("sec-filter-actor"),h=document.getElementById("sec-refresh-btn");[H,C,y].forEach(r=>r.addEventListener("change",z)),O.addEventListener("input",M(z,250)),h.addEventListener("click",z);async function z(){try{const r=new URLSearchParams;r.set("limit","200"),H.value&&r.set("source_type",H.value),C.value&&r.set("severity",C.value),y.value&&r.set("source_host",y.value),O.value&&r.set("actor_prefix",O.value),P=(await(await fetch(`/api/v1/security/events?${r}`)).json()).data.events||[],A(),(!y.options.length||y.options.length===1)&&await u()}catch(r){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}function A(){const r=document.getElementById("sec-events-container");if(!P.length){r.innerHTML='<div class="panel-empty">No events</div>';return}r.innerHTML=P.slice(0,200).map(j).join("")}function j(r){const c=r.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",k=r.ts?new Date(r.ts).toLocaleTimeString():"",f=r.source_type||"",t=r.actor||"\u2014",e=r.target||"",a=r.action||"",o=r.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
</div>`);const h=document.getElementById("security-modal"),L=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),I=h.querySelectorAll(".sec-tab"),D=h.querySelectorAll(".sec-panel");let R=[],H=[],P=null;I.forEach(r=>{r.addEventListener("click",()=>{I.forEach(c=>c.classList.toggle("active",c===r)),D.forEach(c=>c.style.display=c.dataset.panel===r.dataset.tab?"":"none"),r.dataset.tab==="overview"&&$(),r.dataset.tab==="events"&&b(),r.dataset.tab==="hosts"&&u()})}),L&&L.addEventListener("click",()=>{h.classList.add("show"),$(),C()}),A.addEventListener("click",v),h.addEventListener("click",r=>{r.target===h&&v()});function v(){h.classList.remove("show"),x()}function C(){if(x(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{P=new EventSource("/api/v1/security/events/stream"),P.addEventListener("init",r=>{try{R=JSON.parse(r.data).events||[],N()}catch{}}),P.addEventListener("security",r=>{try{const c=JSON.parse(r.data);R.unshift(c),R.length>500&&(R.length=500);const d=h.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?N():d==="overview"&&$()}catch{}}),P.onerror=()=>{}}catch(r){console.warn("[security] SSE failed:",r.message)}}function x(){if(P){try{P.close()}catch{}P=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{h.classList.contains("show")&&C()});async function $(){try{const r=new Date(Date.now()-864e5).toISOString(),[c,d,w]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(r)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(r)}`)]),y=(await c.json()).data||{},t=(await d.json()).data?.hosts||[],e=(await w.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${y.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${y.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${y.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,E("sec-top-actors",y.top_actors||[]),E("sec-top-targets",y.top_targets||[])}catch(r){console.warn("[security] refreshOverview failed:",r.message)}}function E(r,c){const d=document.getElementById(r);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(w=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(w.key))}</td><td style="text-align:right;color:var(--muted);">${w.count}</td></tr>`).join("")+"</table>"}const M=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),B=document.getElementById("sec-filter-host"),U=document.getElementById("sec-filter-actor"),T=document.getElementById("sec-refresh-btn");[M,S,B].forEach(r=>r.addEventListener("change",b)),U.addEventListener("input",z(b,250)),T.addEventListener("click",b);async function b(){try{const r=new URLSearchParams;r.set("limit","200"),M.value&&r.set("source_type",M.value),S.value&&r.set("severity",S.value),B.value&&r.set("source_host",B.value),U.value&&r.set("actor_prefix",U.value),R=(await(await fetch(`/api/v1/security/events?${r}`)).json()).data.events||[],N(),(!B.options.length||B.options.length===1)&&await p()}catch(r){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}function N(){const r=document.getElementById("sec-events-container");if(!R.length){r.innerHTML='<div class="panel-empty">No events</div>';return}r.innerHTML=R.slice(0,200).map(O).join("")}function O(r){const c=r.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",w=r.ts?new Date(r.ts).toLocaleTimeString():"",y=r.source_type||"",t=r.actor||"\u2014",e=r.target||"",a=r.action||"",o=r.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
<span style="color:${d};font-weight:600;">${g(c)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(f)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(y)}</span>
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span>
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span>
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span>
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(k)}</span>
</div>`}async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=y.value;y.innerHTML='<option value="">All hosts</option>'+c.map(k=>`<option value="${g(k.id)}">${g(k.label||k.id)}</option>`).join(""),d&&(y.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",x),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];N=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(k=>{const f=k.enabled?k.last_seen_at?Date.now()-Date.parse(k.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(w)}</span>
</div>`}async function p(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=B.value;B.innerHTML='<option value="">All hosts</option>'+c.map(w=>`<option value="${g(w.id)}">${g(w.label||w.id)}</option>`).join(""),d&&(B.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",f),document.getElementById("sec-hosts-refresh").addEventListener("click",u);async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];H=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(w=>{const y=w.enabled?w.last_seen_at?Date.now()-Date.parse(w.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
<div>
<strong>${g(k.label||k.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(k.type)}</span>
<strong>${g(w.label||w.id)}</strong>
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(w.type)}</span>
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
id: ${g(k.id)} \xB7
registered ${new Date(k.registered_at).toLocaleDateString()} \xB7
last seen ${k.last_seen_at?new Date(k.last_seen_at).toLocaleString():"never"}
id: ${g(w.id)} \xB7
registered ${new Date(w.registered_at).toLocaleDateString()} \xB7
last seen ${w.last_seen_at?new Date(w.last_seen_at).toLocaleString():"never"}
</div>
</div>
<div>
<span style="font-size:0.85rem;margin-right:12px;">${f}</span>
${k.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(k.id)}" style="color:var(--bad-fg);">Remove</button>`}
<span style="font-size:0.85rem;margin-right:12px;">${y}</span>
${w.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${g(w.id)}" style="color:var(--bad-fg);">Remove</button>`}
</div>
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(k=>{k.addEventListener("click",async()=>{confirm(`Remove host ${k.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(k.dataset.id)}`,{method:"DELETE"}),m())})})}catch(r){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}async function x(){const r=prompt("Host id (lowercase, no spaces):");if(!r)return;const c=prompt("Display label:",r)||r,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const k=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:r,label:c,type:d})}),f=await k.json();if(!k.ok){alert("Failed: "+(f?.error?.message||k.statusText));return}alert(`\u2705 Host registered!
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(w=>{w.addEventListener("click",async()=>{confirm(`Remove host ${w.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(w.dataset.id)}`,{method:"DELETE"}),u())})})}catch(r){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+g(r.message)+"</div>"}}async function f(){const r=prompt("Host id (lowercase, no spaces):");if(!r)return;const c=prompt("Display label:",r)||r,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const w=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:r,label:c,type:d})}),y=await w.json();if(!w.ok){alert("Failed: "+(y?.error?.message||w.statusText));return}alert(`\u2705 Host registered!
id: ${f.data.host.id}
label: ${f.data.host.label}
type: ${f.data.host.type}
id: ${y.data.host.id}
label: ${y.data.host.label}
type: ${y.data.host.type}
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
${f.data.api_key}
${y.data.api_key}
Send this key as: Authorization: Bearer <api_key>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k){alert("Failed: "+k.message)}}function g(r){return String(r).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function M(r,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>r.apply(this,arguments),c)}}})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),u()}catch(w){alert("Failed: "+w.message)}}function g(r){return String(r).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"})[c])}function z(r,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>r.apply(this,arguments),c)}}})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
<label for="weather-location-input">Location:</label>
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or &ldquo;City, Country&rdquo;</div>
@@ -1814,23 +1814,23 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k)
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">&deg;C / km/h</span></label>
</div>
</div>
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const L="weather-location",R="weather-zip",I="weather-geo",$="weather-unit";!safeGet(L)&&safeGet(R)&&safeSet(L,safeGet(R));function P(){return safeGet($)||"imperial"}function N(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(h){return T[Math.round(h/22.5)%16]}async function B(h){const z=safeGet(I);if(z)try{const x=JSON.parse(z);if(x.query===h)return x}catch{}const A=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(h)}&count=1&language=en&format=json`);if(!A.ok)throw new Error("Geocoding failed");const j=await A.json();if(!j.results||!j.results.length)throw new Error("Location not found");const u=j.results[0],m={query:h,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(I,JSON.stringify(m)),m}function S(h){return h.countryCode==="US"&&h.state?`${h.city}, ${h.state}`:h.country?`${h.city}, ${h.country}`:h.city}async function H(h){try{const z=await B(h),A=P(),j=A==="metric"?"celsius":"fahrenheit",u=A==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${z.lat}&longitude=${z.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${j}&wind_speed_unit=${u}`,x=await fetch(m);if(!x.ok)throw new Error("Weather fetch failed");const M=(await x.json()).current,r=M.weather_code;return{temp:Math.round(M.temperature_2m),condition:D[r]||"Unknown",icon:v[r]||"\u{1F324}\uFE0F",locationStr:S(z),windSpeed:Math.round(M.wind_speed_10m),windDir:w(M.wind_direction_10m),unit:A}}catch(z){return console.warn("Weather fetch failed:",z),null}}async function C(){const h=N();if(!h.icon||!h.temp||!h.condition||!h.location||!h.wind){console.warn("Weather widget elements not found");return}const z=safeGet(L);if(!z){h.location.textContent="Set Location",h.temp.textContent="--\xB0",h.condition.textContent="Click \u2699\uFE0F to configure",h.wind.textContent="--",h.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const A=await H(z);if(A){const j=A.unit==="metric"?"\xB0C":"\xB0F",u=A.unit==="metric"?"km/h":"mph";h.location.textContent=A.locationStr,h.temp.textContent=`${A.temp}${j}`,h.condition.textContent=A.condition,h.wind.textContent=`Wind: ${A.windSpeed} ${u} ${A.windDir}`,h.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(A.icon)}</span>`}}catch(A){b.logError("[Weather] Update Error",A,{function:"updateWeather"}),h.location.textContent="Weather Error",h.temp.textContent="Error",h.condition.textContent="Failed to load",h.wind.textContent="--"}}const y=document.getElementById("weather-modal"),O=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{O.value=safeGet(L)||"";const h=P(),z=y.querySelector(`input[name="weather-unit-radio"][value="${h}"]`);z&&(z.checked=!0),y.classList.add("show"),O.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{y.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const h=O.value.trim();if(h){safeGet(L)!==h&&safeSet(I,""),safeSet(L,h);const A=y.querySelector('input[name="weather-unit-radio"]:checked'),j=A?A.value:"imperial",u=P();safeSet($,j),u!==j&&safeSet(I,""),y.classList.remove("show"),C()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(y),document.addEventListener("keydown",h=>{h.keyLine truncated
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const L="weather-location",A="weather-zip",I="weather-geo",D="weather-unit";!safeGet(L)&&safeGet(A)&&safeSet(L,safeGet(A));function R(){return safeGet(D)||"imperial"}function H(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const P={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},C=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function x(T){return C[Math.round(T/22.5)%16]}async function $(T){const b=safeGet(I);if(b)try{const f=JSON.parse(b);if(f.query===T)return f}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(T)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const O=await N.json();if(!O.results||!O.results.length)throw new Error("Location not found");const p=O.results[0],u={query:T,lat:p.latitude,lon:p.longitude,city:p.name,state:p.admin1||"",country:p.country||"",countryCode:p.country_code||""};return safeSet(I,JSON.stringify(u)),u}function E(T){return T.countryCode==="US"&&T.state?`${T.city}, ${T.state}`:T.country?`${T.city}, ${T.country}`:T.city}async function M(T){try{const b=await $(T),N=R(),O=N==="metric"?"celsius":"fahrenheit",p=N==="metric"?"kmh":"mph",u=`https://api.open-meteo.com/v1/forecast?latitude=${b.lat}&longitude=${b.lon}&current=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${p}`,f=await fetch(u);if(!f.ok)throw new Error("Weather fetch failed");const z=(await f.json()).current,r=z.weather_code;return{temp:Math.round(z.temperature_2m),condition:P[r]||"Unknown",icon:v[r]||"\u{1F324}\uFE0F",locationStr:E(b),windSpeed:Math.round(z.wind_speed_10m),windDir:x(z.wind_direction_10m),unit:N}}catch(b){return console.warn("Weather fetch failed:",b),null}}async function S(){const T=H();if(!T.icon||!T.temp||!T.condition||!T.location||!T.wind){console.warn("Weather widget elements not found");return}const b=safeGet(L);if(!b){T.location.textContent="Set Location",T.temp.textContent="--\xB0",T.condition.textContent="Click \u2699\uFE0F to configure",T.wind.textContent="--",T.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await M(b);if(N){const O=N.unit==="metric"?"\xB0C":"\xB0F",p=N.unit==="metric"?"km/h":"mph";T.location.textContent=N.locationStr,T.temp.textContent=`${N.temp}${O}`,T.condition.textContent=N.condition,T.wind.textContent=`Wind: ${N.windSpeed} ${p} ${N.windDir}`,T.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){h.logError("[Weather] Update Error",N,{function:"updateWeather"}),T.location.textContent="Weather Error",T.temp.textContent="Error",T.condition.textContent="Failed to load",T.wind.textContent="--"}}const B=document.getElementById("weather-modal"),U=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{U.value=safeGet(L)||"";const T=R(),b=B.querySelector(`input[name="weather-unit-radio"][value="${T}"]`);b&&(b.checked=!0),B.classList.add("show"),U.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{B.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const T=U.value.trim();if(T){safeGet(L)!==T&&safeSet(I,""),safeSet(L,T);const N=B.querySelector('input[name="weather-unit-radio"]:checked'),O=N?N.value:"imperial",p=R();safeSet(D,O),p!==O&&safeSet(I,""),B.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(B),document.addEventListener("keydown",T=>{T.keyLine truncated
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
${F}
${_}
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(U*Math.PI/180)}" y2="${l+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(E*Math.PI/180)}" y2="${l+38*Math.sin(E*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(p*Math.PI/180)}" y2="${l+42*Math.sin(p*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(j*Math.PI/180)}" y2="${l+28*Math.sin(j*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(k*Math.PI/180)}" y2="${l+38*Math.sin(k*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(m*Math.PI/180)}" y2="${l+42*Math.sin(m*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
</svg>`,J=t.getHours()>=12?"PM":"AM";L.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${H(t)}</span></div></div>`,T="analog"}function m(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":h(t);break;case"lcd-blue":h(t);break;case"lcd-amber":h(t);break;case"lcd-retro":h(t);break;case"lcd-taxi":h(t);break;case"flip":z(t);break;case"binary":j(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:O(t)}a===0&&o===0&&e!==N&&(N=e,S(e)),a!==0&&(N=-1)}function x(){clearTimeout(B);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;B=setTimeout(()=>{m(),x()},e)}document.addEventListener("visibilitychange",()=>{v="",C(),m(),x()}),m(),x();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let M='<div class="clock-style-grid">';g.forEach(t=>{M+=`<label class="clock-style-option">
</svg>`,J=t.getHours()>=12?"PM":"AM";L.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${M(t)}</span></div></div>`,C="analog"}function u(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(R!=="default"?" "+R:"");switch(h.className!==i&&(h.className=i),R){case"lcd":T(t);break;case"lcd-blue":T(t);break;case"lcd-amber":T(t);break;case"lcd-retro":T(t);break;case"lcd-taxi":T(t);break;case"flip":b(t);break;case"binary":O(t);break;case"analog":p(t,!1);break;case"roman":p(t,!0);break;default:U(t)}a===0&&o===0&&e!==H&&(H=e,E(e)),a!==0&&(H=-1)}function f(){clearTimeout($);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;$=setTimeout(()=>{u(),f()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),u(),f()}),u(),f();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let z='<div class="clock-style-grid">';g.forEach(t=>{z+=`<label class="clock-style-option">
<input type="radio" name="clock-style-radio" value="${t.id}">
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
</label>`}),M+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
</label>`}),z+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
<div class="weather-modal-content" style="max-width: 420px;">
<h3>Clock Settings</h3>
<div style="margin-bottom: 16px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
${M}
${z}
</div>
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
@@ -1854,7 +1854,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k)
<button id="clock-settings-save">Save</button>
</div>
</div>
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),k=document.getElementById("clock-volume-section");function f(){const t=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",k.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{k.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{f(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(d.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=r.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=e,v="",C(),m(),x(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,v="",C(),m()})})})(),(function(){async function b(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[v,T]of Object.entries(D.status)){const w=document.getElementById("uptime-"+v),B=document.getElementById("uptime-bar-"+v);if(!w)continue;const S=T.uptime?.["24h"];if(S!=null){const H=S.toFixed(1);w.textContent=`${H}% uptime`,w.className="uptime-chip",S>=99.9?w.classList.add("excellent"):S>=99?w.classList.add("good"):S>=95?w.classList.add("degraded"):w.classList.add("poor"),B&&(B.style.width=H+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let L;try{L=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{L=new Set}let R=[];async function I(N){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const T=v.updates||[];if(R=T,N&&T.length>0){const w=window._lastKnownUpdateCount||0;w>0&&T.length>w&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const w of T){const B=window.APPS||[];for(const S of B)if(S.containerId===w.containerId||S.id===w.containerName||S.name===w.containerName){if(L.has(S.id))break;const H=document.getElementById("update-badge-"+S.id),C=document.getElementById("update-btn-"+S.id);H&&(H.classList.add("visible"),H.title="Update available \u2014 click to open Update Management.",H.style.cursor="pointer",H.onclick=y=>{y.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(S.id)}),C&&(C.style.background="#f97316",C.style.borderColor="#f97316",C.style.boxShadow="0 0 6px #f9731688",C.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function $(){setTimeout(()=>{b(),I()},5e3),setInterval(()=>{b(),I(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(b,1e3)}catch(N){console.warn("[Card Badges] Error in refreshAll hook:",N.message)}}),$()})(),(function(){var b=null,L=null,R={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},$=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var N=document.getElementById("theme-label");function D(o){if(I[o])return I[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function v(){N&&(N.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),s=o[(n+1)%o.length];window.applyTheme(s),v()}),v();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),w=document.getElementById("clock-volume-section");function y(){const t=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",w.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{w.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(d.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=r.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),R=e,v="",S(),u(),f(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{R=t.value,v="",S(),u()})})})(),(function(){async function h(){try{const P=await(await fetch("/api/v1/health-checks/status")).json();if(!P.success||!P.status)return;for(const[v,C]of Object.entries(P.status)){const x=document.getElementById("uptime-"+v),$=document.getElementById("uptime-bar-"+v);if(!x)continue;const E=C.uptime?.["24h"];if(E!=null){const M=E.toFixed(1);x.textContent=`${M}% uptime`,x.className="uptime-chip",E>=99.9?x.classList.add("excellent"):E>=99?x.classList.add("good"):E>=95?x.classList.add("degraded"):x.classList.add("poor"),$&&($.style.width=M+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let L;try{L=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{L=new Set}let A=[];async function I(H){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(x=>x.classList.remove("visible"));const C=v.updates||[];if(A=C,H&&C.length>0){const x=window._lastKnownUpdateCount||0;x>0&&C.length>x&&showNotification(`${C.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=C.length}if(!C.length)return;for(const x of C){const $=window.APPS||[];for(const E of $)if(E.containerId===x.containerId||E.id===x.containerName||E.name===x.containerName){if(L.has(E.id))break;const M=document.getElementById("update-badge-"+E.id),S=document.getElementById("update-btn-"+E.id);M&&(M.classList.add("visible"),M.title="Update available \u2014 click to open Update Management.",M.style.cursor="pointer",M.onclick=B=>{B.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(E.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function D(){setTimeout(()=>{h(),I()},5e3),setInterval(()=>{h(),I(!0)},6e4)}const R=window.refreshAll;R&&(window.refreshAll=async function(){try{await R(),setTimeout(h,1e3)}catch(H){console.warn("[Card Badges] Error in refreshAll hook:",H.message)}}),D()})(),(function(){var h=null,L=null,A={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},D=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],R=document.getElementById("theme");if(!R)return;var H=document.getElementById("theme-label");function P(o){if(I[o])return I[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function v(){H&&(H.textContent=P(window.getActiveTheme()))}R.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),s=o[(n+1)%o.length];window.applyTheme(s),v()}),v();function C(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
<h3>DashCaddy License</h3>
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
@@ -1891,16 +1891,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k)
<button id="license-activate" class="btn-accent">Activate</button>
</div>
</div>
</div>`);const b=document.getElementById("license-modal"),L=document.getElementById("license-code-input"),R=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),$=document.getElementById("license-error"),P=document.getElementById("license-success"),N=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),T=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),B=document.getElementById("license-activate-section");let S=null;function H(){$.style.display="none",P.style.display="none"}function C(x){H(),$.textContent=x,$.style.display="block"}function y(x){H(),P.textContent=x,P.style.display="block"}function O(x){if(S=x,x.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",N.textContent="\u2605",D.textContent="Premium Active";const r=x.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(x.expiresAt).toLocaleDateString()}</strong> (${x.daysRemaining} days remaining)</div>`;T.innerHTML=`
<div>Code: <code style="font-family: monospace;">${x.code||"***"}</code></div>
</div>`);const h=document.getElementById("license-modal"),L=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),D=document.getElementById("license-error"),R=document.getElementById("license-success"),H=document.getElementById("license-badge-icon"),P=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),C=document.getElementById("license-details"),x=document.getElementById("license-feature-list"),$=document.getElementById("license-activate-section");let E=null;function M(){D.style.display="none",R.style.display="none"}function S(f){M(),D.textContent=f,D.style.display="block"}function B(f){M(),R.textContent=f,R.style.display="block"}function U(f){if(E=f,f.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",H.textContent="\u2605",P.textContent="Premium Active";const r=f.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(f.expiresAt).toLocaleDateString()}</strong> (${f.daysRemaining} days remaining)</div>`;C.innerHTML=`
<div>Code: <code style="font-family: monospace;">${f.code||"***"}</code></div>
${r}
`,B.style.display="none",R.style.display="none",I.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",N.textContent="\u2606",D.textContent=x.expired?"License Expired":"Free Tier",T.innerHTML=x.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",B.style.display="",R.style.display="",I.style.display="none";const g=x.premiumFeatures||{},M=new Set(x.features||[]);w.innerHTML=Object.entries(g).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${M.has(r)?"\u2705":"\u{1F512}"}</span>
`,$.style.display="none",A.style.display="none",I.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",H.textContent="\u2606",P.textContent=f.expired?"License Expired":"Free Tier",C.innerHTML=f.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",$.style.display="",A.style.display="",I.style.display="none";const g=f.premiumFeatures||{},z=new Set(f.features||[]);x.innerHTML=Object.entries(g).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
<span style="font-size: 1.1rem;">${z.has(r)?"\u2705":"\u{1F512}"}</span>
<div>
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
</div>
</div>`).join("")}async function h(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(O(g.license),j(g.license))}catch(x){console.warn("Failed to load license status:",x.message)}}async function z(){const x=L.value.trim();if(!x){C("Please enter a license code.");return}H(),R.disabled=!0,R.textContent="Activating...";try{const M=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:x})})).json();M.success?(y(M.message),L.value="",O(M.license),showNotification("License activated! Premium features unlocked.","success",5e3),j(M.license)):C(M.error||"Activation failed")}catch(g){C("Network error: "+g.message)}finally{R.disabled=!1,R.textContent="Activate"}}async function A(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(y(g.message),await h(),showNotification("License deactivated.","info",3e3),j({active:!1})):C(g.error||"Deactivation failed")}catch(x){C("Network error: "+x.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function j(x){const g=document.getElementById("license-status-topbar"),M=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(x.active?"premium":"free"),x.active)if(M.textContent="\u2605",r.textContent="PREMIUM",x.lifetime)c.textContent="\xB7 LIFETIME";else{const d=x.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else M.textContent="\u2606",r.textContent=x.expired?"EXPIRED":"FREE TIER",c.textContent=""}function u(){H(),h(),b.classList.add("show")}L.addEventListener("input",function(){let x=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(x.length>this._prevLength&&(x=x.replace(/-/g,""),x.length>2&&!x.startsWith("DC")&&(x="DC"+x),x.startsWith("DC")&&x.length>2)){const g=["DC"],M=x.substring(2);for(let r=0;r<M.length;r+=5)g.push(M.substring(r,r+5));x=g.join("-")}this._prevLength=x.length,this.value=x}),R.addEventListener("click",z),I.addEventListener("click",A),L.addEventListener("keydown",x=>{x.key==="Enter"&&z()}),wireModal(b,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(x){try{return(await(await fetch(`/api/v1/license/feature/${x}`)).json()).available}catch{return!1}},h().then(x=>{S&&j(S)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const b=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
</div>`).join("")}async function T(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(U(g.license),O(g.license))}catch(f){console.warn("Failed to load license status:",f.message)}}async function b(){const f=L.value.trim();if(!f){S("Please enter a license code.");return}M(),A.disabled=!0,A.textContent="Activating...";try{const z=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();z.success?(B(z.message),L.value="",U(z.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(z.license)):S(z.error||"Activation failed")}catch(g){S("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(B(g.message),await T(),showNotification("License deactivated.","info",3e3),O({active:!1})):S(g.error||"Deactivation failed")}catch(f){S("Network error: "+f.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function O(f){const g=document.getElementById("license-status-topbar"),z=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(f.active?"premium":"free"),f.active)if(z.textContent="\u2605",r.textContent="PREMIUM",f.lifetime)c.textContent="\xB7 LIFETIME";else{const d=f.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else z.textContent="\u2606",r.textContent=f.expired?"EXPIRED":"FREE TIER",c.textContent=""}function p(){M(),T(),h.classList.add("show")}L.addEventListener("input",function(){let f=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(f.length>this._prevLength&&(f=f.replace(/-/g,""),f.length>2&&!f.startsWith("DC")&&(f="DC"+f),f.startsWith("DC")&&f.length>2)){const g=["DC"],z=f.substring(2);for(let r=0;r<z.length;r+=5)g.push(z.substring(r,r+5));f=g.join("-")}this._prevLength=f.length,this.value=f}),A.addEventListener("click",b),I.addEventListener("click",N),L.addEventListener("keydown",f=>{f.key==="Enter"&&b()}),wireModal(h,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=p,window.checkPremiumFeature=async function(f){try{return(await(await fetch(`/api/v1/license/feature/${f}`)).json()).available}catch{return!1}},T().then(f=>{E&&O(E)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const h=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
@@ -1922,7 +1922,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k)
<div class="share-tab-panel" data-panel="public">
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
${b.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
${h.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
</select>
<button id="share-public-create" class="btn-accent" type="button"
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
@@ -1969,17 +1969,17 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(k)
</div>
</div>
</div>
`);const L=document.getElementById("share-modal"),R=document.getElementById("share-modal-service-name"),I=document.getElementById("share-issued"),$=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),N=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),v=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),B=document.getElementById("share-public-create"),S=document.getElementById("share-ts-create"),H=document.getElementById("share-ts-email"),C=document.getElementById("share-public-ttl");let y=null,O="public";function h(){D.style.display="none",v.style.display="none",I.style.display="none"}function z(t){v.style.display="none",I.style.display="none",D.textContent=t,D.style.display="block"}function A(t){D.style.display="none",v.textContent=t,v.style.display="block"}function j(){return window.location.origin}function u(t){return t.startsWith("http")?t:j()+t}function m(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function x(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){O=t,h(),L.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),L.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function M(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===y.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(i=>{const n=i.expiresAt?x(new Date(i.expiresAt).getTime()-Date.now()):"";return`
`);const L=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),I=document.getElementById("share-issued"),D=document.getElementById("share-issued-url"),R=document.getElementById("share-issued-copy"),H=document.getElementById("share-issued-meta"),P=document.getElementById("share-error"),v=document.getElementById("share-success"),C=document.getElementById("share-outstanding-list"),x=document.getElementById("share-cancel"),$=document.getElementById("share-public-create"),E=document.getElementById("share-ts-create"),M=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let B=null,U="public";function T(){P.style.display="none",v.style.display="none",I.style.display="none"}function b(t){v.style.display="none",I.style.display="none",P.textContent=t,P.style.display="block"}function N(t){P.style.display="none",v.textContent=t,v.style.display="block"}function O(){return window.location.origin}function p(t){return t.startsWith("http")?t:O()+t}function u(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function f(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){U=t,T(),L.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),L.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function z(){C.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){C.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===B.id);if(o.length===0){C.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}C.innerHTML=o.map(i=>{const n=i.expiresAt?f(new Date(i.expiresAt).getTime()-Date.now()):"";return`
<div class="share-row" data-share-id="${escapeHtml(i.id)}"
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
<span style="flex: 1;">
<strong>${i.kind==="tailscale"?"Tailscale":"Public"}</strong>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(i.expiresAt))} (${n})</span>
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(u(i.expiresAt))} (${n})</span>
</span>
<button class="share-revoke" data-share-id="${escapeHtml(i.id)}" type="button"
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
Revoke
</button>
</div>
`}).join(""),T.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>r(i.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function r(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));z(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}A("Share revoked."),M()}catch{z("Network error while revoking.")}}async function c(){if(!y)return;h(),B.disabled=!0;const t=B.textContent;B.textContent="Creating\u2026";try{const e=parseInt(C.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:y.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){k(a.status,o);return}const i=o.data.urlPath;$.value=u(i),N.textContent=`Public link \xB7 expires ${m(o.data.expiresAt)}`,I.style.display="block",A("Share link created."),M()}catch{z("Network error while creating share.")}finally{B.disabled=!1,B.textContent=t}}async function d(){if(!y)return;const t=(H.value||"").trim();if(!t||!t.includes("@")){z("A valid recipient email is required.");return}h(),S.disabled=!0;const e=S.textContent;S.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:y.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){k(a.status,o);return}const i=o.data&&o.data.urlPath;i?($.value=u(i),N.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):($.value="",N.textContent=`Tailscale invite sent to ${t}.`),I.style.display="block",A("Tailscale invite created."),H.value="",M()}catch{z("Network error while creating Tailscale invite.")}finally{S.disabled=!1,S.textContent=e}}function k(t,e){const a=e&&e.error||"";if(t===402){z("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){z("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){z("You do not have permission to share services.");return}if(t===404){z("Service not found. It may have been deleted.");return}z(a||`Request failed (HTTP ${t}).`)}function f(t){!t||!t.id||(y={id:t.id,name:t.name||t.id},R.textContent=y.name,h(),g("public"),H.value="",C.value=String(b[1].ms),L.classList.add("show"),M())}L.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),B.addEventListener("click",c),S.addEventListener("click",d),w.addEventListener("click",()=>L.classList.remove("show")),wireModal(L,w),P.addEventListener("click",async()=>{const t=$.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):($.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{z("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=f})(),(function(){"use strict";const b="dashcaddy-language",R=["en","es","fr","de","ar"],I={en:"English",es:"Espa\xF1ol",fr:"Fran\xE7ais",de:"Deutsch",ar:"\u0627\u0644\u0639\u0631\u0628\u064A\u0629"};let $=localStorage.getItem(b)||"en",P={},N=!1;async function D(y){if(y==="en"){P={},N=!0;return}try{const O=await fetch(`/api/v1/i18n/translations/${y}`);O.ok&&(P=(await O.json()).translations||{},N=!0)}catch(O){console.warn("[i18n] Failed to load translations for",y,O)}}function v(y){return $==="en"?y:P[y]||y}function T(y){if(!R.includes(y))return;$=y,localStorage.setItem(b,y);const O=y==="ar";document.documentElement.dir=O?"rtl":"ltr",document.documentElement.lang=y,D(y).then(()=>{B()})}function w(){return $}function B(){document.querySelectorAll("[data-i18n]").forEach(y=>{const O=y.getAttribute("data-i18n"),h=v(O);h&&h!==O&&(y.textContent=h)}),document.querySelectorAll("[data-i18n-placeholder]").forEach(y=>{const O=y.getAttribute("data-i18n-placeholder"),h=v(O);h&&h!==O&&(y.placeholder=h)}),document.querySelectorAll("[data-i18n-title]").forEach(y=>{const O=y.getAttribute("data-i18n-title"),h=v(O);h&&h!==O&&(y.title=h)})}function S(){const y=document.querySelector(".header-actions")||document.querySelector("#auth-settings-btn")?.parentElement||document.querySelector(".top-bar-actions");if(!y){const O=document.getElementById("auth-settings-btn");return O&&O.parentElement?H(O.parentElement):null}return H(y)}function H(y){if(document.getElementById("dc-lang-selector"))return;const O=document.createElement("div");O.id="dc-lang-selector",O.style.cssText="display: inline-flex; align-items: center; gap: 4px; margin: 0 8px; position: relative;";const h=document.createElement("button");h.id="dc-lang-btn",h.className="lang-selector-btn",h.style.cssText="background: var(--card-base, #2a2a3e); border: 1px solid var(--border, Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
`}).join(""),C.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>r(i.dataset.shareId))})}catch{C.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function r(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));b(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),z()}catch{b("Network error while revoking.")}}async function c(){if(!B)return;T(),$.disabled=!0;const t=$.textContent;$.textContent="Creating\u2026";try{const e=parseInt(S.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:B.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){w(a.status,o);return}const i=o.data.urlPath;D.value=p(i),H.textContent=`Public link \xB7 expires ${u(o.data.expiresAt)}`,I.style.display="block",N("Share link created."),z()}catch{b("Network error while creating share.")}finally{$.disabled=!1,$.textContent=t}}async function d(){if(!B)return;const t=(M.value||"").trim();if(!t||!t.includes("@")){b("A valid recipient email is required.");return}T(),E.disabled=!0;const e=E.textContent;E.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:B.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){w(a.status,o);return}const i=o.data&&o.data.urlPath;i?(D.value=p(i),H.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(D.value="",H.textContent=`Tailscale invite sent to ${t}.`),I.style.display="block",N("Tailscale invite created."),M.value="",z()}catch{b("Network error while creating Tailscale invite.")}finally{E.disabled=!1,E.textContent=e}}function w(t,e){const a=e&&e.error||"";if(t===402){b("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){b("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){b("You do not have permission to share services.");return}if(t===404){b("Service not found. It may have been deleted.");return}b(a||`Request failed (HTTP ${t}).`)}function y(t){!t||!t.id||(B={id:t.id,name:t.name||t.id},A.textContent=B.name,T(),g("public"),M.value="",S.value=String(h[1].ms),L.classList.add("show"),z())}L.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),$.addEventListener("click",c),E.addEventListener("click",d),x.addEventListener("click",()=>L.classList.remove("show")),wireModal(L,x),R.addEventListener("click",async()=>{const t=D.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(D.select(),document.execCommand("copy"));const e=R.textContent;R.textContent="Copied!",setTimeout(()=>{R.textContent=e},1200)}catch{b("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=y})(),(function(){"use strict";const h="dashcaddy-language",A=["en","ar","bn","cs","da","de","el","es","fa","fi","fr","hi","hu","id","it","ja","ko","ms","nl","no","pl","pt","ro","ru","sv","th","tr","uk","ur","vi","zh"],I={en:"English",ar:"\u0627\u0644\u0639\u0631\u0628\u064A\u0629",bn:"\u09AC\u09BE\u0982\u09B2\u09BE",cs:"\u010Ce\u0161tina",da:"Dansk",de:"Deutsch",el:"\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC",es:"Espa\xF1ol",fa:"\u0641\u0627\u0631\u0633\u06CC",fi:"Suomi",fr:"Fran\xE7ais",hi:"\u0939\u093F\u0928\u094D\u0926\u0940",hu:"Magyar",id:"Bahasa Indonesia",it:"Italiano",ja:"\u65E5\u672C\u8A9E",ko:"\uD55C\uAD6D\uC5B4",ms:"Bahasa Melayu",nl:"Nederlands",no:"Norsk",pl:"Polski",pt:"Portugu\xEAs",ro:"Rom\xE2n\u0103",ru:"\u0420\u0443\u0441\u0441\u043A\u0438\u0439",sv:"Svenska",th:"\u0E44\u0E17\u0E22",tr:"T\xFCrk\xE7e",uk:"\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430",ur:"\u0627\u0631\u062F\u0648",vi:"Ti\u1EBFng Vi\u1EC7t",zh:"\u4E2D\u6587"},D=new Set(["ar","fa","ur"]);function R(){try{const b=localStorage.getItem(h);if(b&&A.includes(b))return b}catch{}return"en"}let H=R(),P={},v=!1,C=0;async function x(b,N){if(N===void 0&&(N=++C),b==="en"){P={},v=!0;return}try{const O=await fetch(`/api/v1/i18n/translations/${b}`);if(N!==C)return;if(O.ok){const p=await O.json();if(N!==C)return;P=p.translations||{},v=!0}else P={},v=!1}catch(O){console.warn("[i18n] Failed to load translations for",b,O),N===C&&(P={},v=!1)}}function $(b){return H==="en"?b:P[b]||b}function E(b){if(!A.includes(b))return;H=b;try{localStorage.setItem(h,b)}catch{}const N=D.has(b);document.documentElement.dir=N?"rtl":"ltr",document.documentElement.lang=b;const O=++C;x(b,O).then(()=>{O===C&&S()})}function M(){return H}function S(){document.querySelectorAll("[data-i18n]").forEach(b=>{const N=b.getAttribute("data-i18n");b.textContent=$Line truncated
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc Line truncated
+77 -35
View File
@@ -11,16 +11,45 @@
const STORAGE_KEY = 'dashcaddy-language';
const DEFAULT_LANG = 'en';
const SUPPORTED_LANGS = ['en', 'es', 'fr', 'de', 'ar'];
// Must match the 31 languages in language-selector.js and the backend i18n route.
const SUPPORTED_LANGS = [
'en', 'ar', 'bn', 'cs', 'da', 'de', 'el', 'es', 'fa', 'fi',
'fr', 'hi', 'hu', 'id', 'it', 'ja', 'ko', 'ms', 'nl', 'no',
'pl', 'pt', 'ro', 'ru', 'sv', 'th', 'tr', 'uk', 'ur', 'vi', 'zh',
];
const LANG_NAMES = {
en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', ar: 'العربية',
en: 'English', ar: 'العربية', bn: 'বাংলা', cs: 'Čeština', da: 'Dansk',
de: 'Deutsch', el: 'Ελληνικά', es: 'Español', fa: 'فارسی', fi: 'Suomi',
fr: 'Français', hi: 'हिन्दी', hu: 'Magyar', id: 'Bahasa Indonesia', it: 'Italiano',
ja: '日本語', ko: '한국어', ms: 'Bahasa Melayu', nl: 'Nederlands', no: 'Norsk',
pl: 'Polski', pt: 'Português', ro: 'Română', ru: 'Русский', sv: 'Svenska',
th: 'ไทย', tr: 'Türkçe', uk: 'Українська', ur: 'اردو', vi: 'Tiếng Việt', zh: '中文',
};
// RTL languages need dir="rtl" on the document element. (No Hebrew per project policy.)
const RTL_LANGS = new Set(['ar', 'fa', 'ur']);
let currentLang = localStorage.getItem(STORAGE_KEY) || DEFAULT_LANG;
// Validate the stored language — if it's invalid (old/corrupt), fall back to default.
// Wrap in try/catch for environments where localStorage is disabled (private mode).
function _readValidLang() {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored && SUPPORTED_LANGS.includes(stored)) return stored;
} catch (e) { /* localStorage unavailable */ }
return DEFAULT_LANG;
}
let currentLang = _readValidLang();
let translations = {};
let loaded = false;
// Monotonic token to guard against out-of-order async resolution.
// Each setLanguage / loadTranslations call captures the current value; if it
// changed by the time the fetch resolves, the result is discarded.
let _langRequestId = 0;
async function loadTranslations(lang) {
async function loadTranslations(lang, reqId) {
// reqId is the monotonic token incremented by the caller (setLanguage/init).
// If not provided (direct API call), allocate one for backward compatibility.
if (reqId === undefined) reqId = ++_langRequestId;
if (lang === DEFAULT_LANG) {
translations = {}; // English is the default — no translation needed
loaded = true;
@@ -28,33 +57,48 @@
}
try {
const res = await fetch(`/api/v1/i18n/translations/${lang}`);
// Guard against out-of-order resolution: if another loadTranslations
// started after this one (or the user switched languages), discard.
if (reqId !== _langRequestId) return;
if (res.ok) {
const data = await res.json();
if (reqId !== _langRequestId) return; // double-check after second await
translations = data.translations || {};
loaded = true;
} else {
// HTTP error — clear stale translations so we don't show the wrong language
translations = {};
loaded = false;
}
} catch (e) {
console.warn('[i18n] Failed to load translations for', lang, e);
if (reqId === _langRequestId) {
translations = {};
loaded = false;
}
}
}
function t(key) {
if (currentLang === DEFAULT_LANG) return key;
// If translations didn't load, fall back to the English key
return translations[key] || key;
}
function setLanguage(lang) {
if (!SUPPORTED_LANGS.includes(lang)) return;
currentLang = lang;
localStorage.setItem(STORAGE_KEY, lang);
try { localStorage.setItem(STORAGE_KEY, lang); } catch (e) { /* localStorage unavailable */ }
// RTL handling
const isRtl = lang === 'ar';
// RTL handling — always set dir/lang explicitly so switching back to LTR works.
const isRtl = RTL_LANGS.has(lang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = lang;
loadTranslations(lang).then(() => {
applyTranslations();
const reqId = ++_langRequestId; // increment BEFORE calling loadTranslations
loadTranslations(lang, reqId).then(() => {
// Only apply if this is still the latest request.
if (reqId === _langRequestId) applyTranslations();
});
}
@@ -63,29 +107,23 @@
}
function applyTranslations() {
// Apply translations to elements with data-i18n attributes
// Apply translations to elements with data-i18n attributes.
// Always write the resolved value — when switching back to English or when a
// key has no translation, this restores the original English text rather than
// leaving the previous language's translated text visible.
document.querySelectorAll('[data-i18n]').forEach(el => {
const key = el.getAttribute('data-i18n');
const translated = t(key);
if (translated && translated !== key) {
el.textContent = translated;
}
el.textContent = t(key);
});
// Apply to placeholders
document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
const key = el.getAttribute('data-i18n-placeholder');
const translated = t(key);
if (translated && translated !== key) {
el.placeholder = translated;
}
el.placeholder = t(key);
});
// Apply to titles
document.querySelectorAll('[data-i18n-title]').forEach(el => {
const key = el.getAttribute('data-i18n-title');
const translated = t(key);
if (translated && translated !== key) {
el.title = translated;
}
el.title = t(key);
});
}
@@ -121,11 +159,11 @@
btn.className = 'lang-selector-btn';
btn.style.cssText = 'background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); color: var(--text-primary, #e0e0e0); padding: 6px 10px; border-radius: 6px; cursor: pointer; font-size: 0.8rem; display: flex; align-items: center; gap: 4px;';
btn.innerHTML = `🌐 <span class="lang-current">${currentLang.toUpperCase()}</span>`;
btn.title = 'Language / Idioma / Langue / Sprache / اللغة';
btn.title = 'Select Language';
const dropdown = document.createElement('div');
dropdown.id = 'dc-lang-dropdown';
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 140px; overflow: hidden;';
dropdown.style.cssText = 'display: none; position: absolute; top: 100%; right: 0; margin-top: 4px; background: var(--card-base, #2a2a3e); border: 1px solid var(--border, #3a3a4e); border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.3); z-index: 9999; min-width: 160px; max-height: 320px; overflow-y: auto;';
SUPPORTED_LANGS.forEach(lang => {
const item = document.createElement('div');
@@ -181,21 +219,25 @@
// Initialize on page load
function init() {
// Set initial RTL if needed
if (currentLang === 'ar') {
document.documentElement.dir = 'rtl';
document.documentElement.lang = 'ar';
// Always set dir/lang explicitly — covers LTR reset and RTL setup.
const isRtl = RTL_LANGS.has(currentLang);
document.documentElement.dir = isRtl ? 'rtl' : 'ltr';
document.documentElement.lang = currentLang;
function start() {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) {
const reqId = ++_langRequestId;
loadTranslations(currentLang, reqId).then(() => {
if (reqId === _langRequestId) applyTranslations();
});
}
}
// Create the language selector after DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
});
document.addEventListener('DOMContentLoaded', start);
} else {
createLanguageSelector();
if (currentLang !== DEFAULT_LANG) loadTranslations(currentLang).then(applyTranslations);
start();
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'dashcaddy-shell-f0ebe6ce4f';
const CACHE = 'dashcaddy-shell-491e157c0e';
const PRECACHE = [
'/',
'/index.html',