|
|
|
@@ -90,44 +90,44 @@
|
|
|
|
|
<button id="logo-save" class="btn-accent">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),L=document.getElementById("logo-preview-light"),T=document.getElementById("logo-status"),B=document.getElementById("logo-same-both"),N=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),$=document.getElementById("logo-upload-light"),P=document.getElementById("logo-upload-single"),C=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),O=document.getElementById("dashboard-title"),z=DC.NAME;let S=null,E=null,k=null,b="left",m=z;B?.addEventListener("change",()=>{B.checked?(N.style.display="none",D.style.display="",S=null,E=null):(N.style.display="flex",D.style.display="none",k=null)});function f(o,s){if(!o||!o.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const u=new FileReader;u.onload=l=>s(l.target.result),u.readAsDataURL(o)}A?.addEventListener("change",o=>{f(o.target.files[0],s=>{S=s,h.src=s,T.textContent="New dark logo ready to save"})}),$?.addEventListener("change",o=>{f(o.target.files[0],s=>{E=s,L.src=s,T.textContent="New light logo ready to save"})}),P?.addEventListener("change",o=>{f(o.target.files[0],s=>{k=s,h.src=s,L.src=s,T.textContent="New logo ready to save (both themes)"})});function c(o){x.setAttribute("data-logo-pos",o),document.querySelectorAll(".logo-pos-btn").forEach(s=>{s.style.background=s.dataset.pos===o?"var(--accent)":"var(--card-bg)",s.style.color=s.dataset.pos===o?"white":"var(--fg)"})}function d(o){m=o||z,document.title=m;const s=document.querySelector(".dashboard-title");s&&(s.textContent=m)}async function a(){try{const o=await fetch("/api/v1/logo");if(o.ok){const s=await o.json();s.customLogoDark&&(C.src=s.customLogoDark,h.src=s.customLogoDark),s.customLogoLight&&(H.src=s.customLogoLight,L.src=s.customLogoLight),!s.customLogoDark&&!s.customLogoLight&&s.customLogo&&(C.src=s.customLogo,H.src=s.customLogo,h.src=s.customLogo,L.src=s.customLogo),s.isDefault||(T.textContent="Using custom logo"),s.position&&(b=s.position,c(s.position)),s.dashboardTitle&&d(s.dashboardTitle)}}catch(o){console.warn("Could not load custom logo:",o.message)}}document.querySelectorAll(".logo-pos-btn").forEach(o=>{o.addEventListener("click",()=>{b=o.dataset.pos,c(b)})}),document.getElementById("brand")?.addEventListener("click",()=>{S=null,E=null,k=null,A&&(A.value=""),$&&($.value=""),P&&(P.value=""),B&&(B.checked=!1),N.style.display="flex",D.style.display="none",h.src=C.src,L.src=H.src;const o=C.src.includes("custom-logo")||H.src.includes("custom-logo");T.textContent=o?"Using custom logo":"Using default logos",c(b),O.value=m,y.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const o=O.value.trim()||z,s={position:b,dashboardTitle:o};B?.checked&&k?(s.dataDark=k,s.dataLight=k):(S&&(s.dataDark=S),E&&(s.dataLight=E));const u=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)});if(u.ok){const l=await u.json(),g="?t="+Date.now();l.pathDark&&(C.src=l.pathDark+g,h.src=l.pathDark+g),l.pathLight&&(H.src=l.pathLight+g,L.src=l.pathLight+g),c(b),d(o),y.classList.remove("show")}else{const l=await u.json();showNotification("Failed to save: "+l.error,"error")}}catch(o){showNotification("Error saving: "+o.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
|
</div>`);const f=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),M=document.getElementById("logo-preview-light"),k=document.getElementById("logo-status"),S=document.getElementById("logo-same-both"),D=document.getElementById("logo-dual-uploads"),B=document.getElementById("logo-single-upload"),C=document.getElementById("logo-upload-dark"),m=document.getElementById("logo-upload-light"),h=document.getElementById("logo-upload-single"),u=document.querySelector("#brand .brand-logo-dark"),y=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),O=document.getElementById("dashboard-title"),A=DC.NAME;let N=null,z=null,H=null,L="left",g=A;S?.addEventListener("change",()=>{S.checked?(D.style.display="none",B.style.display="",N=null,z=null):(D.style.display="flex",B.style.display="none",H=null)});function I(o,i){if(!o||!o.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const v=new FileReader;v.onload=d=>i(d.target.result),v.readAsDataURL(o)}C?.addEventListener("change",o=>{I(o.target.files[0],i=>{N=i,E.src=i,k.textContent="New dark logo ready to save"})}),m?.addEventListener("change",o=>{I(o.target.files[0],i=>{z=i,M.src=i,k.textContent="New light logo ready to save"})}),h?.addEventListener("change",o=>{I(o.target.files[0],i=>{H=i,E.src=i,M.src=i,k.textContent="New logo ready to save (both themes)"})});function c(o){x.setAttribute("data-logo-pos",o),document.querySelectorAll(".logo-pos-btn").forEach(i=>{i.style.background=i.dataset.pos===o?"var(--accent)":"var(--card-bg)",i.style.color=i.dataset.pos===o?"white":"var(--fg)"})}function l(o){g=o||A,document.title=g;const i=document.querySelector(".dashboard-title");i&&(i.textContent=g)}async function a(){try{const o=await fetch("/api/v1/logo");if(o.ok){const i=await o.json();i.customLogoDark&&(u.src=i.customLogoDark,E.src=i.customLogoDark),i.customLogoLight&&(y.src=i.customLogoLight,M.src=i.customLogoLight),!i.customLogoDark&&!i.customLogoLight&&i.customLogo&&(u.src=i.customLogo,y.src=i.customLogo,E.src=i.customLogo,M.src=i.customLogo),i.isDefault||(k.textContent="Using custom logo"),i.position&&(L=i.position,c(i.position)),i.dashboardTitle&&l(i.dashboardTitle)}}catch(o){console.warn("Could not load custom logo:",o.message)}}document.querySelectorAll(".logo-pos-btn").forEach(o=>{o.addEventListener("click",()=>{L=o.dataset.pos,c(L)})}),document.getElementById("brand")?.addEventListener("click",()=>{N=null,z=null,H=null,C&&(C.value=""),m&&(m.value=""),h&&(h.value=""),S&&(S.checked=!1),D.style.display="flex",B.style.display="none",E.src=u.src,M.src=y.src;const o=u.src.includes("custom-logo")||y.src.includes("custom-logo");k.textContent=o?"Using custom logo":"Using default logos",c(L),O.value=g,f.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const o=O.value.trim()||A,i={position:L,dashboardTitle:o};S?.checked&&H?(i.dataDark=H,i.dataLight=H):(N&&(i.dataDark=N),z&&(i.dataLight=z));const v=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(v.ok){const d=await v.json(),w="?t="+Date.now();d.pathDark&&(u.src=d.pathDark+w,E.src=d.pathDark+w),d.pathLight&&(y.src=d.pathLight+w,M.src=d.pathLight+w),c(L),l(o),f.classList.remove("show")}else{const d=await v.json();showNotification("Failed to save: "+d.error,"error")}}catch(o){showNotification("Error saving: "+o.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&&(C.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",L.src="/assets/dashcaddy-logo-light.png",T.textContent="Using default logos",S=null,E=null,k=null,O.value=z,d(z),b="left",c("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const u=document.querySelector('link[rel="icon"]'),l=document.getElementById("favicon-preview"),g=document.getElementById("favicon-status");u&&(u.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),l&&(l.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),g&&(g.textContent="Using DashCaddy favicon"),r=null}}catch(o){showNotification("Error resetting branding: "+o.message,"error")}}),wireModal(y,document.getElementById("logo-cancel"));const n=document.getElementById("favicon-preview"),e=document.getElementById("favicon-status"),t=document.getElementById("favicon-upload"),i=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(i.rel="icon",i.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(i));async function p(){try{const o=await fetch("/api/v1/favicon");if(o.ok){const s=await o.json();s.customFavicon&&(i.href=s.customFavicon+"?t="+Date.now(),n.src=s.customFavicon+"?t="+Date.now(),e.textContent="Using custom favicon")}}catch(o){console.warn("Could not load custom favicon:",o.message)}}t?.addEventListener("change",o=>{const s=o.target.files[0];if(!s)return;if(!s.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),t.value="";return}const u=new FileReader;u.onload=l=>{r=l.target.result,n.src=r,e.textContent="New favicon ready to save"},u.readAsDataURL(s)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const o=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(o.ok){const s=await o.json();i.href=s.path+"?t="+Date.now(),n.src=s.path+"?t="+Date.now(),e.textContent="Using custom favicon",r=null}else{const s=await o.json();showNotification("Failed to save favicon: "+s.error,"error")}}catch(o){showNotification("Error saving favicon: "+o.message,"error")}}),p(),a();const v=document.getElementById("settings-timezone");v&&(new MutationObserver(()=>{y.classList.contains("show")&&v.options.length===0&&(async()=>{let s;try{const u=await fetch("/api/v1/config");u.ok&&(s=(await u.json()).timezone)}catch{}window.populateTimezoneSelect(v,s)})()}).observe(y,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const s=v.value;if(s)try{const u=await fetch("/api/v1/config");if(!u.ok)return;const l=await u.json();l.timezone=s,l.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)})}catch(u){console.warn("Failed to save timezone:",u.message)}}))})(),window.populateTimezoneSelect=function(y,h){const L=Intl.supportedValuesOf("timeZone"),T=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y.innerHTML="";for(const B of L){const N=document.createElement("option");N.value=B,N.textContent=B.replace(/_/g," "),B===T&&(N.selected=!0),y.appendChild(N)}},(function(){let y="homelab",h=null;async function L(){try{const f=await fetch("/api/v1/config");if(f.ok&&(h=await f.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(f){console.warn("Could not fetch server config, checking localStorage fallback:",f.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}L();const T=document.getElementById("setup-timezone");T&&window.populateTimezoneSelect(T);function B(m){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const f=document.getElementById(m);f&&(f.style.display="block")}function N(){const m=document.getElementById("setup-summary-content");if(!m)return;let f='<div style="display: grid; gap: 20px;">';if(y==="homelab"){const d=document.getElementById("setup-tld")?.value?.trim()||".home",a=document.getElementById("setup-ca-name")?.value?.trim()||"",n=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;f+=`
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(u.src="/assets/dashcaddy-logo-dark.png",y.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",k.textContent="Using default logos",N=null,z=null,H=null,O.value=A,l(A),L="left",c("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const v=document.querySelector('link[rel="icon"]'),d=document.getElementById("favicon-preview"),w=document.getElementById("favicon-status");v&&(v.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),d&&(d.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),w&&(w.textContent="Using DashCaddy favicon"),r=null}}catch(o){showNotification("Error resetting branding: "+o.message,"error")}}),wireModal(f,document.getElementById("logo-cancel"));const n=document.getElementById("favicon-preview"),e=document.getElementById("favicon-status"),t=document.getElementById("favicon-upload"),s=document.querySelector('link[rel="icon"]')||document.createElement("link");let r=null;document.querySelector('link[rel="icon"]')||(s.rel="icon",s.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(s));async function p(){try{const o=await fetch("/api/v1/favicon");if(o.ok){const i=await o.json();i.customFavicon&&(s.href=i.customFavicon+"?t="+Date.now(),n.src=i.customFavicon+"?t="+Date.now(),e.textContent="Using custom favicon")}}catch(o){console.warn("Could not load custom favicon:",o.message)}}t?.addEventListener("change",o=>{const i=o.target.files[0];if(!i)return;if(!i.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),t.value="";return}const v=new FileReader;v.onload=d=>{r=d.target.result,n.src=r,e.textContent="New favicon ready to save"},v.readAsDataURL(i)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(r)try{const o=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:r})});if(o.ok){const i=await o.json();s.href=i.path+"?t="+Date.now(),n.src=i.path+"?t="+Date.now(),e.textContent="Using custom favicon",r=null}else{const i=await o.json();showNotification("Failed to save favicon: "+i.error,"error")}}catch(o){showNotification("Error saving favicon: "+o.message,"error")}}),p(),a();const b=document.getElementById("settings-timezone");b&&(new MutationObserver(()=>{f.classList.contains("show")&&b.options.length===0&&(async()=>{let i;try{const v=await fetch("/api/v1/config");v.ok&&(i=(await v.json()).timezone)}catch{}window.populateTimezoneSelect(b,i)})()}).observe(f,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const i=b.value;if(i)try{const v=await fetch("/api/v1/config");if(!v.ok)return;const d=await v.json();d.timezone=i,d.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)})}catch(v){console.warn("Failed to save timezone:",v.message)}}))})(),window.populateTimezoneSelect=function(f,E){const M=Intl.supportedValuesOf("timeZone"),k=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f.innerHTML="";for(const S of M){const D=document.createElement("option");D.value=S,D.textContent=S.replace(/_/g," "),S===k&&(D.selected=!0),f.appendChild(D)}},(function(){let f="homelab",E=null;async function M(){try{const I=await fetch("/api/v1/config");if(I.ok&&(E=await I.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(I){console.warn("Could not fetch server config, checking localStorage fallback:",I.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}M();const k=document.getElementById("setup-timezone");k&&window.populateTimezoneSelect(k);function S(g){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const I=document.getElementById(g);I&&(I.style.display="block")}function D(){const g=document.getElementById("setup-summary-content");if(!g)return;let I='<div style="display: grid; gap: 20px;">';if(f==="homelab"){const l=document.getElementById("setup-tld")?.value?.trim()||".home",a=document.getElementById("setup-ca-name")?.value?.trim()||"",n=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;I+=`
|
|
|
|
|
<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> ${d}</div>
|
|
|
|
|
<div><strong>TLD:</strong> ${l}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${a}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${n}:${e}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${d}, https://nextcloud${d}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${l}, https://nextcloud${l}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(y==="simple"){const d=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";f+=`
|
|
|
|
|
`}else if(f==="simple"){const l=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";I+=`
|
|
|
|
|
<div>
|
|
|
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
|
|
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
|
|
|
<div><strong>Access Method:</strong> IP:Port only</div>
|
|
|
|
|
<div><strong>Default IP:</strong> ${d}</div>
|
|
|
|
|
<div><strong>Default IP:</strong> ${l}</div>
|
|
|
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${d}:8080, http://${d}:3000</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${l}:8080, http://${l}:3000</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(y==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",a=document.getElementById("setup-public-email")?.value?.trim()||"",n=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=n==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;f+=`
|
|
|
|
|
`}else if(f==="public"){const l=document.getElementById("setup-public-domain")?.value?.trim()||"",a=document.getElementById("setup-public-email")?.value?.trim()||"",n=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=n==="subdirectory"?`https://${l}/sonarr, https://${l}/grafana`:`https://sonarr.${l}, https://grafana.${l}`;I+=`
|
|
|
|
|
<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> ${d}</div>
|
|
|
|
|
<div><strong>Domain:</strong> ${l}</div>
|
|
|
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
|
|
|
<div><strong>Email:</strong> ${a}</div>
|
|
|
|
|
<div><strong>Routing:</strong> ${n==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${e}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";f+=`
|
|
|
|
|
`}const c=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";I+=`
|
|
|
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
|
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${c.replace(/_/g," ")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`,f+="</div>",m.innerHTML=f,B("setup-step-summary")}async function D(m){try{const f=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)});return f.ok?(await f.json(),!0):(console.error("Failed to save config to server:",f.status),!1)}catch(f){return console.error("Error saving config to server:",f),!1}}async function A(){const m={setupComplete:!0,configurationType:y,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};y==="homelab"?(m.tld=document.getElementById("setup-tld")?.value?.trim()||".home",m.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",m.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},m.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):y==="simple"?(m.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",m.defaults={dnsType:"none",sslType:"none",targetIP:m.defaultIP}):y==="public"&&(m.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",m.email=document.getElementById("setup-public-email")?.value?.trim()||"",m.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m.defaults={dnsType:m.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const f=await D(m);safeSet("dashcaddy-config",JSON.stringify(m)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const c=y==="homelab"?"Professional Home Lab":y==="simple"?"Simple Setup":"Public Server",d=f?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const $=document.getElementById("setup-step-1-next");$&&($.onclick=function(m){m.preventDefault();const f=document.querySelector('input[name="config-type"]:checked');f&&(y=f.value),B(y==="homelab"?"setup-step-homelab":y==="simple"?"setup-step-simple":y==="public"?"setup-step-public":"setup-step-homelab")});const P=document.getElementById("setup-skip");P&&(P.onclick=async function(m){m.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const C=document.getElementById("setup-tld");C&&(C.oninput=function(m){const f=m.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=f),d&&(d.textContent=f)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(m){m.preventDefault(),B("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!f||!f.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}N()});const O=document.getElementById("setup-simple-back");O&&(O.onclick=function(m){m.preventDefault(),B("setup-step-1")});const z=document.getElementById("setup-simple-next");z&&(z.onclick=function(m){m.preventDefault(),N()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(m){m.onchange=function(){var f=document.getElementById("dns-requirement-note");f&&(f.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const S=document.getElementById("setup-public-back");S&&(S.onclick=function(m){m.preventDefault(),B("setup-step-1")});const E=document.getElementById("setup-public-next");E&&(E.onclick=function(m){m.preventDefault();const f=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!f){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}N()});const k=document.getElementById("setup-summary-back");k&&(k.onclick=function(m){m.preventDefault(),y==="homelab"?B("setup-step-homelab"):y==="simple"?B("setup-step-simple"):y==="public"&&B("setup-step-public")});const b=document.getElementById("setup-finish");b&&(b.onclick=function(m){m.preventDefault()
Line truncated
|
|
|
|
|
`,I+="</div>",g.innerHTML=I,S("setup-step-summary")}async function B(g){try{const I=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(g)});return I.ok?(await I.json(),!0):(console.error("Failed to save config to server:",I.status),!1)}catch(I){return console.error("Error saving config to server:",I),!1}}async function C(){const g={setupComplete:!0,configurationType:f,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};f==="homelab"?(g.tld=document.getElementById("setup-tld")?.value?.trim()||".home",g.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",g.dns={provider:"technitium",ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},g.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):f==="simple"?(g.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",g.defaults={dnsType:"none",sslType:"none",targetIP:g.defaultIP}):f==="public"&&(g.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",g.email=document.getElementById("setup-public-email")?.value?.trim()||"",g.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",g.defaults={dnsType:g.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const I=await B(g);safeSet("dashcaddy-config",JSON.stringify(g)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const c=f==="homelab"?"Professional Home Lab":f==="simple"?"Simple Setup":"Public Server",l=I?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${l}`,"success",5e3),setTimeout(()=>location.reload(),500)}const m=document.getElementById("setup-step-1-next");m&&(m.onclick=function(g){g.preventDefault();const I=document.querySelector('input[name="config-type"]:checked');I&&(f=I.value),S(f==="homelab"?"setup-step-homelab":f==="simple"?"setup-step-simple":f==="public"?"setup-step-public":"setup-step-homelab")});const h=document.getElementById("setup-skip");h&&(h.onclick=async function(g){g.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await B({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const u=document.getElementById("setup-tld");u&&(u.oninput=function(g){const I=g.target.value||".home",c=document.getElementById("tld-preview"),l=document.getElementById("tld-preview-2");c&&(c.textContent=I),l&&(l.textContent=I)});const y=document.getElementById("setup-homelab-back");y&&(y.onclick=function(g){g.preventDefault(),S("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(g){g.preventDefault();const I=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",l=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!I||!I.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!l){showNotification("Please enter your DNS server IP address","warning");return}D()});const O=document.getElementById("setup-simple-back");O&&(O.onclick=function(g){g.preventDefault(),S("setup-step-1")});const A=document.getElementById("setup-simple-next");A&&(A.onclick=function(g){g.preventDefault(),D()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(g){g.onchange=function(){var I=document.getElementById("dns-requirement-note");I&&(I.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const N=document.getElementById("setup-public-back");N&&(N.onclick=function(g){g.preventDefault(),S("setup-step-1")});const z=document.getElementById("setup-public-next");z&&(z.onclick=function(g){g.preventDefault();const I=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!I){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}D()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(g){g.preventDefault(),f==="homelab"?S("setup-step-homelab"):f==="simple"?S("setup-step-simple"):f==="public"&&S("setup-step-public")});const L=document.getElementById("setup-finish");L&&(L.onclick=function(g){g.preventDefault()
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>
|
|
|
|
@@ -306,6 +306,22 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
</div>
|
|
|
|
|
<div id="volume-mounts-list" style="display: grid; gap: 8px;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="margin-top: 12px;">
|
|
|
|
|
<label class="form-label-accent-sm">\u2699\uFE0F Resource Limits</label>
|
|
|
|
|
<div style="font-size: 0.8rem; color: var(--muted); margin-bottom: 8px;">
|
|
|
|
|
Optional CPU and memory constraints. Leave at 0 for unlimited.
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label for="deploy-cpu-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">CPU Cores</label>
|
|
|
|
|
<input type="number" id="deploy-cpu-limit" value="0" min="0" max="64" step="0.25" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label for="deploy-memory-limit" style="display: block; font-size: 0.78rem; color: var(--muted); margin-bottom: 4px;">Memory (MB)</label>
|
|
|
|
|
<input type="number" id="deploy-memory-limit" value="0" min="0" max="131072" step="64" class="form-input-card" style="width: 100%;" placeholder="0 = unlimited" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
@@ -317,52 +333,52 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y="custom-apps";let h=null,L=null;const T=document.getElementById("app-selector-modal"),B=document.getElementById("app-selector-grid");async function N(){try{const t=await(await fetch("/api/v1/apps/templates")).json();if(t.success)return h=t.templates,L=t.categories,!0}catch(e){console.error("Failed to fetch app templates:",e)}return!1}async function D(e){try{return await(await fetch(`/api/v1/apps/ports/${e}/check`)).json()}catch(t){return console.error("Failed to check port:",t),{available:!0}}}async function A(e){try{const i=await(await fetch(`/api/v1/apps/ports/${e}/suggest`)).json();if(i.success)return i.suggestedPort}catch(t){console.error("Failed to get suggested port:",t)}return e}async function $(){if(B.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!h&&!await N()){B.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}B.innerHTML="";const e={};for(const[i,r]of Object.entries(h)){const p=r.category||"Other";e[p]||(e[p]=[]),e[p].push({id:i,...r})}const t=L?Object.keys(L):Object.keys(e).sort();for(const i of t){const r=e[i];if(!r||r.length===0)continue;r.sort((o,s)=>(s.popularity||0)-(o.popularity||0));const p=document.createElement("div");p.className="app-category-header";const v=L?.[i]||{};p.innerHTML=`${escapeHtml(v.icon||"")} ${escapeHtml(i)}`,v.color&&(p.style.borderBottomColor=v.color),B.appendChild(p),r.forEach(o=>{const s=document.createElement("div");s.className="app-option";const u=o.isDashboardWidget,l=u&&safeGet("widget-"+o.id+"-enabled")!=="false",g=u?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${l?"#2ecc7130":"#e74c3c30"}; color: ${l?"#2ecc71":"#e74c3c"}; font-weight: 600;">${l?"ON":"OFF"}</div>`:"",I=!u&&o.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(o.difficulty)}</div>`:"";s.innerHTML=`
|
|
|
|
|
</div>`);const f="custom-apps";let E=null,M=null;const k=document.getElementById("app-selector-modal"),S=document.getElementById("app-selector-grid");async function D(){try{const t=await(await fetch("/api/v1/apps/templates")).json();if(t.success)return E=t.templates,M=t.categories,!0}catch(e){console.error("Failed to fetch app templates:",e)}return!1}async function B(e){try{return await(await fetch(`/api/v1/apps/ports/${e}/check`)).json()}catch(t){return console.error("Failed to check port:",t),{available:!0}}}async function C(e){try{const s=await(await fetch(`/api/v1/apps/ports/${e}/suggest`)).json();if(s.success)return s.suggestedPort}catch(t){console.error("Failed to get suggested port:",t)}return e}async function m(){if(S.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!E&&!await D()){S.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}S.innerHTML="";const e={};for(const[s,r]of Object.entries(E)){const p=r.category||"Other";e[p]||(e[p]=[]),e[p].push({id:s,...r})}const t=M?Object.keys(M):Object.keys(e).sort();for(const s of t){const r=e[s];if(!r||r.length===0)continue;r.sort((o,i)=>(i.popularity||0)-(o.popularity||0));const p=document.createElement("div");p.className="app-category-header";const b=M?.[s]||{};p.innerHTML=`${escapeHtml(b.icon||"")} ${escapeHtml(s)}`,b.color&&(p.style.borderBottomColor=b.color),S.appendChild(p),r.forEach(o=>{const i=document.createElement("div");i.className="app-option";const v=o.isDashboardWidget,d=v&&safeGet("widget-"+o.id+"-enabled")!=="false",w=v?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${d?"#2ecc7130":"#e74c3c30"}; color: ${d?"#2ecc71":"#e74c3c"}; font-weight: 600;">${d?"ON":"OFF"}</div>`:"",T=!v&&o.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${o.difficulty==="Easy"?"#2ecc71":o.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(o.difficulty)}</div>`:"";i.innerHTML=`
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(o.icon||"\u{1F4E6}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(o.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(o.description||"")}</div>
|
|
|
|
|
${g}${I}
|
|
|
|
|
`,u?s.onclick=()=>P(o,s):s.onclick=()=>C(o),B.appendChild(s)})}window.renderRecipeCards&&await window.renderRecipeCards(B)}function P(e,t){const i="widget-"+e.id+"-enabled",p=!(safeGet(i)!=="false");safeSet(i,String(p));const v=e.widgetSelector;if(v){const s=document.querySelector(v);s&&(s.style.display=p?"":"none")}const o=t.querySelector('div[style*="border-radius: 4px"]');o&&(o.textContent=p?"ON":"OFF",o.style.background=p?"#2ecc7130":"#e74c3c30",o.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${e.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function C(e){const t=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),v=document.getElementById("deploy-ip"),o=document.getElementById("deploy-port"),s=document.getElementById("deploy-tailscale-only"),u=document.getElementById("tailscale-status");try{const U=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e.id})})).json();if(U.success&&U.exists){const J=U.container;confirm(`Found existing ${e.name} container:
|
|
|
|
|
${w}${T}
|
|
|
|
|
`,v?i.onclick=()=>h(o,i):i.onclick=()=>u(o),S.appendChild(i)})}window.renderRecipeCards&&await window.renderRecipeCards(S)}function h(e,t){const s="widget-"+e.id+"-enabled",p=!(safeGet(s)!=="false");safeSet(s,String(p));const b=e.widgetSelector;if(b){const i=document.querySelector(b);i&&(i.style.display=p?"":"none")}const o=t.querySelector('div[style*="border-radius: 4px"]');o&&(o.textContent=p?"ON":"OFF",o.style.background=p?"#2ecc7130":"#e74c3c30",o.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${e.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function u(e){const t=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),r=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),b=document.getElementById("deploy-ip"),o=document.getElementById("deploy-port"),i=document.getElementById("deploy-tailscale-only"),v=document.getElementById("tailscale-status");try{const q=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:e.id})})).json();if(q.success&&q.exists){const W=q.container;confirm(`Found existing ${e.name} container:
|
|
|
|
|
|
|
|
|
|
Container: ${J.name}
|
|
|
|
|
Status: ${J.status}
|
|
|
|
|
Port: ${J.primaryPort||"N/A"}
|
|
|
|
|
Container: ${W.name}
|
|
|
|
|
Status: ${W.status}
|
|
|
|
|
Port: ${W.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.`)&&(e._useExisting=!0,e._existingContainer=J)}}catch{}i.textContent=`Deploy ${e.name}`;const l=e.subdomain||e.id.replace(/-/g,"");r.value=l;const g=document.getElementById("subpath-compat-warning");if(g)if(SITE.routingMode==="subdirectory"){const F=e.subpathSupport||"strip";F==="none"?(g.style.display="block",g.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+e.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):F==="strip"?(g.style.display="block",g.innerHTML='<span style="color: var(--muted);">ⓘ '+e.name+" has unverified subdirectory support. It may require additional configuration.</span>"):g.style.display="none"}else g.style.display="none";const I=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),w=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),M=document.querySelector(`input[name="dns-type"][value="${I}"]`),R=document.querySelector(`input[name="ssl-type"][value="${w}"]`);M?M.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,R?R.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,v.value=SITE.defaults.targetIP||"localhost",s.checked=!1;const q=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),W=_?.querySelector("div");if(_&&W&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const F=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,U=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;F&&!F.dataset.moved&&(W.appendChild(F),F.dataset.moved="1"),U&&!U.dataset.moved&&(W.appendChild(U),U.dataset.moved="1")}const j=document.getElementById("media-path-section"),V=document.getElementById("deploy-media-path"),se=document.getElementById("media-path-description");if(e.mediaMount){j.style.display="block",V.value="",V.placeholder="/media/Movies, /media/TVShows or click Browse";const F=document.getElementById("detected-mounts-container"),U=document.getElementById("detected-mounts-list");try{const G=await(await fetch("/api/v1/media/detected-mounts")).json();if(G.success&&G.mounts.length>0){F.style.display="block",U.innerHTML="";const ee=[...new Set(G.mounts.map(Z=>Z.hostPath))];V.value=ee.join(", "),G.mounts.forEach(Z=>{const Y=document.createElement("button");Y.type="button";const le=ee.includes(Z.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.innerHTML=`<span style="font-weight: 500;">${escapeHtml(Z.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(Z.sourceImage)}</span>`,Y.title=`${Z.hostPath} (from ${Z.sourceContainer})`,Y.onclick=()=>{const re=V.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(Z.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(Z.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),V.value=re.join(", ")},U.appendChild(Y)})}else F.style.display="none"}catch{F.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(V)}}else j.style.display="none",V.value="",document.getElementById("detected-mounts-container").style.display="none";const K=document.getElementById("plex-claim-section");K&&(e.id==="plex"||e.claimToken?(K.style.display="block",document.getElementById("deploy-plex-claim").value=""):K.style.display="none");const Q=document.getElementById("volume-mounts-section"),te=document.getElementById("volume-mounts-list");if(te.innerHTML="",e.docker?.volumes?.length){const F=e.mediaMount?.containerPath,U=e.docker.volumes.filter(J=>!J.includes("{{MEDIA_PATH}}")&&!(F&&J.endsWith(":"+F)));U.length>0?(Q.style.display="block",U.forEach((J,G)=>{const[ee,Z]=J.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=`
|
|
|
|
|
Click Cancel to deploy a new container.`)&&(e._useExisting=!0,e._existingContainer=W)}}catch{}s.textContent=`Deploy ${e.name}`;const d=e.subdomain||e.id.replace(/-/g,"");r.value=d;const w=document.getElementById("subpath-compat-warning");if(w)if(SITE.routingMode==="subdirectory"){const j=e.subpathSupport||"strip";j==="none"?(w.style.display="block",w.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+e.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):j==="strip"?(w.style.display="block",w.innerHTML='<span style="color: var(--muted);">ⓘ '+e.name+" has unverified subdirectory support. It may require additional configuration.</span>"):w.style.display="none"}else w.style.display="none";const T=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),$=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),P=document.querySelector(`input[name="dns-type"][value="${T}"]`),R=document.querySelector(`input[name="ssl-type"][value="${$}"]`);P?P.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,R?R.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,b.value=SITE.defaults.targetIP||"localhost",i.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),J=_?.querySelector("div");if(_&&J&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const j=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,q=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;j&&!j.dataset.moved&&(J.appendChild(j),j.dataset.moved="1"),q&&!q.dataset.moved&&(J.appendChild(q),q.dataset.moved="1")}const F=document.getElementById("media-path-section"),K=document.getElementById("deploy-media-path"),se=document.getElementById("media-path-description");if(e.mediaMount){F.style.display="block",K.value="",K.placeholder="/media/Movies, /media/TVShows or click Browse";const j=document.getElementById("detected-mounts-container"),q=document.getElementById("detected-mounts-list");try{const G=await(await fetch("/api/v1/media/detected-mounts")).json();if(G.success&&G.mounts.length>0){j.style.display="block",q.innerHTML="";const ee=[...new Set(G.mounts.map(Z=>Z.hostPath))];K.value=ee.join(", "),G.mounts.forEach(Z=>{const Y=document.createElement("button");Y.type="button";const le=ee.includes(Z.hostPath);Y.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Y.innerHTML=`<span style="font-weight: 500;">${escapeHtml(Z.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(Z.sourceImage)}</span>`,Y.title=`${Z.hostPath} (from ${Z.sourceContainer})`,Y.onclick=()=>{const re=K.value.split(",").map(de=>de.trim()).filter(de=>de),ce=re.indexOf(Z.hostPath);ce>=0?(re.splice(ce,1),Y.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(re.push(Z.hostPath),Y.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),K.value=re.join(", ")},q.appendChild(Y)})}else j.style.display="none"}catch{j.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(K)}}else F.style.display="none",K.value="",document.getElementById("detected-mounts-container").style.display="none";const V=document.getElementById("plex-claim-section");V&&(e.id==="plex"||e.claimToken?(V.style.display="block",document.getElementById("deploy-plex-claim").value=""):V.style.display="none");const Q=document.getElementById("volume-mounts-section"),te=document.getElementById("volume-mounts-list");if(te.innerHTML="",e.docker?.volumes?.length){const j=e.mediaMount?.containerPath,q=e.docker.volumes.filter(W=>!W.includes("{{MEDIA_PATH}}")&&!(j&&W.endsWith(":"+j)));q.length>0?(Q.style.display="block",q.forEach((W,G)=>{const[ee,Z]=W.split(":"),Y=document.createElement("div");Y.style.cssText="display: flex; gap: 6px; align-items: center;",Y.innerHTML=`
|
|
|
|
|
<input type="text" class="vol-host-path" data-container-path="${Z}" value="${ee}"
|
|
|
|
|
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 ${Z}</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>
|
|
|
|
|
`,te.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=e.defaultPort||8080;o.value="",o.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",o.parentNode.appendChild(X));async function oe(){const F=o.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const U=await D(F);if(U.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(F))} is available</span>`;else{const J=await A(ne);X.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(F)} in use by ${escapeHtml(U.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;const G=document.createElement("button");G.type="button",G.textContent=`Use ${J}`,G.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",G.onclick=()=>{document.getElementById("deploy-port").value=J,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(J))}</span>`},X.appendChild(G)}}let ie;o.oninput=function(){clearTimeout(ie),ie=setTimeout(oe,500)},oe();try{const U=await(await fetch("/api/v1/tailscale/status")).json();U.success&&U.installed&&U.connected?u.innerHTML=`
|
|
|
|
|
`,te.appendChild(Y),Y.querySelector(".vol-browse-btn").onclick=()=>{const le=Y.querySelector(".vol-host-path");openFolderBrowser(le)}})):Q.style.display="none"}else Q.style.display="none";const ne=e.defaultPort||8080;o.value="",o.placeholder=`Default: ${ne}`;let X=document.getElementById("deploy-port-status");X||(X=document.createElement("div"),X.id="deploy-port-status",X.style.cssText="font-size: 0.8rem; margin-top: 4px;",o.parentNode.appendChild(X));async function oe(){const j=o.value||ne;X.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const q=await B(j);if(q.available)X.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(j))} is available</span>`;else{const W=await C(ne);X.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(j)} in use by ${escapeHtml(q.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;const G=document.createElement("button");G.type="button",G.textContent=`Use ${W}`,G.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",G.onclick=()=>{document.getElementById("deploy-port").value=W,X.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(W))}</span>`},X.appendChild(G)}}let ie;o.oninput=function(){clearTimeout(ie),ie=setTimeout(oe,500)},oe();try{const q=await(await fetch("/api/v1/tailscale/status")).json();q.success&&q.installed&&q.connected?v.innerHTML=`
|
|
|
|
|
<span style="color: #4caf50;">Connected</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${U.self?.hostname} (${U.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${U.deviceCount} devices</span>
|
|
|
|
|
`:U.installed?u.innerHTML='<span style="color: #ff9800;">Not connected</span>':(u.innerHTML='<span style="color: var(--muted);">Not available</span>',s.disabled=!0)}catch{u.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const F=r.value||"subdomain",U=document.querySelector('input[name="dns-type"]:checked').value,J=document.querySelector('input[name="ssl-type"]:checked').value;let G="";if(SITE.routingMode==="subdirectory"&&SITE.domain)G=`https://${SITE.domain}/${F}`;else if(U==="private")G=`${J==="none"?"http":"https"}://${buildDomain(F)}`;else if(U==="public"){const ee=J==="none"?"http":"https",Z=SITE.domain||F;G=SITE.domain?`${ee}://${F}.${SITE.domain}`:`${ee}://${F}`}else{const ee=o.value||e.defaultPort||DC.DEFAULTS.SERVICE_PORT;G=`http://${v.value}:${ee}`}p.textContent=G}r.oninput=ae,v.oninput=ae,o.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(F=>{F.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(F=>{F.onchange=ae}),ae(),T.classList.remove("show"),t.classList.add("show"),t.dataset.appTemplate=JSON.stringify(e)}async function H(e){const t=e.appTemplate,i=safeGetJSON(y,[]),r=t._useExisting&&t._existingContainer,p=i.find(v=>v.id===e.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${e.subdomain}" already exists. Redeploy?`))){if(p){const v=i.indexOf(p);i.splice(v,1),safeSet(y,JSON.stringify(i))}if(r)e.port=t._existingContainer.primaryPort;else{const v=e.port||t.defaultPort||8080;showNotification(`Checking port ${v} availability...`,"info",0);const o=await D(v);if(!o.available){const s=await A(t.defaultPort||8080);if(confirm(`Port ${v} is already in use by ${o.conflict?.usedBy||"another container"}.
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${q.self?.hostname} (${q.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${q.deviceCount} devices</span>
|
|
|
|
|
`:q.installed?v.innerHTML='<span style="color: #ff9800;">Not connected</span>':(v.innerHTML='<span style="color: var(--muted);">Not available</span>',i.disabled=!0)}catch{v.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const j=r.value||"subdomain",q=document.querySelector('input[name="dns-type"]:checked').value,W=document.querySelector('input[name="ssl-type"]:checked').value;let G="";if(SITE.routingMode==="subdirectory"&&SITE.domain)G=`https://${SITE.domain}/${j}`;else if(q==="private")G=`${W==="none"?"http":"https"}://${buildDomain(j)}`;else if(q==="public"){const ee=W==="none"?"http":"https",Z=SITE.domain||j;G=SITE.domain?`${ee}://${j}.${SITE.domain}`:`${ee}://${j}`}else{const ee=o.value||e.defaultPort||DC.DEFAULTS.SERVICE_PORT;G=`http://${b.value}:${ee}`}p.textContent=G}r.oninput=ae,b.oninput=ae,o.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(j=>{j.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(j=>{j.onchange=ae}),ae(),k.classList.remove("show"),t.classList.add("show"),t.dataset.appTemplate=JSON.stringify(e)}async function y(e){const t=e.appTemplate,s=safeGetJSON(f,[]),r=t._useExisting&&t._existingContainer,p=s.find(b=>b.id===e.subdomain);if(!(p&&!r&&!confirm(`An app with subdomain "${e.subdomain}" already exists. Redeploy?`))){if(p){const b=s.indexOf(p);s.splice(b,1),safeSet(f,JSON.stringify(s))}if(r)e.port=t._existingContainer.primaryPort;else{const b=e.port||t.defaultPort||8080;showNotification(`Checking port ${b} availability...`,"info",0);const o=await B(b);if(!o.available){const i=await C(t.defaultPort||8080);if(confirm(`Port ${b} is already in use by ${o.conflict?.usedBy||"another container"}.
|
|
|
|
|
|
|
|
|
|
Would you like to use port ${s} instead?`))e.port=s;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${t.name} with existing container...`:`Deploying ${t.name}...`,"info",0);try{const v={appId:t.id,config:{subdomain:e.subdomain,ip:e.ip,createDns:e.dnsType==="private",port:e.port||t.defaultPort||null,sslType:e.sslType,dnsType:e.dnsType,tailscaleOnly:e.tailscaleOnly||!1,mediaPath:e.mediaPath||null,plexClaimToken:e.plexClaimToken||null,customVolumes:e.customVolumes||null}};r&&(v.config.useExisting=!0,v.config.existingContainerId=t._existingContainer.id,v.config.existingPort=t._existingContainer.primaryPort,!e.port&&t._existingContainer.primaryPort&&(v.config.port=t._existingContainer.primaryPort));const s=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(v)})).json();if(s.success){const u={id:e.subdomain,name:t.name,logo:`/assets/${t.id}.png`,containerId:s.containerId,url:s.url,ip:e.ip,appTemplate:t.id,tailscaleOnly:e.tailscaleOnly||!1};i.push(u),safeSet(y,JSON.stringify(i)),window.APPS&&!window.APPS.some(g=>g.id===t.id)&&(window.APPS.push(u),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let l=s.usedExisting?`${t.name} configured with existing container!
|
|
|
|
|
URL: ${s.url}`:`${t.name} deployed successfully!
|
|
|
|
|
URL: ${s.url}`;s.warning&&(l+=`
|
|
|
|
|
Would you like to use port ${i} instead?`))e.port=i;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(r?`Configuring ${t.name} with existing container...`:`Deploying ${t.name}...`,"info",0);try{const b={appId:t.id,config:{subdomain:e.subdomain,ip:e.ip,createDns:e.dnsType==="private",port:e.port||t.defaultPort||null,sslType:e.sslType,dnsType:e.dnsType,tailscaleOnly:e.tailscaleOnly||!1,mediaPath:e.mediaPath||null,plexClaimToken:e.plexClaimToken||null,customVolumes:e.customVolumes||null}};r&&(b.config.useExisting=!0,b.config.existingContainerId=t._existingContainer.id,b.config.existingPort=t._existingContainer.primaryPort,!e.port&&t._existingContainer.primaryPort&&(b.config.port=t._existingContainer.primaryPort));const i=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(b)})).json();if(i.success){const v={id:e.subdomain,name:t.name,logo:`/assets/${t.id}.png`,containerId:i.containerId,url:i.url,ip:e.ip,appTemplate:t.id,tailscaleOnly:e.tailscaleOnly||!1};s.push(v),safeSet(f,JSON.stringify(s)),window.APPS&&!window.APPS.some(w=>w.id===t.id)&&(window.APPS.push(v),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let d=i.usedExisting?`${t.name} configured with existing container!
|
|
|
|
|
URL: ${i.url}`:`${t.name} deployed successfully!
|
|
|
|
|
URL: ${i.url}`;i.warning&&(d+=`
|
|
|
|
|
|
|
|
|
|
\u26A0 Warning: ${s.warning}`),showNotification(l,"success",8e3),delete t._useExisting,delete t._existingContainer,s.url&&s.url.startsWith("https://")&&x(s.url,t.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const g=s.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${t.name}: ${g}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(v){console.error("Deployment error:",v),showNotification(`Failed to deploy ${t.name}: ${v.message}`,"error",8e3)}}}async function x(e,t){showNotification(`\u23F3 Generating SSL certificate for ${t}...`,"warning",6e4);let i=0;const r=12,p=async()=>{i++;try{const v=await fetch(e,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${t} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<r?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${t} deployed but SSL certificate may still be generating.
|
|
|
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(p,3e3)}function O(){safeGetJSON(y,[]).forEach(t=>{window.APPS.some(i=>i.id===t.id)||window.APPS.push(t)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{$(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const z=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const e=JSON.parse(z.dataset.appTemplate),t=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{i.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:e,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:t||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:i.length>0?i:null};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(e.mediaMount?.required&&!t){showNotification("Please enter a media library path for this application","warning");return}z.classList.remove("show"),H(r)}),wireModal(z);const S=document.getElementById("folder-browser-modal"),E=document.getElementById("folder-browser-path"),k=document.getElementById("folder-browser-list"),b=document.getElementById("folder-browser-selected"),m=document.getElementById("folder-browser-selected-list");let f="",c=[],d=null;window.openFolderBrowser=function(e){d=e,c=e.value.split(",").map(t=>t.trim()).filter(t=>t),f="",n(),a(""),S.classList.add("show")};async function a(e){E.textContent=e||"Select a drive...",k.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(e)}`)).json();if(!i.success){k.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(i.error)}</div>`;return}f=i.path||"",E.textContent=f||"Select a drive...";let r="";i.parent&&i.parent!==i.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(i.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
\u26A0 Warning: ${i.warning}`),showNotification(d,"success",8e3),delete t._useExisting,delete t._existingContainer,i.url&&i.url.startsWith("https://")&&x(i.url,t.name),i.setupInstructions&&i.setupInstructions.length>0&&setTimeout(()=>{const w=i.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${t.name}: ${w}`,"info",1e4)},1e3)}else throw new Error(i.error||"Deployment failed")}catch(b){console.error("Deployment error:",b),showNotification(`Failed to deploy ${t.name}: ${b.message}`,"error",8e3)}}}async function x(e,t){showNotification(`\u23F3 Generating SSL certificate for ${t}...`,"warning",6e4);let s=0;const r=12,p=async()=>{s++;try{const b=await fetch(e,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${t} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<r?setTimeout(p,5e3):showNotification(`\u26A0\uFE0F ${t} deployed but SSL certificate may still be generating.
|
|
|
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(p,3e3)}function O(){safeGetJSON(f,[]).forEach(t=>{window.APPS.some(s=>s.id===t.id)||window.APPS.push(t)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{m(),k.classList.add("show")}),wireModal(k,document.getElementById("app-selector-cancel"));const A=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{A.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const e=JSON.parse(A.dataset.appTemplate),t=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{s.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const r={appTemplate:e,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:t||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:s.length>0?s:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!r.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(e.mediaMount?.required&&!t){showNotification("Please enter a media library path for this application","warning");return}A.classList.remove("show"),y(r)}),wireModal(A);const N=document.getElementById("folder-browser-modal"),z=document.getElementById("folder-browser-path"),H=document.getElementById("folder-browser-list"),L=document.getElementById("folder-browser-selected"),g=document.getElementById("folder-browser-selected-list");let I="",c=[],l=null;window.openFolderBrowser=function(e){l=e,c=e.value.split(",").map(t=>t.trim()).filter(t=>t),I="",n(),a(""),N.classList.add("show")};async function a(e){z.textContent=e||"Select a drive...",H.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const s=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(e)}`)).json();if(!s.success){H.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}I=s.path||"",z.textContent=I||"Select a drive...";let r="";s.parent&&s.parent!==s.path&&(r+=`<div class="folder-item" data-path="${escapeHtml(s.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
|
|
|
|
<span style="color: var(--muted);">.. Parent Directory</span>
|
|
|
|
|
</div>`),i.items.length===0&&!i.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':i.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':i.items.forEach(p=>{const v=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",o=c.includes(p.path),s=o?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${s}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${v}</span>
|
|
|
|
|
</div>`),s.items.length===0&&!s.parent?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':s.items.length===0?r+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(p=>{const b=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",o=c.includes(p.path),i=o?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";r+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${i}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${b}</span>
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(p.name)}</span>
|
|
|
|
|
${o?'<span style="color: var(--success);">\u2713</span>':""}
|
|
|
|
|
</div>`}),k.innerHTML=r,k.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{a(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const v=c.includes(p.dataset.path);p.style.background=v?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(t){k.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(t.message)}</div>`}}function n(){if(c.length===0){b.style.display="none";return}b.style.display="block",m.innerHTML=c.map(e=>`
|
|
|
|
|
</div>`}),H.innerHTML=r,H.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{a(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const b=c.includes(p.dataset.path);p.style.background=b?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(t){H.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(t.message)}</div>`}}function n(){if(c.length===0){L.style.display="none";return}L.style.display="block",g.innerHTML=c.map(e=>`
|
|
|
|
|
<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(e)}
|
|
|
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(e)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
|
|
|
</span>
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(e){c=c.filter(t=>t!==e),n(),a(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!c.includes(f)&&(c.push(f),n(),a(f))}),wireModal(S,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{d&&(d.value=c.join(", ")),S.classList.remove("show")}),O()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(e){c=c.filter(t=>t!==e),n(),a(I)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{I&&!c.includes(I)&&(c.push(I),n(),a(I))}),wireModal(N,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{l&&(l.value=c.join(", ")),N.classList.remove("show")}),O()})(),(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>
|
|
|
|
|
|
|
|
|
@@ -429,13 +445,13 @@ 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 y=null,h=null,L=null,T=1,B=!1;const N=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),$=document.getElementById("recipe-next");wireModal(N,D);async function P(){try{const c=await fetch("/api/v1/recipes/templates"),d=await c.json();if(d.success)return y=d.templates,h=d.categories,!0;if(c.status===403)return B=!1,!1}catch(c){console.warn("Failed to fetch recipe templates:",c.message)}return!1}async function C(){try{B=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{B=!1}return B}window.renderRecipeCards=async function(c){await C();let d;if(B&&y?d=y:d=H(),!d||d.length===0)return;const a=document.createElement("div");a.className="app-category-header",a.innerHTML="\u{1F9EA} Recipes",a.style.borderBottomColor="#8e44ad",c.appendChild(a);const n=Array.isArray(d)?d:Object.values(d);n.sort((e,t)=>(t.popularity||0)-(e.popularity||0));for(const e of n){const t=document.createElement("div");t.className="app-option",t.style.position="relative";const i=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${e.componentCount||e.components?.length||"?"} apps</div>`,r=B?"":'<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>';t.innerHTML=`
|
|
|
|
|
</div>`);let f=null,E=null,M=null,k=1,S=!1;const D=document.getElementById("recipe-deploy-modal"),B=document.getElementById("recipe-cancel"),C=document.getElementById("recipe-prev"),m=document.getElementById("recipe-next");wireModal(D,B);async function h(){try{const c=await fetch("/api/v1/recipes/templates"),l=await c.json();if(l.success)return f=l.templates,E=l.categories,!0;if(c.status===403)return S=!1,!1}catch(c){console.warn("Failed to fetch recipe templates:",c.message)}return!1}async function u(){try{S=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{S=!1}return S}window.renderRecipeCards=async function(c){await u();let l;if(S&&f?l=f:l=y(),!l||l.length===0)return;const a=document.createElement("div");a.className="app-category-header",a.innerHTML="\u{1F9EA} Recipes",a.style.borderBottomColor="#8e44ad",c.appendChild(a);const n=Array.isArray(l)?l:Object.values(l);n.sort((e,t)=>(t.popularity||0)-(e.popularity||0));for(const e of n){const t=document.createElement("div");t.className="app-option",t.style.position="relative";const s=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${e.componentCount||e.components?.length||"?"} apps</div>`,r=S?"":'<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>';t.innerHTML=`
|
|
|
|
|
${r}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
|
|
|
|
${i}
|
|
|
|
|
`,t.onclick=()=>{if(!B){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(e)},c.appendChild(t)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(c){L=c,T=1;const d=document.getElementById("app-selector-modal");d&&d.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${c.name}`,O(),z(),N.classList.add("show")}function O(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(c=>{const d=parseInt(c.dataset.step);c.classList.toggle("active",d===T),c.classList.toggle("completed",d<T)});for(let c=1;c<=4;c++){const d=document.getElementById(`recipe-step-${c}`);d&&(d.style.display=c===T?"":"none")}A.style.display=T>1&&T<4?"":"none",T===4?($.style.display="none",D.textContent="Close"):T===3?($.textContent="\u{1F680} Deploy",$.style.display="",D.textContent="Cancel"):($.textContent="Next",$.style.display="",D.textContent="Cancel")}function z(){const c=document.getElementById("recipe-component-list");c.innerHTML="";const d=L.components||[];for(const a of d){const n=document.createElement("div");n.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const e=a.required,t=a.internal;n.innerHTML=`
|
|
|
|
|
${s}
|
|
|
|
|
`,t.onclick=()=>{if(!S){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(e)},c.appendChild(t)}};function y(){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 x(c){M=c,k=1;const l=document.getElementById("app-selector-modal");l&&l.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${c.name}`,O(),A(),D.classList.add("show")}function O(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(c=>{const l=parseInt(c.dataset.step);c.classList.toggle("active",l===k),c.classList.toggle("completed",l<k)});for(let c=1;c<=4;c++){const l=document.getElementById(`recipe-step-${c}`);l&&(l.style.display=c===k?"":"none")}C.style.display=k>1&&k<4?"":"none",k===4?(m.style.display="none",B.textContent="Close"):k===3?(m.textContent="\u{1F680} Deploy",m.style.display="",B.textContent="Cancel"):(m.textContent="Next",m.style.display="",B.textContent="Cancel")}function A(){const c=document.getElementById("recipe-component-list");c.innerHTML="";const l=M.components||[];for(const a of l){const n=document.createElement("div");n.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const e=a.required,t=a.internal;n.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${e?"checked disabled":"checked"} data-component-id="${a.id}"
|
|
|
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
@@ -447,20 +463,20 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</div>
|
|
|
|
|
${a.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(a.note)}</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,c.appendChild(n)}}function S(){const c=document.getElementById("recipe-volumes-section"),d=document.getElementById("recipe-volume-list"),a=L.sharedVolumes;if(a&&Object.keys(a).length>0){c.style.display="",d.innerHTML="";for(const[n,e]of Object.entries(a)){const t=document.createElement("div");t.style.cssText="display: grid; gap: 4px;",t.innerHTML=`
|
|
|
|
|
`,c.appendChild(n)}}function N(){const c=document.getElementById("recipe-volumes-section"),l=document.getElementById("recipe-volume-list"),a=M.sharedVolumes;if(a&&Object.keys(a).length>0){c.style.display="",l.innerHTML="";for(const[n,e]of Object.entries(a)){const t=document.createElement("div");t.style.cssText="display: grid; gap: 4px;",t.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(e.label||n)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${n}" value="${escapeHtml(e.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(e.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(e.description||"")}</div>
|
|
|
|
|
`,d.appendChild(t)}}else c.style.display="none"}function E(){const c=document.getElementById("recipe-review-content"),d=k(),a=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),n={};a.forEach(r=>{n[r.dataset.volumeKey]=r.value});const e=document.getElementById("recipe-timezone").value||"UTC",t=document.getElementById("recipe-ip").value||"host.docker.internal",i=document.getElementById("recipe-tailscale").checked;c.innerHTML=`
|
|
|
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(L.name)}</div>
|
|
|
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(L.description||"")}</div>
|
|
|
|
|
`,l.appendChild(t)}}else c.style.display="none"}function z(){const c=document.getElementById("recipe-review-content"),l=H(),a=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),n={};a.forEach(r=>{n[r.dataset.volumeKey]=r.value});const e=document.getElementById("recipe-timezone").value||"UTC",t=document.getElementById("recipe-ip").value||"host.docker.internal",s=document.getElementById("recipe-tailscale").checked;c.innerHTML=`
|
|
|
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(M.name)}</div>
|
|
|
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(M.description||"")}</div>
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Components (${d.length}):</strong>
|
|
|
|
|
<strong>Components (${l.length}):</strong>
|
|
|
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
|
|
|
${d.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
${l.map(r=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
\u2022 <strong>${escapeHtml(r.role||r.id)}</strong> ${r.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
|
|
|
</div>`).join("")}
|
|
|
|
|
</div>
|
|
|
|
@@ -472,15 +488,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</div>`:""}
|
|
|
|
|
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
|
|
|
Timezone: ${escapeHtml(e)} • IP: ${escapeHtml(t)} ${i?"• Tailscale only":""}
|
|
|
|
|
Timezone: ${escapeHtml(e)} • IP: ${escapeHtml(t)} ${s?"• Tailscale only":""}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${L.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(L.network.name)}</code></div>`:""}
|
|
|
|
|
`}function k(){const c=document.querySelectorAll("#recipe-component-list input[data-component-id]"),d=new Set;c.forEach(n=>{n.checked&&d.add(n.dataset.componentId)});const a=L.components||[];return a.filter(n=>n.required).forEach(n=>d.add(n.id)),a.filter(n=>d.has(n.id))}async function b(){const c=document.getElementById("recipe-progress-list"),d=document.getElementById("recipe-deploy-result");d.style.display="none",c.innerHTML="";const a=k();for(const i of a){const r=document.createElement("div");r.id=`recipe-progress-${i.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
|
|
|
|
${M.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(M.network.name)}</code></div>`:""}
|
|
|
|
|
`}function H(){const c=document.querySelectorAll("#recipe-component-list input[data-component-id]"),l=new Set;c.forEach(n=>{n.checked&&l.add(n.dataset.componentId)});const a=M.components||[];return a.filter(n=>n.required).forEach(n=>l.add(n.id)),a.filter(n=>l.has(n.id))}async function L(){const c=document.getElementById("recipe-progress-list"),l=document.getElementById("recipe-deploy-result");l.style.display="none",c.innerHTML="";const a=H();for(const s of a){const r=document.createElement("div");r.id=`recipe-progress-${s.id}`,r.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",r.innerHTML=`
|
|
|
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(i.role||i.id)}</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(s.role||s.id)}</span>
|
|
|
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
|
|
|
`,c.appendChild(r)}const n=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),e={};n.forEach(i=>{e[i.dataset.volumeKey]=i.value});const t={selectedComponents:a.map(i=>i.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:e},componentOverrides:{}};for(const i of a)m(i.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:L.id,config:t})})).json();if(r.success){for(const p of r.deployed||[])m(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])m(p.componentId,"error",p.error);d.style.display="",d.innerHTML=`
|
|
|
|
|
`,c.appendChild(r)}const n=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),e={};n.forEach(s=>{e[s.dataset.volumeKey]=s.value});const t={selectedComponents:a.map(s=>s.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:e},componentOverrides:{}};for(const s of a)g(s.id,"deploying","Deploying...");try{const r=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:M.id,config:t})})).json();if(r.success){for(const p of r.deployed||[])g(p.id,"success",p.url?`Running \u2192 ${p.url}`:"Running");for(const p of r.errors||[])g(p.componentId,"error",p.error);l.style.display="",l.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(r.message||"Deployed!")}</div>
|
|
|
|
|
${r.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
|
|
@@ -488,11 +504,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${r.setupInstructions.map(p=>`<li>${escapeHtml(p)}</li>`).join("")}</ul>
|
|
|
|
|
</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,showNotification(`${L.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else d.style.display="",d.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(`${M.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else l.style.display="",l.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
|
|
|
<strong>Deployment failed:</strong> ${escapeHtml(r.error||"Unknown error")}
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(i){d.style.display="",d.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(i.message)}
|
|
|
|
|
</div>`}}function m(c,d,a){const n=document.getElementById(`recipe-progress-${c}`);if(!n)return;const e=n.querySelector(".recipe-progress-icon"),t=n.querySelector(".recipe-progress-status");d==="deploying"?(e.textContent="\u23F3",t.style.color="var(--accent)"):d==="success"?(e.textContent="\u2705",t.style.color="var(--ok-fg)"):d==="error"&&(e.textContent="\u274C",t.style.color="var(--bad-fg)"),t.textContent=a}$.addEventListener("click",()=>{if(T===3){T=4,O(),b();return}T<3&&(T++,O(),T===2&&S(),T===3&&E())}),A.addEventListener("click",()=>{T>1&&T<4&&(T--,O())}),window.groupRecipeCards=function(){const c=document.querySelectorAll(".service-card[data-recipe-id]");if(c.length===0)return;const d={};c.forEach(a=>{const n=a.dataset.recipeId;d[n]||(d[n]=[]),d[n].push(a)});for(const[a,n]of Object.entries(d))n.length<2||n.forEach((e,t)=>{if(e.style.borderLeft="3px solid rgba(142,68,173,0.5)",t===0){let i=e.querySelector(".recipe-group-label");i||(i=document.createElement("div"),i.className="recipe-group-label",i.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;",i.textContent=a.replace(/-/g," "),e.style.position="relative",e.appendChild(i))}})},window.manageRecipe=async function(c,d){const a=`/api/v1/recipes/${c}/${d}`,n=d==="remove"?"DELETE":"POST",e=d==="remove"?`/api/v1/recipes/${c}`:a;if(!(d==="remove"&&!confirm(`Remove the entire ${c} recipe? This will delete all containers and configuration.`)))try{const i=await(await secureFetch(e,{method:n})).json();i.success?(showNotification(`Recipe ${d}: ${i.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${d} failed: ${i.error}`,"error",5e3)}catch(t){showNotification(`Network error: ${t.message}`,"error",5e3)}};const f=document.createElement("style");f.textContent=`
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${r.error}`,"error",5e3)}catch(s){l.style.display="",l.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(s.message)}
|
|
|
|
|
</div>`}}function g(c,l,a){const n=document.getElementById(`recipe-progress-${c}`);if(!n)return;const e=n.querySelector(".recipe-progress-icon"),t=n.querySelector(".recipe-progress-status");l==="deploying"?(e.textContent="\u23F3",t.style.color="var(--accent)"):l==="success"?(e.textContent="\u2705",t.style.color="var(--ok-fg)"):l==="error"&&(e.textContent="\u274C",t.style.color="var(--bad-fg)"),t.textContent=a}m.addEventListener("click",()=>{if(k===3){k=4,O(),L();return}k<3&&(k++,O(),k===2&&N(),k===3&&z())}),C.addEventListener("click",()=>{k>1&&k<4&&(k--,O())}),window.groupRecipeCards=function(){const c=document.querySelectorAll(".service-card[data-recipe-id]");if(c.length===0)return;const l={};c.forEach(a=>{const n=a.dataset.recipeId;l[n]||(l[n]=[]),l[n].push(a)});for(const[a,n]of Object.entries(l))n.length<2||n.forEach((e,t)=>{if(e.style.borderLeft="3px solid rgba(142,68,173,0.5)",t===0){let s=e.querySelector(".recipe-group-label");s||(s=document.createElement("div"),s.className="recipe-group-label",s.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;",s.textContent=a.replace(/-/g," "),e.style.position="relative",e.appendChild(s))}})},window.manageRecipe=async function(c,l){const a=`/api/v1/recipes/${c}/${l}`,n=l==="remove"?"DELETE":"POST",e=l==="remove"?`/api/v1/recipes/${c}`:a;if(!(l==="remove"&&!confirm(`Remove the entire ${c} recipe? This will delete all containers and configuration.`)))try{const s=await(await secureFetch(e,{method:n})).json();s.success?(showNotification(`Recipe ${l}: ${s.results?.filter(r=>r.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${l} failed: ${s.error}`,"error",5e3)}catch(t){showNotification(`Network error: ${t.message}`,"error",5e3)}};const I=document.createElement("style");I.textContent=`
|
|
|
|
|
.recipe-step {
|
|
|
|
|
flex: 1;
|
|
|
|
|
text-align: center;
|
|
|
|
@@ -534,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(f),C()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const y=document.getElementById("reload-caddy-top"),h=y.textContent;try{y.textContent="\u23F3 Reloading...",y.disabled=!0;const L=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),T=await L.json();if(L.ok&&T.success)y.textContent="\u2705 Reloaded!",setTimeout(()=>{y.textContent=h,y.disabled=!1},2e3);else throw new Error(T.error||"Reload failed")}catch(L){y.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${L.message}`,"error"),setTimeout(()=>{y.textContent=h,y.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 y=document.getElementById("error-log-modal"),h=document.getElementById("error-log-content"),L=document.getElementById("view-error-logs"),T=document.getElementById("error-log-refresh"),B=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close");async function D(){h.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const P=await(await fetch("/api/v1/error-logs")).json();P.success&&P.logs?P.logs.length===0?h.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':h.innerHTML=P.logs.map(C=>`
|
|
|
|
|
`,document.head.appendChild(I),u()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const f=document.getElementById("reload-caddy-top"),E=f.textContent;try{f.textContent="\u23F3 Reloading...",f.disabled=!0;const M=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),k=await M.json();if(M.ok&&k.success)f.textContent="\u2705 Reloaded!",setTimeout(()=>{f.textContent=E,f.disabled=!1},2e3);else throw new Error(k.error||"Reload failed")}catch(M){f.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${M.message}`,"error"),setTimeout(()=>{f.textContent=E,f.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 f=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),M=document.getElementById("view-error-logs"),k=document.getElementById("error-log-refresh"),S=document.getElementById("error-log-clear"),D=document.getElementById("error-log-close");async function B(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const h=await(await fetch("/api/v1/error-logs")).json();h.success&&h.logs?h.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=h.logs.map(u=>`
|
|
|
|
|
<div class="log-entry error">
|
|
|
|
|
<span class="log-timestamp">${new Date(C.timestamp).toLocaleString()}</span>
|
|
|
|
|
<span class="log-timestamp">${new Date(u.timestamp).toLocaleString()}</span>
|
|
|
|
|
<span class="log-level">ERROR</span>
|
|
|
|
|
<div class="log-message">
|
|
|
|
|
<strong>${escapeHtml(C.context)}</strong>: ${escapeHtml(C.error)}
|
|
|
|
|
${C.details?`<br><small style="opacity: 0.7;">${escapeHtml(C.details)}</small>`:""}
|
|
|
|
|
<strong>${escapeHtml(u.context)}</strong>: ${escapeHtml(u.error)}
|
|
|
|
|
${u.details?`<br><small style="opacity: 0.7;">${escapeHtml(u.details)}</small>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`).join(""):h.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch($){h.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml($.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),D()):showNotification("\u274C Failed to clear logs","error",3e3)}catch($){showNotification(`\u274C Error: ${$.message}`,"error",3e3)}}L?.addEventListener("click",()=>{y.classList.add("show"),D()}),T?.addEventListener("click",D),B?.addEventListener("click",A),wireModal(y,N)})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(m){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(m.message)}</div>`}}async function C(){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),B()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(m){showNotification(`\u274C Error: ${m.message}`,"error",3e3)}}M?.addEventListener("click",()=>{f.classList.add("show"),B()}),k?.addEventListener("click",B),S?.addEventListener("click",C),wireModal(f,D)})(),(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;">
|
|
|
|
@@ -622,73 +638,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 y=document.getElementById("arr-setup-modal"),h=document.getElementById("arr-setup-btn"),L=document.getElementById("arr-setup-cancel"),T=document.getElementById("smart-connect-btn"),B=document.getElementById("smart-phase-detect"),N=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),$=document.getElementById("smart-detect-results"),P=document.getElementById("smart-credential-inputs"),C=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),O=document.getElementById("smart-retry-btn");let z=null;const S={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},E={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function k(n){B.style.display=n==="detect"?"block":"none",N.style.display=n==="credentials"?"block":"none",D.style.display=n==="progress"?"block":"none",A.style.display=n==="results"?"block":"none"}function b(n){const e={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},t=e[n]||e.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, ${t.bg} 20%, transparent); color: ${t.bg};">${t.icon} ${t.text}</span>`}async function m(){k("detect"),$.style.display="none";try{if(z=await(await fetch("/api/v1/arr/smart-detect")).json(),!z.success){$.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(z.error)}</div>`,$.style.display="block";return}let e='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[i,r]of Object.entries(z.services)){const p=S[i]||"\u{1F4E6}",v=E[i]||i,o=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",s=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",u=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";e+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
</div>`);const f=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),M=document.getElementById("arr-setup-cancel"),k=document.getElementById("smart-connect-btn"),S=document.getElementById("smart-phase-detect"),D=document.getElementById("smart-phase-credentials"),B=document.getElementById("smart-phase-progress"),C=document.getElementById("smart-phase-results"),m=document.getElementById("smart-detect-results"),h=document.getElementById("smart-credential-inputs"),u=document.getElementById("smart-progress-steps"),y=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),O=document.getElementById("smart-retry-btn");let A=null;const N={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},z={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function H(n){S.style.display=n==="detect"?"block":"none",D.style.display=n==="credentials"?"block":"none",B.style.display=n==="progress"?"block":"none",C.style.display=n==="results"?"block":"none"}function L(n){const e={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},t=e[n]||e.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, ${t.bg} 20%, transparent); color: ${t.bg};">${t.icon} ${t.text}</span>`}async function g(){H("detect"),m.style.display="none";try{if(A=await(await fetch("/api/v1/arr/smart-detect")).json(),!A.success){m.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(A.error)}</div>`,m.style.display="block";return}let e='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[s,r]of Object.entries(A.services)){const p=N[s]||"\u{1F4E6}",b=z[s]||s,o=r.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(r.source)}</span>`:"",i=r.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(r.version)}</span>`:"",v=(r.hasApiKey||r.hasToken)&&r.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";e+=`<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;">${p}</span>
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${v}</div>
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${b}</div>
|
|
|
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
|
|
|
${o} ${s} ${u}
|
|
|
|
|
${o} ${i} ${v}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
${b(r.status)}
|
|
|
|
|
</div>`}e+="</div>";const t=z.summary;e+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
|
|
|
|
${L(r.status)}
|
|
|
|
|
</div>`}e+="</div>";const t=A.summary;e+=`<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(t.fullyConnected))}/${escapeHtml(String(t.totalDetected+(5-t.totalDetected)))} services detected ·
|
|
|
|
|
${escapeHtml(String(t.fullyConnected))} connected${t.needsApiKey>0?` · <strong>${escapeHtml(String(t.needsApiKey))} needs API key</strong>`:""}
|
|
|
|
|
</div>`,$.innerHTML=e,$.style.display="block",f(z),setTimeout(()=>{k("credentials")},800)}catch(n){$.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(n.message)}</div>`,$.style.display="block"}}function f(n){let e="";const t=n.services,i=["radarr","sonarr","prowlarr"];for(const v of i){const o=t[v];if(!o||o.status==="not_found"&&!o.url)continue;const s=S[v],u=E[v],l=o.status==="connected";e+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${l?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`,m.innerHTML=e,m.style.display="block",I(A),setTimeout(()=>{H("credentials")},800)}catch(n){m.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(n.message)}</div>`,m.style.display="block"}}function I(n){let e="";const t=n.services,s=["radarr","sonarr","prowlarr"];for(const b of s){const o=t[b];if(!o||o.status==="not_found"&&!o.url)continue;const i=N[b],v=z[b],d=o.status==="connected";e+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${d?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">${s}</span>
|
|
|
|
|
<span style="font-weight: 500;">${u}</span>
|
|
|
|
|
<span id="smart-${v}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${l?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
|
|
|
<span style="font-size: 1.1rem;">${i}</span>
|
|
|
|
|
<span style="font-weight: 500;">${v}</span>
|
|
|
|
|
<span id="smart-${b}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${d?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
|
|
|
|
<input type="text" id="smart-${v}-url" value="${escapeHtml(o.url||"")}" placeholder="https://seedbox.com/${v}/"
|
|
|
|
|
<input type="text" id="smart-${b}-url" value="${escapeHtml(o.url||"")}" placeholder="https://seedbox.com/${b}/"
|
|
|
|
|
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-${v}-key" placeholder="${l?"(saved)":"Paste API key"}"
|
|
|
|
|
<input type="password" id="smart-${b}-key" placeholder="${d?"(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('${v}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const r=t.plex;if(r){const v=r.status==="connected";e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
|
|
|
<button onclick="smartTestConnection('${b}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const r=t.plex;if(r){const b=r.status==="connected";e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${b?"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>
|
|
|
|
|
${b(r.status)}
|
|
|
|
|
${L(r.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(r.source||"")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}const p=t.seerr;if(p){const v=p.status==="connected";let o="";if(p.configuredServices){const s=p.configuredServices;o=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
Configured: ${s.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
|
|
|
${s.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
|
|
|
${s.plex?"✓ Plex":"✗ Plex"}
|
|
|
|
|
</div>`}e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${v?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`}const p=t.seerr;if(p){const b=p.status==="connected";let o="";if(p.configuredServices){const i=p.configuredServices;o=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
Configured: ${i.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
|
|
|
${i.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
|
|
|
${i.plex?"✓ Plex":"✗ Plex"}
|
|
|
|
|
</div>`}e+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${b?"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>
|
|
|
|
|
${b(p.status)}
|
|
|
|
|
${L(p.status)}
|
|
|
|
|
</div>
|
|
|
|
|
${o}
|
|
|
|
|
</div>`}P.innerHTML=e}window.smartTestConnection=async function(n){const e=document.getElementById(`smart-${n}-url`),t=document.getElementById(`smart-${n}-key`),i=document.getElementById(`smart-${n}-status`),r=e?.value.trim(),p=t?.value.trim();if(!r||!p){i.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}i.innerHTML='<span class="brand-spinner"></span>';try{const o=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:n,url:r,apiKey:p})})).json();o.success?i.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(o.appName||"Connected")} v${escapeHtml(o.version||"")}</span>`:i.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(o.error)}</span>`}catch(v){i.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(v.message)}</span>`}};async function c(){k("progress"),C.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 n={};for(const t of["radarr","sonarr","prowlarr"]){const i=document.getElementById(`smart-${t}-url`)?.value.trim(),r=document.getElementById(`smart-${t}-key`)?.value.trim();r&&i?n[t]={apiKey:r,url:i}:r&&(n[t]={apiKey:r})}const e={services:Object.keys(n).length>0?n: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 i=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();let r="";for(const p of i.steps||[]){const v=p.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',o=p.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${v}
|
|
|
|
|
</div>`}h.innerHTML=e}window.smartTestConnection=async function(n){const e=document.getElementById(`smart-${n}-url`),t=document.getElementById(`smart-${n}-key`),s=document.getElementById(`smart-${n}-status`),r=e?.value.trim(),p=t?.value.trim();if(!r||!p){s.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}s.innerHTML='<span class="brand-spinner"></span>';try{const o=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:n,url:r,apiKey:p})})).json();o.success?s.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(o.appName||"Connected")} v${escapeHtml(o.version||"")}</span>`:s.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(o.error)}</span>`}catch(b){s.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(b.message)}</span>`}};async function c(){H("progress"),u.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 n={};for(const t of["radarr","sonarr","prowlarr"]){const s=document.getElementById(`smart-${t}-url`)?.value.trim(),r=document.getElementById(`smart-${t}-key`)?.value.trim();r&&s?n[t]={apiKey:r,url:s}:r&&(n[t]={apiKey:r})}const e={services:Object.keys(n).length>0?n: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 s=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();let r="";for(const p of s.steps||[]){const b=p.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',o=p.status==="success"?"var(--muted)":"var(--bad-fg)";r+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${b}
|
|
|
|
|
<span>${escapeHtml(p.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${o};">${escapeHtml(p.details||"")}</span>
|
|
|
|
|
</div>`}C.innerHTML=r,setTimeout(()=>d(i),500)}catch(t){C.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(t.message)}</div>`}}function d(n){k("results");const e=n.summary||{},t=e.failed===0&&e.succeeded>0,i=t?"var(--ok-fg)":"#f39c12",r=t?"✓":"⚠",p=t?"All Connected!":`${escapeHtml(String(e.succeeded))}/${escapeHtml(String(e.totalSteps))} Steps Succeeded`;let v=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${i} 12%, transparent); border-radius: 10px; border: 1px solid ${i}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${i};">${r}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${i};">${p}</div>
|
|
|
|
|
</div>`}u.innerHTML=r,setTimeout(()=>l(s),500)}catch(t){u.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(t.message)}</div>`}}function l(n){H("results");const e=n.summary||{},t=e.failed===0&&e.succeeded>0,s=t?"var(--ok-fg)":"#f39c12",r=t?"✓":"⚠",p=t?"All Connected!":`${escapeHtml(String(e.succeeded))}/${escapeHtml(String(e.totalSteps))} Steps Succeeded`;let b=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${s} 12%, transparent); border-radius: 10px; border: 1px solid ${s}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${s};">${r}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${s};">${p}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(e.succeeded))} succeeded, ${escapeHtml(String(e.failed))} failed</div>
|
|
|
|
|
</div>`;v+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const o of n.steps||[]){const s=o.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';v+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
|
|
|
${s} ${escapeHtml(o.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(o.details||"")}</span>
|
|
|
|
|
</div>`}v+="</div>",H.innerHTML=v,O.style.display=e.failed>0?"block":"none",n.steps?.some(o=>o.step.includes("Plex")&&o.status==="success")&&a()}async function a(){try{const e=await(await fetch("/api/v1/plex/libraries")).json();if(e.success&&e.libraries?.length>0){let t=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
</div>`;b+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const o of n.steps||[]){const i=o.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';b+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
|
|
|
${i} ${escapeHtml(o.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(o.details||"")}</span>
|
|
|
|
|
</div>`}b+="</div>",y.innerHTML=b,O.style.display=e.failed>0?"block":"none",n.steps?.some(o=>o.step.includes("Plex")&&o.status==="success")&&a()}async function a(){try{const e=await(await fetch("/api/v1/plex/libraries")).json();if(e.success&&e.libraries?.length>0){let t=`<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(e.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const i of e.libraries){const r=i.type==="movie"?"\u{1F3AC}":i.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${r} <strong>${escapeHtml(i.title)}</strong>
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(i.count))} items</span>
|
|
|
|
|
</div>`}t+="</div></div>",x.innerHTML=t,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{y.classList.add("show"),x.style.display="none",m()}),wireModal(y,L),T?.addEventListener("click",c),O?.addEventListener("click",c)})(),(function(){injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const s of e.libraries){const r=s.type==="movie"?"\u{1F3AC}":s.type==="show"?"\u{1F4FA}":"\u{1F3B5}";t+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${r} <strong>${escapeHtml(s.title)}</strong>
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(s.count))} items</span>
|
|
|
|
|
</div>`}t+="</div></div>",x.innerHTML=t,x.style.display="block"}}catch{}}E?.addEventListener("click",()=>{f.classList.add("show"),x.style.display="none",g()}),wireModal(f,M),k?.addEventListener("click",c),O?.addEventListener("click",c)})(),(function(){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>
|
|
|
|
|
|
|
|
|
@@ -767,6 +783,52 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Email -->
|
|
|
|
|
<div class="notification-provider provider-card">
|
|
|
|
|
<div class="provider-header">
|
|
|
|
|
<label class="checkbox-label">
|
|
|
|
|
<input type="checkbox" id="email-enabled" />
|
|
|
|
|
<span class="fw-500">Email (SMTP)</span>
|
|
|
|
|
</label>
|
|
|
|
|
<button id="email-test" class="test-btn btn-xs">Test</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="email-config" style="display: none;">
|
|
|
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">SMTP Host:</label>
|
|
|
|
|
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">Port:</label>
|
|
|
|
|
<input type="number" id="email-port" value="587" placeholder="587" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">Username:</label>
|
|
|
|
|
<input type="text" id="email-user" placeholder="user@gmail.com" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">Password:</label>
|
|
|
|
|
<input type="password" id="email-pass" placeholder="app password" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">From:</label>
|
|
|
|
|
<input type="text" id="email-from" placeholder="DashCaddy <noreply@example.com>" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label class="field-label-sm">To:</label>
|
|
|
|
|
<input type="text" id="email-to" placeholder="admin@example.com" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
|
|
|
|
|
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Health Check Settings -->
|
|
|
|
|
<h4 class="section-heading">Health Monitoring</h4>
|
|
|
|
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
@@ -822,15 +884,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 y=document.getElementById("notifications-modal"),h=document.getElementById("manage-notifications"),L=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy"].forEach(C=>{const H=document.getElementById(`${C}-enabled`),x=document.getElementById(`${C}-config`);H?.addEventListener("change",()=>{x.style.display=H.checked?"block":"none"})});const B=document.getElementById("health-check-enabled"),N=document.getElementById("health-check-config");B?.addEventListener("change",()=>{N.style.opacity=B.checked?"1":"0.5"});async function D(){try{const H=await(await fetch("/api/v1/notifications/config")).json();if(H.success){const x=H.config;document.getElementById("notifications-enabled").checked=x.enabled,document.getElementById("discord-enabled").checked=x.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=x.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=x.providers?.ntfy?.enabled||!1,document.getElementById("discord-config").style.display=x.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=x.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=x.providers?.ntfy?.enabled?"block":"none",x.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=x.providers.ntfy.serverUrl),document.getElementById("health-check-enabled").checked=x.healthCheck?.enabled||!1,x.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=x.healthCheck.intervalMinutes),x.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=x.events?.containerDown!==!1,document.getElementById("event-container-up").checked=x.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=x.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=x.events?.deploymentFailed!==!1}}catch(C){console.error("Failed to load notification config:",C)}}async function A(){try{const H=await(await fetch("/api/v1/notifications/history?limit=10")).json(),x=document.getElementById("notification-history");H.success&&H.history?.length>0?x.innerHTML=H.history.map(O=>{const z=new Date(O.timestamp).toLocaleString();return`
|
|
|
|
|
</div>`);const f=document.getElementById("notifications-modal"),E=document.getElementById("manage-notifications"),M=document.getElementById("notifications-save"),k=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(u=>{const y=document.getElementById(`${u}-enabled`),x=document.getElementById(`${u}-config`);y?.addEventListener("change",()=>{x.style.display=y.checked?"block":"none"})});const S=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");S?.addEventListener("change",()=>{D.style.opacity=S.checked?"1":"0.5"});async function B(){try{const y=await(await fetch("/api/v1/notifications/config")).json();if(y.success){const x=y.config;document.getElementById("notifications-enabled").checked=x.enabled,document.getElementById("discord-enabled").checked=x.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=x.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=x.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=x.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=x.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=x.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=x.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=x.providers?.email?.enabled?"block":"none",x.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=x.providers.ntfy.serverUrl),x.providers?.email?.host&&(document.getElementById("email-host").value=x.providers.email.host),x.providers?.email?.from&&(document.getElementById("email-from").value=x.providers.email.from),document.getElementById("health-check-enabled").checked=x.healthCheck?.enabled||!1,x.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=x.healthCheck.intervalMinutes),x.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(x.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=x.events?.containerDown!==!1,document.getElementById("event-container-up").checked=x.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=x.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=x.events?.deploymentFailed!==!1}}catch(u){console.error("Failed to load notification config:",u)}}async function C(){try{const y=await(await fetch("/api/v1/notifications/history?limit=10")).json(),x=document.getElementById("notification-history");y.success&&y.history?.length>0?x.innerHTML=y.history.map(O=>{const A=new Date(O.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)"}[O.type]||"var(--muted)"}">${O.type==="success"?"\u2713":O.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(O.title)}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${z}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${A}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""):x.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(C){console.error("Failed to load notification history:",C)}}async function $(){try{const C={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()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},x=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(C)})).json();x.success?(showNotification("Notification settings saved","success",3e3),y.classList.remove("show")):showNotification(`Failed to save: ${x.error}`,"error",3e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}async function P(C){try{const x=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:C})})).json();x.success?showNotification(`Test ${C} notification sent!`,"success",3e3):showNotification(`Test failed: ${x.error}`,"error",3e3)}catch(H){showNotification(`Error: ${H.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>P("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>P("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>P("ntfy")),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(C){showNotification(`Error: ${C.message}`,"error",3e3)}}),h?.addEventListener("click",()=>{y.classList.add("show"),D(),A()}),L?.addEventListener("click",$),wireModal(y,T)})(),(function(){document.addEventListener("click",y=>{const h=y.target.closest(".panel-tab");if(!h)return;const L=h.dataset.panel;if(!L)return;const T=h.closest(".panel-tabs"),B=T.closest(".weather-modal-content");T.querySelectorAll(".panel-tab").forEach(D=>D.classList.remove("active")),h.classList.add("active"),B.querySelectorAll(".panel-section").forEach(D=>D.classList.remove("active"));const N=B.querySelector("#"+L);N&&N.classList.add("active")})})(),(function(){var y=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function h(){for(var a={},n=0;n<y.length;n++){var e=y[n],t=safeGet(e);t!=null&&(a[e]=t)}try{for(var i=0;i<localStorage.length;i++){var r=localStorage.key(i);/^widget-.+-enabled$/.test(r)&&(a[r]=localStorage.getItem(r))}}catch{}return a}function L(a){if(!a||typeof a!="object")return 0;var n=0;for(var e in a)a.hasOwnProperty(e)&&(safeSet(e,a[e]),n++);return n}function T(a){return a.version&&!a.files&&a.services}function B(a){var n={};a.customServices&&(n["custom-services"]=JSON.stringify(a.customServices)),a.customApps&&(n["custom-apps"]=JSON.stringify(a.customApps)),a.weatherZip&&(n["weather-zip"]=a.weatherZip),a.theme&&(n.theme=a.theme),a.userThemes&&Object.keys(a.userThemes).length&&(n["user-themes"]=JSON.stringify(a.userThemes)),L(n),a.services&&Array.isArray(a.services)&&secureFetch("/api/v1/services",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(a.services)}).catch(function(){}),a.userThemes&&Object.keys(a.userThemes).forEach(function(e){var t=a.userThemes[e],i={};(window.THEME_PROPS||[]).forEach(function(r){t[r]&&(i[r]=t[r])}),secureFetch("/api/v1/themes/"+e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:t.name||e,colors:i})}).catch(function(){})})}injectModal("backup-modal",`<div id="backup-modal" class="weather-modal">
|
|
|
|
|
`}).join(""):x.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(u){console.error("Failed to load notification history:",u)}}async function m(){try{const u={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},x=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)})).json();x.success?(showNotification("Notification settings saved","success",3e3),f.classList.remove("show")):showNotification(`Failed to save: ${x.error}`,"error",3e3)}catch(u){showNotification(`Error: ${u.message}`,"error",3e3)}}async function h(u){try{const x=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:u})})).json();x.success?showNotification(`Test ${u} notification sent!`,"success",3e3):showNotification(`Test failed: ${x.error}`,"error",3e3)}catch(y){showNotification(`Error: ${y.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>h("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>h("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>h("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>h("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const y=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();y.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(y.lastCheck).toLocaleString()} (${y.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(u){showNotification(`Error: ${u.message}`,"error",3e3)}}),E?.addEventListener("click",()=>{f.classList.add("show"),B(),C()}),M?.addEventListener("click",m),wireModal(f,k)})(),(function(){document.addEventListener("click",f=>{const E=f.target.closest(".panel-tab");if(!E)return;const M=E.dataset.panel;if(!M)return;const k=E.closest(".panel-tabs"),S=k.closest(".weather-modal-content");k.querySelectorAll(".panel-tab").forEach(B=>B.classList.remove("active")),E.classList.add("active"),S.querySelectorAll(".panel-section").forEach(B=>B.classList.remove("active"));const D=S.querySelector("#"+M);D&&D.classList.add("active")})})(),(function(){var f=["dashcaddy_site_config","dashcaddy_onboarding","dashcaddy-encryption-key","dashcaddy-setup","dashcaddy-config","theme","user-themes","custom-theme","custom-apps","custom-services","toolbar-sections","weather-location","weather-zip","weather-geo","weather-unit","clock-style","clock-chimes","clock-chime-volume"];function E(){for(var a={},n=0;n<f.length;n++){var e=f[n],t=safeGet(e);t!=null&&(a[e]=t)}try{for(var s=0;s<localStorage.length;s++){var r=localStorage.key(s);/^widget-.+-enabled$/.test(r)&&(a[r]=localStorage.getItem(r))}}catch{}return a}function M(a){if(!a||typeof a!="object")return 0;var n=0;for(var e in a)a.hasOwnProperty(e)&&(safeSet(e,a[e]),n++);return n}function k(a){return a.version&&!a.files&&a.services}function S(a){var n={};a.customServices&&(n["custom-services"]=JSON.stringify(a.customServices)),a.customApps&&(n["custom-apps"]=JSON.stringify(a.customApps)),a.weatherZip&&(n["weather-zip"]=a.weatherZip),a.theme&&(n.theme=a.theme),a.userThemes&&Object.keys(a.userThemes).length&&(n["user-themes"]=JSON.stringify(a.userThemes)),M(n),a.services&&Array.isArray(a.services)&&secureF
Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
|
|
|
<h3>\u{1F4BE} Backup & Restore</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -914,7 +976,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 N=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),$=document.getElementById("backup-export-btn"),P=document.getElementById("backup-select-file"),C=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),O=document.getElementById("backup-preview-content"),z=document.getElementById("backup-do-restore-btn"),S=document.getElementById("backup-result"),E=document.getElementById("backup-schedule-container"),k=document.getElementById("backup-history-container"),b=null;D?.addEventListener("click",function(){N.classList.add("show"),S&&(S.style.display="none"),x&&(x.style.display="none"),H&&(H.style.display="none"),b=null}),wireModal(N,A),$?.addEventListener("click",async function(){$.disabled=!0,$.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var a=await fetch("/api/v1/backup/export"),n=await a.json();n.browserState=h();var e=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),t=URL.createObjectURL(e),i=document.createElement("a");i.href=t,i.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(i),i.click(),document.body.removeChild(i),URL.revokeObjectURL(t);var r=Object.keys(n.browserState).length,p=n.themes?Object.keys(n.themes).length:0;S.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(p?" + "+p+" themes":""),S.style.display="block",S.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",S.style.border="1px solid var(--ok-fg)"}catch(v){S.innerHTML="\u274C Export failed: "+escapeHtml(v.message),S.style.display="block",S.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",S.style.border="1px solid var(--bad-fg)"}$.disabled=!1,$.innerHTML="\u2B07\uFE0F Download Full Backup"}),P?.addEventListener("click",function(){C.click()}),C?.addEventListener("change",async function(a){var n=a.target.files[0];if(n){H.textContent="\u{1F4C4} "+n.name,H.style.display="block",S.style.display="none";try{var e=await n.text(),t=JSON.parse(e);if(T(t)){b=t;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";i+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),i+="</div>",O.innerHTML=i,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),p=await r.json();if(p.success){b=t;var i='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";i+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',i+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var v in p.preview.files){var o=p.preview.files[v],s=o.action==="create"?"\u{1F195}":"\u{1F4DD}";i+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+s+" "+escapeHtml(o.description)+"</span>"}i+="</div>",p.preview.serviceCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+p.preview.serviceCount+" services</div>"),p.preview.themeCount&&(i+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+p.preview.themeCount+" custom themes</div>"),p.preview.browserStateCount&&(i+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',i+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),O.innerHTML=i,x.style.display="block"}else S.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(p.error),S.style.display="block",S.style.background="color-mix(in srgb, #f39c12 15%, transparent)",S.style.border="1px solid #f39c12",x.style.display="none"}catch(u){S.innerHTML="\u274C Could not read file: "+escapeHtml(u.message),S.style.display="block",S.style.background="color-mix(in Line truncated
|
|
|
|
|
</div>`);var D=document.getElementById("backup-modal"),B=document.getElementById("backup-restore-btn"),C=document.getElementById("backup-cancel"),m=document.getElementById("backup-export-btn"),h=document.getElementById("backup-select-file"),u=document.getElementById("backup-file-input"),y=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),O=document.getElementById("backup-preview-content"),A=document.getElementById("backup-do-restore-btn"),N=document.getElementById("backup-result"),z=document.getElementById("backup-schedule-container"),H=document.getElementById("backup-history-container"),L=null;B?.addEventListener("click",function(){D.classList.add("show"),N&&(N.style.display="none"),x&&(x.style.display="none"),y&&(y.style.display="none"),L=null}),wireModal(D,C),m?.addEventListener("click",async function(){m.disabled=!0,m.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var a=await fetch("/api/v1/backup/export"),n=await a.json();n.browserState=E();var e=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),t=URL.createObjectURL(e),s=document.createElement("a");s.href=t,s.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(s),s.click(),document.body.removeChild(s),URL.revokeObjectURL(t);var r=Object.keys(n.browserState).length,p=n.themes?Object.keys(n.themes).length:0;N.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(p?" + "+p+" themes":""),N.style.display="block",N.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",N.style.border="1px solid var(--ok-fg)"}catch(b){N.innerHTML="\u274C Export failed: "+escapeHtml(b.message),N.style.display="block",N.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",N.style.border="1px solid var(--bad-fg)"}m.disabled=!1,m.innerHTML="\u2B07\uFE0F Download Full Backup"}),h?.addEventListener("click",function(){u.click()}),u?.addEventListener("change",async function(a){var n=a.target.files[0];if(n){y.textContent="\u{1F4C4} "+n.name,y.style.display="block",N.style.display="none";try{var e=await n.text(),t=JSON.parse(e);if(k(t)){L=t;var s='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";s+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),s+="</div>",O.innerHTML=s,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),p=await r.json();if(p.success){L=t;var s='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";s+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',s+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var b in p.preview.files){var o=p.preview.files[b],i=o.action==="create"?"\u{1F195}":"\u{1F4DD}";s+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+i+" "+escapeHtml(o.description)+"</span>"}s+="</div>",p.preview.serviceCount&&(s+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+p.preview.serviceCount+" services</div>"),p.preview.themeCount&&(s+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+p.preview.themeCount+" custom themes</div>"),p.preview.browserStateCount&&(s+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',s+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+p.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),O.innerHTML=s,x.style.display="block"}else N.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(p.error),N.style.display="block",N.style.background="color-mix(in srgb, #f39c12 15%, transparent)",N.style.border="1px solid #f39c12",x.style.display="none"}catch(v){N.innerHTML="\u274C Could not read file: "+escapeHtml(v.message),N.style.display="block",N.style.background="color-mix(in Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
|
|
|
<h3>\u{1F4CA} Resource Monitor</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -972,11 +1034,11 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="stats-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),L=document.getElementById("stats-cancel"),T=document.getElementById("stats-refresh-btn"),B=document.getElementById("stats-auto-refresh"),N=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),$=document.getElementById("stats-last-update");let P=null,C=null;function H(m){if(m===0||!m)return"0 B";const f=1024,c=["B","KB","MB","GB"],d=Math.floor(Math.log(m)/Math.log(f));return parseFloat((m/Math.pow(f,d)).toFixed(1))+" "+c[d]}function x(m){return m<30?"#2ecc71":m<70?"#f39c12":"#e74c3c"}function O(m){return m<50?"#2ecc71":m<80?"#f39c12":"#e74c3c"}async function z(){try{let m=null,f=!1;try{const a=await(await fetch("/api/v1/monitoring/stats")).json();a.success&&a.stats&&(m=a.stats,f=!0,C=a.stats)}catch{}if(!f){const a=await(await fetch("/api/v1/stats/containers")).json();if(a.success&&a.stats){m={};for(const n of a.stats)m[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};C=m}}if(!m||Object.keys(m).length===0){N.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[d,a]of Object.entries(m)){const n=a.current||a,e=n.cpu?.percent||0,t=n.memory?.percent||0,i=x(e),r=O(t),p=n.memory?.usage||n.memory?.used||0,v=n.memory?.limit||0,o=n.network?.rxBytes||n.network?.rx||0,s=n.network?.txBytes||n.network?.tx||0,u=a.aggregated;c+=`
|
|
|
|
|
</div>`);const f=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),M=document.getElementById("stats-cancel"),k=document.getElementById("stats-refresh-btn"),S=document.getElementById("stats-auto-refresh"),D=document.getElementById("stats-container"),B=document.getElementById("stats-aggregated-container"),C=document.getElementById("stats-alerts-container"),m=document.getElementById("stats-last-update");let h=null,u=null;function y(g){if(g===0||!g)return"0 B";const I=1024,c=["B","KB","MB","GB"],l=Math.floor(Math.log(g)/Math.log(I));return parseFloat((g/Math.pow(I,l)).toFixed(1))+" "+c[l]}function x(g){return g<30?"#2ecc71":g<70?"#f39c12":"#e74c3c"}function O(g){return g<50?"#2ecc71":g<80?"#f39c12":"#e74c3c"}async function A(){try{let g=null,I=!1;try{const a=await(await fetch("/api/v1/monitoring/stats")).json();a.success&&a.stats&&(g=a.stats,I=!0,u=a.stats)}catch{}if(!I){const a=await(await fetch("/api/v1/stats/containers")).json();if(a.success&&a.stats){g={};for(const n of a.stats)g[n.name]={name:n.name,current:{cpu:n.cpu,memory:{percent:n.memory.percent,usage:n.memory.used,limit:n.memory.limit,usageMB:Math.round(n.memory.used/1048576),limitMB:Math.round(n.memory.limit/1048576)},network:{rxBytes:n.network.rx,txBytes:n.network.tx,rxMB:(n.network.rx/1048576).toFixed(1),txMB:(n.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:n.status};u=g}}if(!g||Object.keys(g).length===0){D.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[l,a]of Object.entries(g)){const n=a.current||a,e=n.cpu?.percent||0,t=n.memory?.percent||0,s=x(e),r=O(t),p=n.memory?.usage||n.memory?.used||0,b=n.memory?.limit||0,o=n.network?.rxBytes||n.network?.rx||0,i=n.network?.txBytes||n.network?.tx||0,v=a.aggregated;c+=`
|
|
|
|
|
<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;">${a.name||d}</span>
|
|
|
|
|
${u?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${u.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${a.name||l}</span>
|
|
|
|
|
${v?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${v.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${a.status||"running"}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
|
|
|
@@ -984,9 +1046,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(e,100)}%; background: ${i}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(e,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${i}; min-width: 45px; text-align: right;">${e.toFixed(1)}%</span>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${e.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
@@ -997,19 +1059,19 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${r}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(p)} / ${H(v)}</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${y(p)} / ${y(b)}</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 ${H(o)}</span>
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${y(o)}</span>
|
|
|
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${H(s)}</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${y(i)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}c+="</div>",N.innerHTML=c,$.textContent="Updated: "+new Date().toLocaleTimeString()}catch(m){N.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(m.message)}</div>`}}async function S(){if(!D)return;const m=C;if(!m||Object.keys(m).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,d]of Object.entries(m)){const a=d.aggregated;a&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${d.name||c}</div>
|
|
|
|
|
</div>`}c+="</div>",D.innerHTML=c,m.textContent="Updated: "+new Date().toLocaleTimeString()}catch(g){D.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(g.message)}</div>`}}async function N(){if(!B)return;const g=u;if(!g||Object.keys(g).length===0){B.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 I='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,l]of Object.entries(g)){const a=l.aggregated;a&&(I+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${l.name||c}</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${a.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${a.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
|
|
@@ -1017,9 +1079,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${a.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
${a.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${a.dataPoints} data points over ${a.timeRange||24}h</div>`:""}
|
|
|
|
|
</div>`)}f+="</div>",D.innerHTML=f}async function E(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const m=C;if(!m||Object.keys(m).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,d]of Object.entries(m)){const a=d.alertConfig||{};f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
</div>`)}I+="</div>",B.innerHTML=I}async function z(){if(!C)return;C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const g=u;if(!g||Object.keys(g).length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let I='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,l]of Object.entries(g)){const a=l.alertConfig||{};I+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${d.name||c}</span>
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${l.name||c}</span>
|
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
|
|
|
<input type="checkbox" class="alert-enabled" data-container="${c}" ${a.enabled?"checked":""} /> Enabled
|
|
|
|
|
</label>
|
|
|
|
@@ -1045,7 +1107,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<span style="flex: 1;"></span>
|
|
|
|
|
<button class="alert-save-btn" data-container="${c}" style="padding: 4px 12px; font-size: 0.8rem; background: color-mix(in srgb, var(--accent) 20%, transparent); border: 1px solid var(--accent); color: var(--accent); border-radius: 4px; cursor: pointer;">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}f+="</div>",A.innerHTML=f,A.querySelectorAll(".alert-save-btn").forEach(c=>{c.addEventListener("click",async()=>{const d=c.dataset.container,a=A.querySelector(`.alert-enabled[data-container="${d}"]`)?.checked||!1,n=parseInt(A.querySelector(`.alert-cpu[data-container="${d}"]`)?.value)||80,e=parseInt(A.querySelector(`.alert-mem[data-container="${d}"]`)?.value)||85,t=parseInt(A.querySelector(`.alert-cooldown[data-container="${d}"]`)?.value)||15,i=A.querySelector(`.alert-autorestart[data-container="${d}"]`)?.checked||!1;try{const p=await(await secureFetch(`/api/v1/monitoring/alerts/${d}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:a,cpuThreshold:n,memoryThreshold:e,cooldownMinutes:t,autoRestart:i})})).json();c.textContent=p.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{c.textContent="Save"},2e3)}catch{c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save"},2e3)}})})}function k(){P&&clearInterval(P),B?.checked&&(P=setInterval(z,DC.POLL.STATS))}function b(){P&&(clearInterval(P),P=null)}h?.addEventListener("click",()=>{y.classList.add("show"),z(),k()}),L?.addEventListener("click",()=>{y.classList.remove("show"),b()}),y?.addEventListener("click",m=>{m.target===y&&(y.classList.remove("show"),b())}),T?.addEventListener("click",z),B?.addEventListener("change",()=>{B.checked?k():b()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",S),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",E)})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
|
|
|
</div>`}I+="</div>",C.innerHTML=I,C.querySelectorAll(".alert-save-btn").forEach(c=>{c.addEventListener("click",async()=>{const l=c.dataset.container,a=C.querySelector(`.alert-enabled[data-container="${l}"]`)?.checked||!1,n=parseInt(C.querySelector(`.alert-cpu[data-container="${l}"]`)?.value)||80,e=parseInt(C.querySelector(`.alert-mem[data-container="${l}"]`)?.value)||85,t=parseInt(C.querySelector(`.alert-cooldown[data-container="${l}"]`)?.value)||15,s=C.querySelector(`.alert-autorestart[data-container="${l}"]`)?.checked||!1;try{const p=await(await secureFetch(`/api/v1/monitoring/alerts/${l}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:a,cpuThreshold:n,memoryThreshold:e,cooldownMinutes:t,autoRestart:s})})).json();c.textContent=p.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{c.textContent="Save"},2e3)}catch{c.textContent="\u274C Error",setTimeout(()=>{c.textContent="Save"},2e3)}})})}function H(){h&&clearInterval(h),S?.checked&&(h=setInterval(A,DC.POLL.STATS))}function L(){h&&(clearInterval(h),h=null)}E?.addEventListener("click",()=>{f.classList.add("show"),A(),H()}),M?.addEventListener("click",()=>{f.classList.remove("show"),L()}),f?.addEventListener("click",g=>{g.target===f&&(f.classList.remove("show"),L())}),k?.addEventListener("click",A),S?.addEventListener("change",()=>{S.checked?H():L()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",N),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",z)})(),(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">
|
|
|
|
@@ -1131,24 +1193,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="health-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),L=document.getElementById("health-cancel"),T=document.getElementById("health-refresh-btn"),B=document.getElementById("health-status-container"),N=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),$=document.getElementById("health-add-btn"),P=document.getElementById("health-config-form"),C=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save");let O=null;function z(c){return c>=99.9?"var(--ok-fg)":c>=95?"#f39c12":"var(--bad-fg)"}function S(c){const d={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: ${d[c]||"var(--muted)"}20; color: ${d[c]||"var(--muted)"};">${c}</span>`}async function E(){try{const d=await(await fetch("/api/v1/health-checks/status")).json();if(!d.success||!d.status||Object.keys(d.status).length===0){B.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 a=Object.values(d.status);let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',n+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',n+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',n+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const e of a){const t=e.status==="up",i=t?"var(--dot-ok)":"var(--dot-bad)",r=e.uptime?.["24h"]??"-",p=e.uptime?.["7d"]??"-",v=e.avgResponseTime!=null?Math.round(e.avgResponseTime)+"ms":"-",o=e.timestamp?timeAgo(e.timestamp):"-";n+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(e.serviceId)}">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${i}; margin-right: 6px;"></span>${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px; color: ${typeof r=="number"?z(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,n+=`<td style="padding: 8px; color: ${typeof p=="number"?z(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,n+=`<td style="padding: 8px;">${v}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${o}</td>`,n+="</tr>",n+=`<tr id="health-detail-${escapeHtml(e.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}n+="</table>",B.innerHTML=n,A.textContent="Updated "+new Date().toLocaleTimeString(),B.querySelectorAll("tr[data-health-id]").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.healthId,i=document.getElementById("health-detail-"+t);if(i){if(i.style.display!=="none"){i.style.display="none";return}i.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${t}/stats?hours=24`)).json();if(p.success&&p.stats){const v=p.stats,o=v.responseTime||{};i.querySelector("td").innerHTML=`
|
|
|
|
|
</div>`);const f=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),M=document.getElementById("health-cancel"),k=document.getElementById("health-refresh-btn"),S=document.getElementById("health-status-container"),D=document.getElementById("health-incidents-container"),B=document.getElementById("health-config-container"),C=document.getElementById("health-last-update"),m=document.getElementById("health-add-btn"),h=document.getElementById("health-config-form"),u=document.getElementById("health-form-title"),y=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save");let O=null;function A(c){return c>=99.9?"var(--ok-fg)":c>=95?"#f39c12":"var(--bad-fg)"}function N(c){const l={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: ${l[c]||"var(--muted)"}20; color: ${l[c]||"var(--muted)"};">${c}</span>`}async function z(){try{const l=await(await fetch("/api/v1/health-checks/status")).json();if(!l.success||!l.status||Object.keys(l.status).length===0){S.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 a=Object.values(l.status);let n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',n+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',n+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',n+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const e of a){const t=e.status==="up",s=t?"var(--dot-ok)":"var(--dot-bad)",r=e.uptime?.["24h"]??"-",p=e.uptime?.["7d"]??"-",b=e.avgResponseTime!=null?Math.round(e.avgResponseTime)+"ms":"-",o=e.timestamp?timeAgo(e.timestamp):"-";n+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(e.serviceId)}">`,n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${s}; margin-right: 6px;"></span>${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px; color: ${typeof r=="number"?A(r):"var(--muted)"};">${typeof r=="number"?r.toFixed(1)+"%":r}</td>`,n+=`<td style="padding: 8px; color: ${typeof p=="number"?A(p):"var(--muted)"};">${typeof p=="number"?p.toFixed(1)+"%":p}</td>`,n+=`<td style="padding: 8px;">${b}</td>`,n+=`<td style="padding: 8px; color: var(--muted);">${o}</td>`,n+="</tr>",n+=`<tr id="health-detail-${escapeHtml(e.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}n+="</table>",S.innerHTML=n,C.textContent="Updated "+new Date().toLocaleTimeString(),S.querySelectorAll("tr[data-health-id]").forEach(e=>{e.addEventListener("click",async()=>{const t=e.dataset.healthId,s=document.getElementById("health-detail-"+t);if(s){if(s.style.display!=="none"){s.style.display="none";return}s.style.display="";try{const p=await(await fetch(`/api/v1/health-checks/${t}/stats?hours=24`)).json();if(p.success&&p.stats){const b=p.stats,o=b.responseTime||{};s.querySelector("td").innerHTML=`
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
|
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${v.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${z(v.uptime||0)};">${(v.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${b.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${A(b.uptime||0)};">${(b.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(o.avg||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(o.p95||0)}ms / ${Math.round(o.p99||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(o.min||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(o.max||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${v.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${v.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else i.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){i.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(c){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(c.message)}</div>`}}async function k(){try{const[c,d]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),a=await c.json(),n=await d.json();let e="";const t=a.success&&a.incidents?a.incidents:[];if(t.length>0){e+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+t.length+")</h4>";for(const r of t)e+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${b.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${b.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else s.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(r){s.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(r.message)}</div>`}}})})}catch(c){S.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(c.message)}</div>`}}async function H(){try{const[c,l]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),a=await c.json(),n=await l.json();let e="";const t=a.success&&a.incidents?a.incidents:[];if(t.length>0){e+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+t.length+")</h4>";for(const r of t)e+=`<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(r.serviceId)}</span>
|
|
|
|
|
<span>${S(r.severity)}</span>
|
|
|
|
|
<span>${N(r.severity)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(r.message)}</div>
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(r.createdAt)} \xB7 ${r.occurrences||1} occurrence(s)</div>
|
|
|
|
|
</div>`;e+="</div>"}else e+='<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 i=n.success&&n.history?n.history:[];if(i.length>0){e+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',e+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of i){const p=r.status==="resolved",v=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,e+=`<td style="padding: 6px;">${S(r.severity)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,e+=`<td style="padding: 6px;">${v}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,e+="</tr>"}e+="</table>"}N.innerHTML=e||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(c){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function b(){try{const d=await(await fetch("/api/v1/health-checks/status")).json(),a=d.success&&d.status?Object.values(d.status):[];if(a.length===0){D.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 n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<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 e of a){const t=e.status==="up";n+='<tr style="border-bottom: 1px solid var(--border);">',n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px; color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px;">${e.sla?.target?e.sla.target+"%":"-"}</td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,n+="</td></tr>"}n+="</table>",D.innerHTML=n}catch(c){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}function m(c,d,a,n,e,t,i){O=c||null,C.textContent=c?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=c||"",document.getElementById("health-form-id").disabled=!!c,document.getElementById("health-form-name").value=d||"",document.getElementById("health-form-url").value=a||"",document.getElementById("health-form-timeout").value=n||1e4,document.getElementById("health-form-codes").value=e||"200",document.getElementById("health-form-sla").value=t||99.9,document.getElementById("health-form-slow").value=i||5e3,P.style.display="",$.style.display="none"}function f(){P.style.display="none",$.style.display="",O=null}$?.addEventListener("click",()=>m("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",f),x?.addEventListener("click",async()=>{const c=O||document.getElementById("health-form-id").value.trim();if(!c)return showNotification("Service ID is required","warning");const d=document.getElementById("health-form-url").value.trim();if(!d)return showNotification("URL is required","warning");const a=document.getElementById("health-form-codes").value.split(",").map(e=>parseInt(e.trim())).filter(Boolean),n={name:document.getElementById("health-form-name").value.trim()||c,url:d,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:a.length?a:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
|
|
|
|
</div>`;e+="</div>"}else e+='<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 s=n.success&&n.history?n.history:[];if(s.length>0){e+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',e+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const r of s){const p=r.status==="resolved",b=p&&r.duration?r.duration<6e4?Math.round(r.duration/1e3)+"s":Math.round(r.duration/6e4)+"m":"-";e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 6px;">${escapeHtml(r.serviceId)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(r.type)}</td>`,e+=`<td style="padding: 6px;">${N(r.severity)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${p?"var(--ok-fg)":"var(--bad-fg)"};">${r.status}</span></td>`,e+=`<td style="padding: 6px;">${b}</td>`,e+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(r.createdAt)}</td>`,e+="</tr>"}e+="</table>"}D.innerHTML=e||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(c){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function L(){try{const l=await(await fetch("/api/v1/health-checks/status")).json(),a=l.success&&l.status?Object.values(l.status):[];if(a.length===0){B.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 n='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';n+='<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 e of a){const t=e.status==="up";n+='<tr style="border-bottom: 1px solid var(--border);">',n+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(e.name||e.serviceId)}</td>`,n+=`<td style="padding: 8px; color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"Up":"Down"}</td>`,n+=`<td style="padding: 8px;">${e.sla?.target?e.sla.target+"%":"-"}</td>`,n+='<td style="padding: 8px; text-align: right;">',n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,n+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(e.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,n+="</td></tr>"}n+="</table>",B.innerHTML=n}catch(c){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}function g(c,l,a,n,e,t,s){O=c||null,u.textContent=c?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=c||"",document.getElementById("health-form-id").disabled=!!c,document.getElementById("health-form-name").value=l||"",document.getElementById("health-form-url").value=a||"",document.getElementById("health-form-timeout").value=n||1e4,document.getElementById("health-form-codes").value=e||"200",document.getElementById("health-form-sla").value=t||99.9,document.getElementById("health-form-slow").value=s||5e3,h.style.display="",m.style.display="none"}function I(){h.style.display="none",m.style.display="",O=null}m?.addEventListener("click",()=>g("","","",1e4,"200",99.9,5e3)),y?.addEventListener("click",I),x?.addEventListener("click",async()=>{const c=O||document.getElementById("health-form-id").value.trim();if(!c)return showNotification("Service ID is required","warning");const l=document.getElementById("health-form-url").value.trim();if(!l)return showNotification("URL is required","warning");const a=document.getElementById("health-form-codes").value.split(",").map(e=>parseInt(e.trim())).filter(Boolean),n={name:document.getElementById("health-form-name").value.trim()||c,url:l,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:a.length?a:[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">
|
|
|
|
@@ -1220,17 +1282,120 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="updates-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),L=document.getElementById("updates-cancel"),T=document.getElementById("updates-check-btn"),B=document.getElementById("updates-available-container"),N=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function $(){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)throw new Error(v.error);const o=v.updates||[];if(o.length===0){B.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="";return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';s+='<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 u of o)s+='<tr style="border-bottom: 1px solid var(--border);">',s+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(u.containerName)}</td>`,s+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(u.imageName)}</td>`,s+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(u.currentDigest)}</code></td>`,s+=`<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(u.latestDigest)}</code></td>`,s+='<td style="padding: 8px; text-align: right;">',s+=`<button class="update-now-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,s+=`<button class="rollback-btn" data-id="${escapeHtml(u.containerId)}" data-name="${escapeHtml(u.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,s+="</td></tr>";s+="</table>",B.innerHTML=s,A.textContent=o.length+" update(s) available",B.querySelectorAll(".update-now-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Update "${g}" to the latest version? The container will restart.`)){u.textContent="Updating...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(w.success)u.textContent="Done!",u.style.background="var(--ok-fg)",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Update failed")}catch(I){u.textContent="Failed",u.style.color="var(--bad-fg)",showNotification("Update error: "+I.message,"error"),setTimeout(()=>{u.textContent="Update",u.disabled=!1,u.style.color="",u.style.background=""},3e3)}}})}),B.querySelectorAll(".rollback-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.dataset.name;if(confirm(`Rollback "${g}" to its previous version?`)){u.textContent="Rolling back...",u.disabled=!0;try{const w=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(l)}`,{method:"POST"})).json();if(w.success)u.textContent="Rolled back!",setTimeout(()=>$(),2e3);else throw new Error(w.error||"Rollback failed")}catch(I){u.textContent="Failed",showNotification("Rollback error: "+I.message,"error"),setTimeout(()=>{u.textContent="Rollback",u.disabled=!1},3e3)}}})})}catch(p){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function P(){T.textContent="\u{1F50D} Checking...",T.disabled=!0;try{const v=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!v.success)throw new Error(v.error);T.textContent="\u2705 Done!",await $()}catch(p){T.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{T.textContent="\u{1F50D} Check for Updates",T.disabled=!1},3e3)}async function C(){try{N.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const v=await(await fetch("/api/v1/updates/history?limit=50")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';s+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(g)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
<option value="">Disabled</option>
|
|
|
|
|
<option value="daily">Daily</option>
|
|
|
|
|
<option value="weekly">Weekly</option>
|
|
|
|
|
<option value="monthly">Monthly</option>
|
|
|
|
|
</select></td>`,s+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(g)}" checked /></td>`,s+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(g)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,s+="</tr>"}s+="</table>",D.innerHTML=s,D.querySelectorAll(".save-auto-btn").forEach(u=>{u.addEventListener("click",async()=>{const l=u.dataset.id,g=u.closest("tr"),I=g.querySelector(".auto-schedule").value,w=g.querySelector(".auto-rollback").checked;u.textContent="Saving...",u.disabled=!0;try{const R=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:w})})).json();if(R.success)u.textContent="\u2713 Saved";else throw new Error(R.error)}catch(M){u.textContent="\u2717 Error",showNotification("Save error: "+M.message,"error")}setTimeout(()=>{u.textContent="Save",u.disabled=!1},2e3)})})}catch(p){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const x=document.getElementById("dashcaddy-current-version"),O=document.getElementById("dashcaddy-update-badge"),z=document.getElementById("dashcaddy-update-details"),S=document.getElementById("dashcaddy-new-version"),E=document.getElementById("dashcaddy-changelog"),k=document.getElementById("dashcaddy-apply-btn"),b=document.getElementById("dashcaddy-check-btn"),m=document.getElementById("dashcaddy-rollback-btn"),f=document.getElementById("dashcaddy-status-bar"),c=document.getElementById("dashcaddy-history-container");let d=null;function a(p,v){f&&(f.style.display="block",f.style.background=v==="error"?"var(--bad-bg)":v==="success"?"var(--ok-bg)":"var(--bg)",f.style.color=v==="error"?"var(--bad-fg)":v==="success"?"var(--ok-fg)":"var(--fg)",f.textContent=p)}async function n(){try{const v=await(await fetch("/api/v1/system/version")).json();v.success&&(x.textContent="v"+v.version+(v.commit?" ("+v.commit.substring(0,7)+")":""))}catch{x.textContent="Unable to fetch version"}}async function e(p){p||(b.textContent="Checking...",b.disabled=!0);try{const o=await(await fetch("/api/v1/system/update-check")).json();if(d=o,o.success&&o.available&&o.remote){O.style.display="",z.style.display="",S.textContent="v"+o.remote.version,E.textContent=o.remote.changelog||"No changelog available.";const s=document.getElementById("updates-btn");if(s&&!s.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",s.style.position="relative",s.appendChild(l)}const u=document.getElementById("updates-dashcaddy-tab");if(u&&!u.querySelector(".update-dot")){const l=document.createElement("span");l.className="update-dot",l.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",u.appendChild(l)}}else O.style.display="none",z.style.display="none",p||a("You are running the latest version.","success");p||(b.textContent="Check for Updates",b.disabled=!1)}catch(v){p||(a("Failed to check: "+v.message,"error"),b.textContent="Check for Updates",b.disabled=!1)}}async function t(){if(confirm("Apply DashCaddy update? The API container will restart.")){k.textContent="Updating...",k.disabled=!0,a("Downloading and applying update...","info");try{const v=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(v.success)a("Update initiated: v"+(v.fromVersion||"?")+" \u2192 v"+(v.toVersion||"?")+". The container will restart shortly.","success"),k.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(o=>o.remove());else throw new Error(v.error||"Update failed")}catch(p){a("Update failed: "+p.message,"error"),k.textContent="Update Now",k.disabled=!1}}}async function i(){try{const v=await(await fetch("/api/v1/system/update-history")).json(),o=v.success&&v.history?v.history:[];if(o.length===0){c.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let s='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';s+='<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;">Version</th><th style="padding: 6px; text-align: left;">From</th><th style="padding: 6px; text-align: left;">Status</th></tr>';for(const u of o){const l=u.status==="success"?"\u2713 success":u.status==="pending"?"\u23F3 pending":u.status==="partial"?"\u26A0 partial":"\u2717 "+u.status,g=u.status==="success"?"var(--ok-fg)":u.status==="pending"?"var(--muted)":"var(--bad-fg)";s+='<tr style="border-bottom: 1px solid var(--border);">',s+='<td styLine truncated
|
|
|
|
|
</div>`);const f=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),M=document.getElementById("updates-cancel"),k=document.getElementById("updates-check-btn"),S=document.getElementById("updates-available-container"),D=document.getElementById("updates-history-container"),B=document.getElementById("updates-auto-container"),C=document.getElementById("updates-last-check");async function m(){try{const b=await(await fetch("/api/v1/updates/available")).json();if(!b.success)throw new Error(b.error);const o=b.updates||[];if(o.length===0){S.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',C.textContent="";return}let i='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';i+='<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 v of o)i+='<tr style="border-bottom: 1px solid var(--border);">',i+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(v.containerName)}</td>`,i+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(v.imageName)}</td>`,i+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(v.currentDigest)}</code></td>`,i+=`<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(v.latestDigest)}</code></td>`,i+='<td style="padding: 8px; text-align: right;">',i+=`<button class="update-now-btn" data-id="${escapeHtml(v.containerId)}" data-name="${escapeHtml(v.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,i+=`<button class="rollback-btn" data-id="${escapeHtml(v.containerId)}" data-name="${escapeHtml(v.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,i+="</td></tr>";i+="</table>",S.innerHTML=i,C.textContent=o.length+" update(s) available",S.querySelectorAll(".update-now-btn").forEach(v=>{v.addEventListener("click",async()=>{const d=v.dataset.id,w=v.dataset.name;if(confirm(`Update "${w}" to the latest version? The container will restart.`)){v.textContent="Updating...",v.disabled=!0;try{const $=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if($.success)v.textContent="Done!",v.style.background="var(--ok-fg)",setTimeout(()=>m(),2e3);else throw new Error($.error||"Update failed")}catch(T){v.textContent="Failed",v.style.color="var(--bad-fg)",showNotification("Update error: "+T.message,"error"),setTimeout(()=>{v.textContent="Update",v.disabled=!1,v.style.color="",v.style.background=""},3e3)}}})}),S.querySelectorAll(".rollback-btn").forEach(v=>{v.addEventListener("click",async()=>{const d=v.dataset.id,w=v.dataset.name;if(confirm(`Rollback "${w}" to its previous version?`)){v.textContent="Rolling back...",v.disabled=!0;try{const $=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(d)}`,{method:"POST"})).json();if($.success)v.textContent="Rolled back!",setTimeout(()=>m(),2e3);else throw new Error($.error||"Rollback failed")}catch(T){v.textContent="Failed",showNotification("Rollback error: "+T.message,"error"),setTimeout(()=>{v.textContent="Rollback",v.disabled=!1},3e3)}}})})}catch(p){S.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function h(){k.textContent="\u{1F50D} Checking...",k.disabled=!0;try{const b=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!b.success)throw new Error(b.error);k.textContent="\u2705 Done!",await m()}catch(p){k.textContent="\u274C Failed",showNotification("Check error: "+p.message,"error")}setTimeout(()=>{k.textContent="\u{1F50D} Check for Updates",k.disabled=!1},3e3)}async function u(){try{D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const b=await(await fetch("/api/v1/updates/history?limit=50")).json(),o=b.success&&b.history?b.history:[];if(o.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let i='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';i+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">Container</th><th style="padding: 6px; text-align: left;">Image</th><th style="padding: 6px; text-aligLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(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>`,w+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(P)}" value="${escapeHtml(J)}" 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>`,w+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(P)}"${_?" checked":""} /></td>`,w+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${F}</td>`,w+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(P)}" data-name="${escapeHtml($)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,w+="</tr>"}w+="</table>",B.innerHTML=w,B.querySelectorAll(".save-auto-btn").forEach(T=>{T.addEventListener("click",async()=>{const $=T.dataset.id,P=T.closest("tr"),R=P.querySelector(".auto-schedule").value,U=P.querySelector(".auto-rollback").checked,_=P.querySelector(".auto-window").value.trim();T.textContent="Saving...",T.disabled=!0;try{const F=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent($)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!R,schedule:R||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(F.success)T.textContent="\u2713 Saved";else throw new Error(F.error)}catch(J){T.textContent="\u2717 Error",showNotification("Save error: "+J.message,"error")}setTimeout(()=>{T.textContent="Save",T.disabled=!1},2e3)})})}catch(p){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const x=document.getElementById("dashcaddy-current-version"),O=document.getElementById("dashcaddy-update-badge"),A=document.getElementById("dashcaddy-update-details"),N=document.getElementById("dashcaddy-new-version"),z=document.getElementById("dashcaddy-changelog"),H=document.getElementById("dashcaddy-apply-btn"),L=document.getElementById("dashcaddy-check-btn"),g=document.getElementById("dashcaddy-rollback-btn"),I=document.getElementById("dashcaddy-status-bar"),c=document.getElementById("dashcaddy-history-container");let l=null;function a(p,b){I&&(I.style.display="block",I.style.background=b==="error"?"var(--bad-bg)":b==="success"?"var(--ok-bg)":"var(--bg)",I.style.color=b==="error"?"var(--bad-fg)":b==="success"?"var(--ok-fg)":"var(--fg)",I.textContent=p)}async function n(){try{const b=await(await fetch("/api/v1/system/version")).json();b.success&&(x.textContent="v"+b.version+(b.commit?" ("+b.commit.substring(0,7)+")":""))}catch{x.textContent="Unable to fetch version"}}async function e(p){p||(L.textContent="Checking...",L.disabled=!0);try{const o=await(await fetch("/api/v1/system/update-check")).json();if(l=o,o.success&&o.available&&o.remote){O.style.display="",A.style.display="",N.textContent="v"+o.remote.version,z.textContent=o.remote.changelog||"No changelog available.";const i=document.getElementById("updates-btn");if(i&&!i.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",i.style.position="relative",i.appendChild(d)}const v=document.getElementById("updates-dashcaddy-tab");if(v&&!v.querySelector(".update-dot")){const d=document.createElement("span");d.className="update-dot",d.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",v.appendChild(d)}}else O.style.display="none",A.style.display="none",p||a("You are running the latest version.","success");p||(L.textContent="Check for Updates",L.disabled=!1)}catch(b){p||(a("Failed to check: "+b.message,"error"),L.textContent="Check for Updates",L.disabled=!1)}}async function t(){if(confirm("Apply DashCaddy update? The API container will restart.")){H.textContent="Updating...",H.disabled=!0,a("Downloading and applying update...","info");try{const b=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(b.success)a("Update initiated: v"+(b.fromVersion||"?")+" \u2192 v"+(b.toVersion||"?")+". The container will restart shortly.","success"),H.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(o=>o.remove());else throw new Error(b.error||"Update failed")}catch(p){a("Update failed: "+p.message,"error"),H.textContent="Update Now",H.disabled=!1}}}async function s(){try{const b=await(await fetch("/api/v1/system/update-history")).json(),o=b.success&&b.history?b.history:[];if(o.length===0){c.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let i='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';i+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="paLine truncated
|
|
|
|
|
`+o.join(`
|
|
|
|
|
`)+`
|
|
|
|
|
|
|
|
|
|
Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification("Invalid version: "+s,"error");return}if(!confirm("Rollback DashCaddy to v"+s+"? The container will restart."))return;a("Rolling back to v"+s+"...","info");const l=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:s})})).json();if(l.success)a("Rollback to v"+s+" initiated. Container will restart.","success");else throw new Error(l.error||"Rollback failed")}catch(p){a("Rollback failed: "+p.message,"error")}}b?.addEventListener("click",()=>e(!1)),k?.addEventListener("click",t),m?.addEventListener("click",r),T?.addEventListener("click",P),h?.addEventListener("click",()=>{y?.classList.add("show"),$()}),wireModal(y,L),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",C),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{n(),i(),d||e(!0)}),setTimeout(()=>e(!0),5e3)})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
|
|
|
Enter version to rollback to:`);if(!i)return;if(!o.includes(i)){showNotification("Invalid version: "+i,"error");return}if(!confirm("Rollback DashCaddy to v"+i+"? The container will restart."))return;a("Rolling back to v"+i+"...","info");const d=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:i})})).json();if(d.success)a("Rollback to v"+i+" initiated. Container will restart.","success");else throw new Error(d.error||"Rollback failed")}catch(p){a("Rollback failed: "+p.message,"error")}}L?.addEventListener("click",()=>e(!1)),H?.addEventListener("click",t),g?.addEventListener("click",r),k?.addEventListener("click",h),E?.addEventListener("click",()=>{f?.classList.add("show"),m()}),wireModal(f,M),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",u),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",y),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{n(),s(),l||e(!0)}),setTimeout(()=>e(!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>
|
|
|
|
|
|
|
|
|
|
<div class="panel-tabs">
|
|
|
|
|
<button class="panel-tab active" data-panel="dr-volumes">Volumes</button>
|
|
|
|
|
<button class="panel-tab" data-panel="dr-networks">Networks</button>
|
|
|
|
|
<button class="panel-tab" data-panel="dr-disk">Disk Usage</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Volumes -->
|
|
|
|
|
<div id="dr-volumes" class="panel-section active">
|
|
|
|
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
|
|
|
|
<input type="text" id="dr-vol-name" placeholder="Volume name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
|
|
|
<button id="dr-vol-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="dr-vol-list" class="scroll-container" style="max-height: 400px;">
|
|
|
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Networks -->
|
|
|
|
|
<div id="dr-networks" class="panel-section">
|
|
|
|
|
<div style="display: flex; gap: 8px; margin-bottom: 12px;">
|
|
|
|
|
<input type="text" id="dr-net-name" placeholder="Network name" style="flex: 1; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
|
|
|
<select id="dr-net-driver" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);">
|
|
|
|
|
<option value="bridge">bridge</option>
|
|
|
|
|
<option value="overlay">overlay</option>
|
|
|
|
|
<option value="host">host</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button id="dr-net-create" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.82rem;">Create</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="dr-net-list" class="scroll-container" style="max-height: 400px;">
|
|
|
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Disk Usage -->
|
|
|
|
|
<div id="dr-disk" class="panel-section">
|
|
|
|
|
<div id="dr-disk-content">
|
|
|
|
|
<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
|
|
|
<button id="dr-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const f=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),M=document.getElementById("dr-close");function k(C){if(!C||C===0)return"0 B";const m=["B","KB","MB","GB","TB"],h=Math.floor(Math.log(Math.abs(C))/Math.log(1024));return(C/Math.pow(1024,h)).toFixed(1)+" "+m[h]}async function S(){const C=document.getElementById("dr-vol-list");try{const h=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(h.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let u='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 y of h){const x=y.name==="buildkit"||y.name.length===64;u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(y.name)}">${escapeHtml(y.name.length>40?y.name.substring(0,37)+"...":y.name)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.driver)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.scope)}</td>`,u+='<td style="padding: 6px; text-align: right;">',x||(u+=`<button class="dr-vol-del" data-name="${escapeHtml(y.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),u+="</td></tr>"}u+="</table>",C.innerHTML=u,C.querySelectorAll(".dr-vol-del").forEach(y=>{y.addEventListener("click",async()=>{if(confirm(`Delete volume "${y.dataset.name}"? Data will be lost.`)){y.textContent="...",y.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(y.dataset.name)}?force=true`),S()}catch(x){showNotification("Delete failed: "+x.message,"error"),y.textContent="Delete",y.disabled=!1}}})})}catch(m){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const C=document.getElementById("dr-vol-name"),m=C.value.trim();if(!m){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:m}),C.value="",showNotification(`Volume "${m}" created`,"success"),S()}catch(h){showNotification("Create failed: "+h.message,"error")}});async function D(){const C=document.getElementById("dr-net-list");try{const h=(await getJSON("/api/v1/docker/networks")).networks||[];if(h.length===0){C.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let u='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';u+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 y of h){const x=["bridge","host","none"].includes(y.name);u+='<tr style="border-bottom: 1px solid var(--border);">',u+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(y.name)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.driver)}</td>`,u+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(y.scope)}</td>`,u+=`<td style="padding: 6px; text-align: center;">${y.containers}</td>`,u+='<td style="padding: 6px; text-align: right;">',x||(u+=`<button class="dr-net-del" data-id="${escapeHtml(y.id)}" data-name="${escapeHtml(y.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),u+="</td></tr>"}u+="</table>",C.innerHTML=u,C.querySelectorAll(".dr-net-del").forEach(y=>{y.addEventListener("click",async()=>{if(confirm(`Delete network "${y.dataset.name}"?`)){y.textContent="...",y.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(y.dataset.id)}`),D()}catch(x){showNotification("Delete failed: "+x.message,"error"),y.textContent="Delete",y.disabled=!1}}})})}catch(m){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const C=document.getElementById("dr-net-name"),m=document.getElementById("dr-net-driver"),h=C.value.trim();if(!h){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:h,driver:m.value}),C.value="",showNotification(`Network "${h}" created`,"success"),D()}catch(u){showNotification("Create failed: "+u.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>
|
|
|
|
|
|
|
|
|
|
<!-- Step 1: Paste YAML -->
|
|
|
|
|
<div id="compose-step-paste">
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<label class="form-label-accent-sm">Stack Name</label>
|
|
|
|
|
<input type="text" id="compose-stack-name" placeholder="my-stack" value="" style="width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg);" />
|
|
|
|
|
</div>
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<label class="form-label-accent-sm">docker-compose.yml</label>
|
|
|
|
|
<textarea id="compose-yaml" rows="14" placeholder="version: '3' services: web: image: nginx:latest ports: - '8080:80'" style="width: 100%; padding: 10px; font-family: monospace; font-size: 0.82rem; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); resize: vertical;"></textarea>
|
|
|
|
|
<div style="margin-top: 6px;">
|
|
|
|
|
<label style="display: inline-flex; align-items: center; gap: 6px; cursor: pointer; font-size: 0.82rem; color: var(--muted);">
|
|
|
|
|
<input type="file" id="compose-file-upload" accept=".yml,.yaml" style="display: none;" />
|
|
|
|
|
<span style="text-decoration: underline;">or upload a file</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
|
|
|
<button id="compose-parse-btn" class="btn-accent-solid" style="padding: 8px 20px;">Parse & Preview</button>
|
|
|
|
|
<button id="compose-cancel">Cancel</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Step 2: Preview -->
|
|
|
|
|
<div id="compose-step-preview" style="display: none;">
|
|
|
|
|
<div id="compose-preview-content"></div>
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;">
|
|
|
|
|
<button id="compose-deploy-btn" class="btn-accent-solid" style="padding: 8px 20px;">Deploy All</button>
|
|
|
|
|
<button id="compose-back-btn">Back</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Step 3: Progress -->
|
|
|
|
|
<div id="compose-step-progress" style="display: none;">
|
|
|
|
|
<div id="compose-progress-content"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const f=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),M=document.getElementById("compose-cancel");wireModal(f,M);let k=null;function S(B){document.getElementById("compose-step-paste").style.display=B==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=B==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=B==="progress"?"":"none"}E?.addEventListener("click",()=>{S("paste"),k=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",f?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",B=>{const C=B.target.files[0];if(!C)return;const m=new FileReader;m.onload=()=>{document.getElementById("compose-yaml").value=m.result},m.readAsText(C)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const B=document.getElementById("compose-yaml").value.trim(),C=document.getElementById("compose-stack-name").value.trim()||"stack";if(!B){showNotification("Paste a docker-compose.yml","warning");return}const m=document.getElementById("compose-parse-btn"),h=m.textContent;m.textContent="Parsing...",m.disabled=!0;try{const u=await postJSON("/api/v1/apps/import-compose",{yaml:B,stackName:C});k=u,k.stackName=C,D(u),S("preview")}catch(u){showNotification("Parse failed: "+u.message,"error")}finally{m.textContent=h,m.disabled=!1}});function D(B){const C=document.getElementById("compose-preview-content");let m="";B.networks&&B.networks.length>0&&(m+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${B.networks.map(h=>`<code>${escapeHtml(h)}</code>`).join(", ")}</div>`),B.volumes&&B.volumes.length>0&&(m+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${B.volumes.map(h=>`<code>${escapeHtml(h)}</code>`).join(", ")}</div>`),m+=`<div style="font-weight: 600; margin-bottom: 8px;">${B.services.length} service(s)</div>`,m+='<div class="scroll-container" style="max-height: 350px;">';for(const h of B.services){const u=h.skip?"var(--bad-fg)":"var(--border)";if(m+=`<div style="padding: 10px 14px; border: 1px solid ${u}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,m+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(h.name)}`,h.skip&&(m+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(h.reason)}</span>`),m+="</div>",!h.skip&&(m+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(h.image)}</code></div>`,h.ports?.length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${h.ports.map(y=>`${y.host}:${y.container}`).join(", ")}</div>`),h.volumes?.length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${h.volumes.length}</div>`),Object.keys(h.environment||{}).length&&(m+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(h.environment).length}</div>`),h.envFileWarning&&(m+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(h.envFileWarning)}</div>`),h.resources?.cpus||h.resources?.memory)){const y=[];h.resources.cpus&&y.push(`CPU: ${h.resources.cpus}`),h.resources.memory&&y.push(`Mem: ${h.resources.memory}MB`),m+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${y.join(", ")}</div>`}m+="</div>"}m+="</div>",C.innerHTML=m}document.getElementById("compose-back-btn")?.addEventListener("click",()=>S("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!k)return;const B=document.getElementById("compose-deploy-btn");B.textContent="Deploying...",B.disabled=!0,S("progress");const C=document.getElementById("compose-progress-content");C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const m=await postJSON("/api/v1/apps/deploy-compose",{services:k.services,networks:k.networks,stackName:k.stackName});let h=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(m.stackName)}" \u2014 Deployment Complete</div>`;h+='<div class="scroll-container" style="max-height: 350px;">';for(const u of m.results){const y=u.status==="deployed"||u.status==="created"?"\u2705":u.status==="exists"?"\u26A1":u.status==="skipped"?"\u23ED":"\u274C";h+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',h+=`${y} <strong>${escapeHtml(u.name)}</strong> (${u.type}) \u2014 ${escapeHtml(u.status)}`,u.error&&(h+=` <span style="color: var(--bad-fg);">${escapeHtml(u.error)}</span>`),u.subdomain&&(h+=` \u2192 <code>${escapeHtml(u.subdomain)}</code>`),u.reason&&(h+=` <span style="color: var(--muted);">(${escapeHtml(u.reason)})</span>`),h+="</div>"}h+="</div>",h+='<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",()=>S("paste"))}finally{B.textContent="Deploy All",B.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>
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 8px; padding-bottom: 12px;">
|
|
|
|
|
<button id="exec-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const f=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),M=document.getElementById("exec-close");let k=null,S=null,D=null;function B(){if(S){try{S.close()}catch{}S=null}if(k){try{k.dispose()}catch{}k=null}D=null,E.innerHTML=""}function C(m,h){if(B(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${h||m}`,f?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}k=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(D=new FitAddon.FitAddon,k.loadAddon(D)),k.open(E),D&&setTimeout(()=>D.fit(),50);const u=location.protocol==="https:"?"wss:":"ws:";S=new WebSocket(`${u}//${location.host}/ws/exec/${encodeURIComponent(m)}`),S.binaryType="arraybuffer",S.onopen=()=>{if(k.writeln("\x1B[32mConnecting...\x1B[0m"),D){const x=D.proposeDimensions();x&&S.send(JSON.stringify({type:"resize",cols:x.cols,rows:x.rows}))}},S.onmessage=x=>{if(typeof x.data=="string"){try{const O=JSON.parse(x.data);if(O.type==="connected"){k.writeln(`\x1B[32mConnected (${O.shell})\x1B[0m\r
|
|
|
|
|
`);return}if(O.type==="error"){k.writeln(`\x1B[31mError: ${O.message}\x1B[0m`);return}if(O.type==="exit"){k.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}k.write(x.data)}else k.write(new Uint8Array(x.data))},S.onclose=()=>{k&&k.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},S.onerror=()=>{k&&k.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},k.onData(x=>{S&&S.readyState===WebSocket.OPEN&&S.send(x)}),k.onResize(({cols:x,rows:O})=>{S&&S.readyState===WebSocket.OPEN&&S.send(JSON.stringify({type:"resize",cols:x,rows:O}))});const y=()=>{D&&D.fit()};window.addEventListener("resize",y),f._resizeHandler=y}M?.addEventListener("click",()=>{B(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show")}),f?.addEventListener("click",m=>{m.target===f&&(B(),f._resizeHandler&&window.removeEventListener("resize",f._resizeHandler),f?.classList.remove("show"))}),window.openExecModal=C})(),(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">
|
|
|
|
@@ -1266,7 +1431,7 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
|
|
|
|
|
<button id="audit-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),L=document.getElementById("audit-cancel"),T=document.getElementById("audit-refresh-btn"),B=document.getElementById("audit-clear-btn"),N=document.getElementById("audit-filter"),D=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let $=0;const P=50;async function C(H){try{H||($=0,D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const x=N.value;let O=`/api/v1/audit-logs?limit=${P}&offset=${$}`;x&&(O+=`&action=${encodeURIComponent(x)}`);const S=await(await fetch(O)).json(),E=S.success&&S.entries?S.entries:[];if(E.length===0&&!H){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let k="";H||(k='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',k+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 E){const m=b.outcome==="success";k+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',k+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(b.timestamp)}</td>`,k+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(b.ip||"-")}</td>`,k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(b.action||"-")}</td>`,k+=`<td style="padding: 6px;">${escapeHtml(b.resource||"-")}</td>`,k+=`<td style="padding: 6px;"><span style="color: ${m?"var(--ok-fg)":"var(--bad-fg)"};">${m?"\u2713":"\u2717"}</span></td>`,k+="</tr>",b.details&&Object.keys(b.details).length>0&&(k+=`<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(!H)k+="</table>",D.innerHTML=k;else{const b=D.querySelector("table");b&&b.insertAdjacentHTML("beforeend",k)}$+=E.length,A.style.display=E.length>=P?"":"none",D.querySelectorAll(".audit-row").forEach(b=>{b.dataset.wired||(b.dataset.wired="true",b.addEventListener("click",()=>{const m=b.nextElementSibling;m&&m.classList.contains("audit-detail")&&(m.style.display=m.style.display==="none"?"":"none")}))})}catch(x){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}h?.addEventListener("click",()=>{y?.classList.add("show"),C(!1)}),wireModal(y,L),T?.addEventListener("click",()=>C(!1)),N?.addEventListener("change",()=>C(!1)),A?.addEventListener("click",()=>C(!0)),B?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const x=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();x.success?C(!1):showNotification("Error: "+(x.error||"Clear failed"),"error")}catch(H){showNotification("Error: "+H.message,"error")}})})(),(function(){injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
</div>`);const f=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),M=document.getElementById("audit-cancel"),k=document.getElementById("audit-refresh-btn"),S=document.getElementById("audit-clear-btn"),D=document.getElementById("audit-filter"),B=document.getElementById("audit-log-container"),C=document.getElementById("audit-load-more");let m=0;const h=50;async function u(y){try{y||(m=0,B.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const x=D.value;let O=`/api/v1/audit-logs?limit=${h}&offset=${m}`;x&&(O+=`&action=${encodeURIComponent(x)}`);const N=await(await fetch(O)).json(),z=N.success&&N.entries?N.entries:[];if(z.length===0&&!y){B.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',C.style.display="none";return}let H="";y||(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 L of z){const g=L.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(L.timestamp)}</td>`,H+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(L.ip||"-")}</td>`,H+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(L.action||"-")}</td>`,H+=`<td style="padding: 6px;">${escapeHtml(L.resource||"-")}</td>`,H+=`<td style="padding: 6px;"><span style="color: ${g?"var(--ok-fg)":"var(--bad-fg)"};">${g?"\u2713":"\u2717"}</span></td>`,H+="</tr>",L.details&&Object.keys(L.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(L.details,null,2))}</pre></td></tr>`)}if(!y)H+="</table>",B.innerHTML=H;else{const L=B.querySelector("table");L&&L.insertAdjacentHTML("beforeend",H)}m+=z.length,C.style.display=z.length>=h?"":"none",B.querySelectorAll(".audit-row").forEach(L=>{L.dataset.wired||(L.dataset.wired="true",L.addEventListener("click",()=>{const g=L.nextElementSibling;g&&g.classList.contains("audit-detail")&&(g.style.display=g.style.display==="none"?"":"none")}))})}catch(x){B.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(x.message)}</div>`}}E?.addEventListener("click",()=>{f?.classList.add("show"),u(!1)}),wireModal(f,M),k?.addEventListener("click",()=>u(!1)),D?.addEventListener("change",()=>u(!1)),C?.addEventListener("click",()=>u(!0)),S?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const x=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();x.success?u(!1):showNotification("Error: "+(x.error||"Clear failed"),"error")}catch(y){showNotification("Error: "+y.message,"error")}})})(),(function(){injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
<label for="weather-location-input">Location:</label>
|
|
|
|
|
<input type="text" id="weather-location-input" placeholder="City name or ZIP (e.g., Hamburg, 90210)" maxlength="100">
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted); margin-top: 4px;">Enter a city name, postal code, or “City, Country”</div>
|
|
|
|
@@ -1277,15 +1442,15 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
|
|
|
|
|
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const y="weather-location",h="weather-zip",L="weather-geo",T="weather-unit";!safeGet(y)&&safeGet(h)&&safeSet(y,safeGet(h));function B(){return safeGet(T)||"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"},A={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"},$=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function P(E){return $[Math.round(E/22.5)%16]}async function C(E){const k=safeGet(L);if(k)try{const d=JSON.parse(k);if(d.query===E)return d}catch{}const b=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(E)}&count=1&language=en&format=json`);if(!b.ok)throw new Error("Geocoding failed");const m=await b.json();if(!m.results||!m.results.length)throw new Error("Location not found");const f=m.results[0],c={query:E,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(L,JSON.stringify(c)),c}function H(E){return E.countryCode==="US"&&E.state?`${E.city}, ${E.state}`:E.country?`${E.city}, ${E.country}`:E.city}async function x(E){try{const k=await C(E),b=B(),m=b==="metric"?"celsius":"fahrenheit",f=b==="metric"?"kmh":"mph",c=`https://api.open-meteo.com/v1/forecast?latitude=${k.lat}&longitude=${k.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${m}&wind_speed_unit=${f}`,d=await fetch(c);if(!d.ok)throw new Error("Weather fetch failed");const n=(await d.json()).current,e=n.weather_code;return{temp:Math.round(n.temperature_2m),condition:D[e]||"Unknown",icon:A[e]||"\u{1F324}\uFE0F",locationStr:H(k),windSpeed:Math.round(n.wind_speed_10m),windDir:P(n.wind_direction_10m),unit:b}}catch(k){return console.warn("Weather fetch failed:",k),null}}async function O(){const E=N();if(!E.icon||!E.temp||!E.condition||!E.location||!E.wind){console.warn("Weather widget elements not found");return}const k=safeGet(y);if(!k){E.location.textContent="Set Location",E.temp.textContent="--\xB0",E.condition.textContent="Click \u2699\uFE0F to configure",E.wind.textContent="--",E.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const b=await x(k);if(b){const m=b.unit==="metric"?"\xB0C":"\xB0F",f=b.unit==="metric"?"km/h":"mph";E.location.textContent=b.locationStr,E.temp.textContent=`${b.temp}${m}`,E.condition.textContent=b.condition,E.wind.textContent=`Wind: ${b.windSpeed} ${f} ${b.windDir}`,E.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(b.icon)}</span>`}}catch(b){console.error("Weather update error:",b),E.location.textContent="Weather Error",E.temp.textContent="Error",E.condition.textContent="Failed to load",E.wind.textContent="--"}}const z=document.getElementById("weather-modal"),S=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{S.value=safeGet(y)||"";const E=B(),k=z.querySelector(`input[name="weather-unit-radio"][value="${E}"]`);k&&(k.checked=!0),z.classList.add("show"),S.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{z.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const E=S.value.trim();if(E){safeGet(y)!==E&&safeSet(L,""),safeSet(y,E);const b=z.querySelector('input[name="weather-unit-radio"]:checked'),m=b?b.value:"imperial",f=B();safeSet(T,m),f!==m&&safeSet(L,""),z.classList.remove("show"),O()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(z),document.addEventListener("keydown",E=>{E.key==="Escape"&&z.classList.Line truncated
|
|
|
|
|
<circle cx="${w}" cy="${M}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${j}
|
|
|
|
|
${W}
|
|
|
|
|
<line x1="${w}" y1="${M}" x2="${w+28*Math.cos(_*Math.PI/180)}" y2="${M+28*Math.sin(_*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${w}" y1="${M}" x2="${w+38*Math.cos(q*Math.PI/180)}" y2="${M+38*Math.sin(q*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${w}" y1="${M}" x2="${w+42*Math.cos(R*Math.PI/180)}" y2="${M+42*Math.sin(R*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${w}" cy="${M}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,se=o.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${V}<div class="analog-info"><span class="analog-digital">${o.getHours()%12||12}:${String(l).padStart(2,"0")} ${se}</span><span class="analog-date-sm">${O(o)}</span></div></div>`,P="analog"}function d(){const o=new Date,s=o.getHours()%12||12,u=o.getMinutes(),l=o.getSeconds(),g="clock-widget"+(N!=="default"?" "+N:"");switch(y.className!==g&&(y.className=g),N){case"lcd":k(o);break;case"lcd-blue":k(o);break;case"lcd-amber":k(o);break;case"lcd-retro":k(o);break;case"lcd-taxi":k(o);break;case"flip":b(o);break;case"binary":f(o);break;case"analog":c(o,!1);break;case"roman":c(o,!0);break;default:E(o)}u===0&&l===0&&s!==D&&(D=s,x(s)),u!==0&&(D=-1)}function a(){clearTimeout(H);const o=document.hidden?6e4:1e3,s=o-Date.now()%o+25;H=setTimeout(()=>{d(),a()},s)}document.addEventListener("visibilitychange",()=>{$="",z(),d(),a()}),d(),a();const n=[{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 e='<div class="clock-style-grid">';n.forEach(o=>{e+=`<label class="clock-style-option">
|
|
|
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const f="weather-location",E="weather-zip",M="weather-geo",k="weather-unit";!safeGet(f)&&safeGet(E)&&safeSet(f,safeGet(E));function S(){return safeGet(k)||"imperial"}function D(){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 B={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"},C={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"},m=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function h(z){return m[Math.round(z/22.5)%16]}async function u(z){const H=safeGet(M);if(H)try{const l=JSON.parse(H);if(l.query===z)return l}catch{}const L=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(z)}&count=1&language=en&format=json`);if(!L.ok)throw new Error("Geocoding failed");const g=await L.json();if(!g.results||!g.results.length)throw new Error("Location not found");const I=g.results[0],c={query:z,lat:I.latitude,lon:I.longitude,city:I.name,state:I.admin1||"",country:I.country||"",countryCode:I.country_code||""};return safeSet(M,JSON.stringify(c)),c}function y(z){return z.countryCode==="US"&&z.state?`${z.city}, ${z.state}`:z.country?`${z.city}, ${z.country}`:z.city}async function x(z){try{const H=await u(z),L=S(),g=L==="metric"?"celsius":"fahrenheit",I=L==="metric"?"kmh":"mph",c=`https://api.open-meteo.com/v1/forecast?latitude=${H.lat}&longitude=${H.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${g}&wind_speed_unit=${I}`,l=await fetch(c);if(!l.ok)throw new Error("Weather fetch failed");const n=(await l.json()).current,e=n.weather_code;return{temp:Math.round(n.temperature_2m),condition:B[e]||"Unknown",icon:C[e]||"\u{1F324}\uFE0F",locationStr:y(H),windSpeed:Math.round(n.wind_speed_10m),windDir:h(n.wind_direction_10m),unit:L}}catch(H){return console.warn("Weather fetch failed:",H),null}}async function O(){const z=D();if(!z.icon||!z.temp||!z.condition||!z.location||!z.wind){console.warn("Weather widget elements not found");return}const H=safeGet(f);if(!H){z.location.textContent="Set Location",z.temp.textContent="--\xB0",z.condition.textContent="Click \u2699\uFE0F to configure",z.wind.textContent="--",z.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const L=await x(H);if(L){const g=L.unit==="metric"?"\xB0C":"\xB0F",I=L.unit==="metric"?"km/h":"mph";z.location.textContent=L.locationStr,z.temp.textContent=`${L.temp}${g}`,z.condition.textContent=L.condition,z.wind.textContent=`Wind: ${L.windSpeed} ${I} ${L.windDir}`,z.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(L.icon)}</span>`}}catch(L){console.error("Weather update error:",L),z.location.textContent="Weather Error",z.temp.textContent="Error",z.condition.textContent="Failed to load",z.wind.textContent="--"}}const A=document.getElementById("weather-modal"),N=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{N.value=safeGet(f)||"";const z=S(),H=A.querySelector(`input[name="weather-unit-radio"][value="${z}"]`);H&&(H.checked=!0),A.classList.add("show"),N.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{A.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const z=N.value.trim();if(z){safeGet(f)!==z&&safeSet(M,""),safeSet(f,z);const L=A.querySelector('input[name="weather-unit-radio"]:checked'),g=L?L.value:"imperial",I=S();safeSet(k,g),I!==g&&safeSet(M,""),A.classList.remove("show"),O()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(A),document.addEventListener("keydown",z=>{z.key==="Escape"&&A.classList.Line truncated
|
|
|
|
|
<circle cx="${$}" cy="${P}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${F}
|
|
|
|
|
${J}
|
|
|
|
|
<line x1="${$}" y1="${P}" x2="${$+28*Math.cos(_*Math.PI/180)}" y2="${P+28*Math.sin(_*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${$}" y1="${P}" x2="${$+38*Math.cos(U*Math.PI/180)}" y2="${P+38*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${$}" y1="${P}" x2="${$+42*Math.cos(R*Math.PI/180)}" y2="${P+42*Math.sin(R*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${$}" cy="${P}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,se=o.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${K}<div class="analog-info"><span class="analog-digital">${o.getHours()%12||12}:${String(d).padStart(2,"0")} ${se}</span><span class="analog-date-sm">${O(o)}</span></div></div>`,h="analog"}function l(){const o=new Date,i=o.getHours()%12||12,v=o.getMinutes(),d=o.getSeconds(),w="clock-widget"+(D!=="default"?" "+D:"");switch(f.className!==w&&(f.className=w),D){case"lcd":H(o);break;case"lcd-blue":H(o);break;case"lcd-amber":H(o);break;case"lcd-retro":H(o);break;case"lcd-taxi":H(o);break;case"flip":L(o);break;case"binary":I(o);break;case"analog":c(o,!1);break;case"roman":c(o,!0);break;default:z(o)}v===0&&d===0&&i!==B&&(B=i,x(i)),v!==0&&(B=-1)}function a(){clearTimeout(y);const o=document.hidden?6e4:1e3,i=o-Date.now()%o+25;y=setTimeout(()=>{l(),a()},i)}document.addEventListener("visibilitychange",()=>{m="",A(),l(),a()}),l(),a();const n=[{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 e='<div class="clock-style-grid">';n.forEach(o=>{e+=`<label class="clock-style-option">
|
|
|
|
|
<input type="radio" name="clock-style-radio" value="${o.id}">
|
|
|
|
|
<span class="clock-style-card"><span class="clock-style-icon">${o.icon}</span><span class="clock-style-label">${o.label}</span></span>
|
|
|
|
|
</label>`}),e+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
|
|
@@ -1317,8 +1482,8 @@ Enter version to rollback to:`);if(!s)return;if(!o.includes(s)){showNotification
|
|
|
|
|
<button id="clock-settings-save">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const t=document.getElementById("clock-settings-modal"),i=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function v(){const o=safeGet("clock-style")||"default",s=t.querySelector(`input[value="${o}"]`);s&&(s.checked=!0),i.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=i.checked?"1":"0.4"}i?.addEventListener("change",()=>{p.style.opacity=i.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{v(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,s=new Audio("/assets/sounds/church-bell.mp3");s.volume=o,s.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),s=o?o.value:"default";safeSet("clock-style",s),safeSet("clock-chimes",String(i.checked)),safeSet("clock-chime-volume",r.value),N=s,$="",z(),d(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{N=o.value,$="",z(),d()})})})(),(function(){async function y(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[A,$]of Object.entries(D.status)){const P=document.getElementById("uptime-"+A),C=document.getElementById("uptime-bar-"+A);if(!P)continue;const H=$.uptime?.["24h"];if(H!=null){const x=H.toFixed(1);P.textContent=`${x}% uptime`,P.className="uptime-chip",H>=99.9?P.classList.add("excellent"):H>=99?P.classList.add("good"):H>=95?P.classList.add("degraded"):P.classList.add("poor"),C&&(C.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let h;try{h=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{h=new Set}async function L(){try{const D=await(await fetch("/api/v1/updates/available")).json();if(!D.success||(document.querySelectorAll(".update-available-badge").forEach(A=>A.classList.remove("visible")),!D.updates?.length))return;for(const A of D.updates){const $=window.APPS||[];for(const P of $)if(P.containerId===A.containerId||P.id===A.containerName||P.name===A.containerName){if(h.has(P.id))break;const C=document.getElementById("update-badge-"+P.id);C&&(C.classList.add("visible"),C.title=`Image digest changed. Click to dismiss if already up to date.
|
|
|
|
|
${A.imageName||""}`,C.style.cursor="pointer",C.onclick=H=>{H.stopPropagation(),C.classList.remove("visible"),h.add(P.id),safeSessionSet("dismissed-updates",JSON.stringify([...h]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function T(){setTimeout(()=>{y(),L()},5e3),setInterval(()=>{y(),L()},6e4)}const B=window.refreshAll;B&&(window.refreshAll=async function(){try{await B(),setTimeout(y,1e3)}catch(N){console.warn("[Card Badges] Error in refreshAll hook:",N.message)}}),T()})(),(function(){var y=null,h=null,L={},T={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},B=[["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"]],N=document.getElementById("theme");if(!N)return;var D=document.getElementById("theme-label");function A(l){if(T[l])return T[l];var g=safeGetJSON(window.USER_THEMES_KEY,{});return g[l]&&g[l].name||l}function $(){D&&(D.textContent=A(window.getActiveTheme()))}N.addEventListener("click",function(){var l=window.THEMES.slice(),g=window.getActiveTheme(),I=l.indexOf(g),w=l[(I+1)%l.length];window.applyTheme(w),$()}),$();function P(){var l={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},g={};B.forEach(function(w){g[w[2]]||(g[w[2]]=[]),g[w[2]].push(w)});var I="";return Object.keys(l).forEach(function(w){w==="advanced"?(I+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>',I+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):I+='<div class="theme-builder-section">',I+='<div class="theme-builder-section-title">'+l[w]+"</div>",(g[w]||[]).forEach(function(M){I+='<div class="theme-builder-row"><span class="theme-builder-label">'+M[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+M[0]+'"'+(w==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+M[0]+'">#000000</span></div>'}),I+="</div>"}),I}function C(){return window.THEMES.map(function(l){return'<option value="'+l+'">'+A(l)+"</option>"}).join("")}var H='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+C()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</
Line truncated
|
|
|
|
|
</div>`);const t=document.getElementById("clock-settings-modal"),s=document.getElementById("clock-chimes-toggle"),r=document.getElementById("clock-chime-volume"),p=document.getElementById("clock-volume-section");function b(){const o=safeGet("clock-style")||"default",i=t.querySelector(`input[value="${o}"]`);i&&(i.checked=!0),s.checked=safeGet("clock-chimes")==="true",r.value=safeGet("clock-chime-volume")||"50",p.style.opacity=s.checked?"1":"0.4"}s?.addEventListener("change",()=>{p.style.opacity=s.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{b(),t.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const o=parseInt(r.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=o,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const o=t.querySelector('input[name="clock-style-radio"]:checked'),i=o?o.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(s.checked)),safeSet("clock-chime-volume",r.value),D=i,m="",A(),l(),a(),t.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{t.classList.remove("show")}),wireModal(t),t?.querySelectorAll('input[name="clock-style-radio"]').forEach(o=>{o.addEventListener("change",()=>{D=o.value,m="",A(),l()})})})(),(function(){async function f(){try{const B=await(await fetch("/api/v1/health-checks/status")).json();if(!B.success||!B.status)return;for(const[C,m]of Object.entries(B.status)){const h=document.getElementById("uptime-"+C),u=document.getElementById("uptime-bar-"+C);if(!h)continue;const y=m.uptime?.["24h"];if(y!=null){const x=y.toFixed(1);h.textContent=`${x}% uptime`,h.className="uptime-chip",y>=99.9?h.classList.add("excellent"):y>=99?h.classList.add("good"):y>=95?h.classList.add("degraded"):h.classList.add("poor"),u&&(u.style.width=x+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}async function M(){try{const B=await(await fetch("/api/v1/updates/available")).json();if(!B.success||(document.querySelectorAll(".update-available-badge").forEach(C=>C.classList.remove("visible")),!B.updates?.length))return;for(const C of B.updates){const m=window.APPS||[];for(const h of m)if(h.containerId===C.containerId||h.id===C.containerName||h.name===C.containerName){if(E.has(h.id))break;const u=document.getElementById("update-badge-"+h.id);u&&(u.classList.add("visible"),u.title=`Image digest changed. Click to dismiss if already up to date.
|
|
|
|
|
${C.imageName||""}`,u.style.cursor="pointer",u.onclick=y=>{y.stopPropagation(),u.classList.remove("visible"),E.add(h.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function k(){setTimeout(()=>{f(),M()},5e3),setInterval(()=>{f(),M()},6e4)}const S=window.refreshAll;S&&(window.refreshAll=async function(){try{await S(),setTimeout(f,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),k()})(),(function(){var f=null,E=null,M={},k={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},S=[["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"]],D=document.getElementById("theme");if(!D)return;var B=document.getElementById("theme-label");function C(d){if(k[d])return k[d];var w=safeGetJSON(window.USER_THEMES_KEY,{});return w[d]&&w[d].name||d}function m(){B&&(B.textContent=C(window.getActiveTheme()))}D.addEventListener("click",function(){var d=window.THEMES.slice(),w=window.getActiveTheme(),T=d.indexOf(w),$=d[(T+1)%d.length];window.applyTheme($),m()}),m();function h(){var d={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},w={};S.forEach(function($){w[$[2]]||(w[$[2]]=[]),w[$[2]].push($)});var T="";return Object.keys(d).forEach(function($){$==="advanced"?(T+='<div id="theme-builder-advanced-toggle" class="theme-builder-advanced-toggle" style="margin:12px 0 4px;cursor:pointer;color:var(--accent);font-size:.85rem;user-select:none;">Show advanced colors ▼</div>',T+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):T+='<div class="theme-builder-section">',T+='<div class="theme-builder-section-title">'+d[$]+"</div>",(w[$]||[]).forEach(function(P){T+='<div class="theme-builder-row"><span class="theme-builder-label">'+P[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+P[0]+'"'+($==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+P[0]+'">#000000</span></div>'}),T+="</div>"}),T}function u(){return window.THEMES.map(function(d){return'<option value="'+d+'">'+C(d)+"</option>"}).join("")}var y='<div id="theme-builder-modal" class="weather-modal"><div class="weather-modal-content" style="min-width:420px;max-width:560px;"><h3>Theme Builder</h3><div id="theme-builder-existing" style="margin-bottom:16px;display:none;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Edit:</label><select id="theme-builder-edit-select" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;margin-right:8px;"><option value="">\u2014 New Theme \u2014</option></select><button id="theme-builder-delete" style="padding:4px 10px;background:color-mix(in srgb,var(--bad-fg) 15%,transparent);border:1px solid var(--bad-fg);color:var(--bad-fg);border-radius:6px;font-size:.8rem;cursor:pointer;">Delete</button></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Name:</label><input type="text" id="theme-builder-name" maxlength="20" placeholder="My Theme" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;width:140px;" /></div><div style="margin-bottom:16px;display:flex;align-items:center;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;cursor:pointer;" for="theme-builder-lightbg"><input type="checkbox" id="theme-builder-lightbg" style="margin-right:6px;cursor:pointer;vertical-align:middle;" />Light background (use dark logo)</label></div><div style="margin-bottom:16px;"><label style="font-size:.85rem;color:var(--muted);margin-right:8px;">Start from:</label><select id="theme-builder-start" style="padding:6px 10px;background:var(--card-bg);color:var(--fg);border:1px solid var(--border);border-radius:6px;font-size:.85rem;">'+u()+'</select></div><div class="theme-builder-preview" id="theme-builder-preview"><div class="theme-builder-card" id="theme-preview-card"><div class="preview-title">Sample Card</div><div class="preview-muted">Secondary text preview</div><div class="preview-badges"><span class="preview-badge" id="preview-badge-ok">ON</
Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
|
|
|
|
<h3>DashCaddy License</h3>
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
|
|
|
@@ -1355,13 +1520,13 @@ ${A.imageName||""}`,C.style.cursor="pointer",C.onclick=H=>{H.stopPropagation(),C
|
|
|
|
|
<button id="license-activate" class="btn-accent">Activate</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const y=document.getElementById("license-modal"),h=document.getElementById("license-code-input"),L=document.getElementById("license-activate"),T=document.getElementById("license-deactivate"),B=document.getElementById("license-error"),N=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),$=document.getElementById("license-badge"),P=document.getElementById("license-details"),C=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let x=null;function O(){B.style.display="none",N.style.display="none"}function z(a){O(),B.textContent=a,B.style.display="block"}function S(a){O(),N.textContent=a,N.style.display="block"}function E(a){if(x=a,a.active){$.style.background="rgba(46,204,113,0.15)",$.style.color="var(--ok-fg)",D.textContent="\u2605",A.textContent="Premium Active";const t=a.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(a.expiresAt).toLocaleDateString()}</strong> (${a.daysRemaining} days remaining)</div>`;P.innerHTML=`
|
|
|
|
|
</div>`);const f=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),M=document.getElementById("license-activate"),k=document.getElementById("license-deactivate"),S=document.getElementById("license-error"),D=document.getElementById("license-success"),B=document.getElementById("license-badge-icon"),C=document.getElementById("license-badge-text"),m=document.getElementById("license-badge"),h=document.getElementById("license-details"),u=document.getElementById("license-feature-list"),y=document.getElementById("license-activate-section");let x=null;function O(){S.style.display="none",D.style.display="none"}function A(a){O(),S.textContent=a,S.style.display="block"}function N(a){O(),D.textContent=a,D.style.display="block"}function z(a){if(x=a,a.active){m.style.background="rgba(46,204,113,0.15)",m.style.color="var(--ok-fg)",B.textContent="\u2605",C.textContent="Premium Active";const t=a.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(a.expiresAt).toLocaleDateString()}</strong> (${a.daysRemaining} days remaining)</div>`;h.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${a.code||"***"}</code></div>
|
|
|
|
|
${t}
|
|
|
|
|
`,H.style.display="none",L.style.display="none",T.style.display=""}else $.style.background="rgba(149,165,166,0.15)",$.style.color="var(--muted)",D.textContent="\u2606",A.textContent=a.expired?"License Expired":"Free Tier",P.innerHTML=a.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",L.style.display="",T.style.display="none";const n=a.premiumFeatures||{},e=new Set(a.features||[]);C.innerHTML=Object.entries(n).map(([t,i])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
|
|
|
|
`,y.style.display="none",M.style.display="none",k.style.display=""}else m.style.background="rgba(149,165,166,0.15)",m.style.color="var(--muted)",B.textContent="\u2606",C.textContent=a.expired?"License Expired":"Free Tier",h.innerHTML=a.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",y.style.display="",M.style.display="",k.style.display="none";const n=a.premiumFeatures||{},e=new Set(a.features||[]);u.innerHTML=Object.entries(n).map(([t,s])=>`<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;">${e.has(t)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${i.name}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${i.description}</div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${s.name}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${s.description}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`).join("")}async function k(){try{const n=await(await fetch("/api/v1/license/status")).json();n.success&&(E(n.license),f(n.license))}catch(a){console.warn("Failed to load license status:",a.message)}}async function b(){const a=h.value.trim();if(!a){z("Please enter a license code.");return}O(),L.disabled=!0,L.textContent="Activating...";try{const e=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:a})})).json();e.success?(S(e.message),h.value="",E(e.license),showNotification("License activated! Premium features unlocked.","success",5e3),f(e.license)):z(e.error||"Activation failed")}catch(n){z("Network error: "+n.message)}finally{L.disabled=!1,L.textContent="Activate"}}async function m(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){T.disabled=!0,T.textContent="Deactivating...";try{const n=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();n.success?(S(n.message),await k(),showNotification("License deactivated.","info",3e3),f({active:!1})):z(n.error||"Deactivation failed")}catch(a){z("Network error: "+a.message)}finally{T.disabled=!1,T.textContent="Deactivate"}}}function f(a){const n=document.getElementById("license-status-topbar"),e=document.getElementById("license-topbar-icon"),t=document.getElementById("license-topbar-text"),i=document.getElementById("license-topbar-time");if(n)if(n.className="license-status-topbar "+(a.active?"premium":"free"),a.active)if(e.textContent="\u2605",t.textContent="PREMIUM",a.lifetime)i.textContent="\xB7 LIFETIME";else{const r=a.daysRemaining;i.textContent=r!=null?"\xB7 "+r+"d remaining":""}else e.textContent="\u2606",t.textContent=a.expired?"EXPIRED":"FREE TIER",i.textContent=""}function c(){O(),k(),y.classList.add("show")}h.addEventListener("input",function(){let a=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(a.length>this._prevLength&&(a=a.replace(/-/g,""),a.length>2&&!a.startsWith("DC")&&(a="DC"+a),a.startsWith("DC")&&a.length>2)){const n=["DC"],e=a.substring(2);for(let t=0;t<e.length;t+=5)n.push(e.substring(t,t+5));a=n.join("-")}this._prevLength=a.length,this.value=a}),L.addEventListener("click",b),T.addEventListener("click",m),h.addEventListener("keydown",a=>{a.key==="Enter"&&b()}),wireModal(y,document.getElementById("license-cancel"));const d=document.getElementById("license-status-topbar");d&&d.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=c,window.checkPremiumFeature=async function(a){try{return(await(await fetch(`/api/v1/license/feature/${a}`)).json()).available}catch{return!1}},k().then(a=>{x&&f(x)})})();
|
|
|
|
|
</div>`).join("")}async function H(){try{const n=await(await fetch("/api/v1/license/status")).json();n.success&&(z(n.license),I(n.license))}catch(a){console.warn("Failed to load license status:",a.message)}}async function L(){const a=E.value.trim();if(!a){A("Please enter a license code.");return}O(),M.disabled=!0,M.textContent="Activating...";try{const e=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:a})})).json();e.success?(N(e.message),E.value="",z(e.license),showNotification("License activated! Premium features unlocked.","success",5e3),I(e.license)):A(e.error||"Activation failed")}catch(n){A("Network error: "+n.message)}finally{M.disabled=!1,M.textContent="Activate"}}async function g(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){k.disabled=!0,k.textContent="Deactivating...";try{const n=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();n.success?(N(n.message),await H(),showNotification("License deactivated.","info",3e3),I({active:!1})):A(n.error||"Deactivation failed")}catch(a){A("Network error: "+a.message)}finally{k.disabled=!1,k.textContent="Deactivate"}}}function I(a){const n=document.getElementById("license-status-topbar"),e=document.getElementById("license-topbar-icon"),t=document.getElementById("license-topbar-text"),s=document.getElementById("license-topbar-time");if(n)if(n.className="license-status-topbar "+(a.active?"premium":"free"),a.active)if(e.textContent="\u2605",t.textContent="PREMIUM",a.lifetime)s.textContent="\xB7 LIFETIME";else{const r=a.daysRemaining;s.textContent=r!=null?"\xB7 "+r+"d remaining":""}else e.textContent="\u2606",t.textContent=a.expired?"EXPIRED":"FREE TIER",s.textContent=""}function c(){O(),H(),f.classList.add("show")}E.addEventListener("input",function(){let a=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(a.length>this._prevLength&&(a=a.replace(/-/g,""),a.length>2&&!a.startsWith("DC")&&(a="DC"+a),a.startsWith("DC")&&a.length>2)){const n=["DC"],e=a.substring(2);for(let t=0;t<e.length;t+=5)n.push(e.substring(t,t+5));a=n.join("-")}this._prevLength=a.length,this.value=a}),M.addEventListener("click",L),k.addEventListener("click",g),E.addEventListener("keydown",a=>{a.key==="Enter"&&L()}),wireModal(f,document.getElementById("license-cancel"));const l=document.getElementById("license-status-topbar");l&&l.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=c,window.checkPremiumFeature=async function(a){try{return(await(await fetch(`/api/v1/license/feature/${a}`)).json()).available}catch{return!1}},H().then(a=>{x&&I(x)})})();
|