|
|
|
@@ -90,44 +90,44 @@
|
|
|
|
|
<button id="logo-save" class="btn-accent">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),N=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),T=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),L=document.getElementById("logo-single-upload"),H=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),I=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),x=document.querySelector("#brand .brand-logo-light"),$=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),R=DC.NAME;let M=null,j=null,B=null,A="left",w=R;T?.addEventListener("change",()=>{T.checked?(P.style.display="none",L.style.display="",M=null,j=null):(P.style.display="flex",L.style.display="none",B=null)});function z(a,e){if(!a||!a.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const n=new FileReader;n.onload=t=>e(t.target.result),n.readAsDataURL(a)}H?.addEventListener("change",a=>{z(a.target.files[0],e=>{M=e,E.src=e,S.textContent="New dark logo ready to save"})}),g?.addEventListener("change",a=>{z(a.target.files[0],e=>{j=e,N.src=e,S.textContent="New light logo ready to save"})}),I?.addEventListener("change",a=>{z(a.target.files[0],e=>{B=e,E.src=e,N.src=e,S.textContent="New logo ready to save (both themes)"})});function f(a){$.setAttribute("data-logo-pos",a),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===a?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===a?"white":"var(--fg)"})}function p(a){w=a||R,document.title=w;const e=document.querySelector(".dashboard-title");e&&(e.textContent=w)}async function y(){try{const a=await fetch("/api/v1/logo");if(a.ok){const e=await a.json();e.customLogoDark&&(k.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(x.src=e.customLogoLight,N.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(k.src=e.customLogo,x.src=e.customLogo,E.src=e.customLogo,N.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(A=e.position,f(e.position)),e.dashboardTitle&&p(e.dashboardTitle)}}catch(a){console.warn("Could not load custom logo:",a.message)}}document.querySelectorAll(".logo-pos-btn").forEach(a=>{a.addEventListener("click",()=>{A=a.dataset.pos,f(A)})}),document.getElementById("brand")?.addEventListener("click",()=>{M=null,j=null,B=null,H&&(H.value=""),g&&(g.value=""),I&&(I.value=""),T&&(T.checked=!1),P.style.display="flex",L.style.display="none",E.src=k.src,N.src=x.src;const a=k.src.includes("custom-logo")||x.src.includes("custom-logo");S.textContent=a?"Using custom logo":"Using default logos",f(A),C.value=w,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const a=C.value.trim()||R,e={position:A,dashboardTitle:a};T?.checked&&B?(e.dataDark=B,e.dataLight=B):(M&&(e.dataDark=M),j&&(e.dataLight=j));const n=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(n.ok){const t=await n.json(),i="?t="+Date.now();t.pathDark&&(k.src=t.pathDark+i,E.src=t.pathDark+i),t.pathLight&&(x.src=t.pathLight+i,N.src=t.pathLight+i),f(A),p(a),b.classList.remove("show")}else{const t=await n.json();showNotification("Failed to save: "+t.error,"error")}}catch(a){showNotification("Error saving: "+a.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
|
</div>`);const h=document.getElementById("logo-modal"),E=document.getElementById("logo-preview-dark"),P=document.getElementById("logo-preview-light"),w=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),O=document.getElementById("logo-dual-uploads"),z=document.getElementById("logo-single-upload"),A=document.getElementById("logo-upload-dark"),v=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),b=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),B=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,j=null,H=null,R="left",x=S;N?.addEventListener("change",()=>{N.checked?(O.style.display="none",z.style.display="",T=null,j=null):(O.style.display="flex",z.style.display="none",H=null)});function D(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const o=new FileReader;o.onload=a=>e(a.target.result),o.readAsDataURL(n)}A?.addEventListener("change",n=>{D(n.target.files[0],e=>{T=e,E.src=e,w.textContent="New dark logo ready to save"})}),v?.addEventListener("change",n=>{D(n.target.files[0],e=>{j=e,P.src=e,w.textContent="New light logo ready to save"})}),L?.addEventListener("change",n=>{D(n.target.files[0],e=>{H=e,E.src=e,P.src=e,w.textContent="New logo ready to save (both themes)"})});function g(n){k.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===n?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===n?"white":"var(--fg)"})}function u(n){x=n||S,document.title=x;const e=document.querySelector(".dashboard-title");e&&(e.textContent=x)}async function f(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(b.src=e.customLogoDark,E.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,P.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(b.src=e.customLogo,M.src=e.customLogo,E.src=e.customLogo,P.src=e.customLogo),e.isDefault||(w.textContent="Using custom logo"),e.position&&(R=e.position,g(e.position)),e.dashboardTitle&&u(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{R=n.dataset.pos,g(R)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,j=null,H=null,A&&(A.value=""),v&&(v.value=""),L&&(L.value=""),N&&(N.checked=!1),O.style.display="flex",z.style.display="none",E.src=b.src,P.src=M.src;const n=b.src.includes("custom-logo")||M.src.includes("custom-logo");w.textContent=n?"Using custom logo":"Using default logos",g(R),B.value=x,h.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=B.value.trim()||S,e={position:R,dashboardTitle:n};N?.checked&&H?(e.dataDark=H,e.dataLight=H):(T&&(e.dataDark=T),j&&(e.dataLight=j));const o=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(o.ok){const a=await o.json(),r="?t="+Date.now();a.pathDark&&(b.src=a.pathDark+r,E.src=a.pathDark+r),a.pathLight&&(M.src=a.pathLight+r,P.src=a.pathLight+r),g(R),u(n),h.classList.remove("show")}else{const a=await o.json();showNotification("Failed to save: "+a.error,"error")}}catch(n){showNotification("Error saving: "+n.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
|
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(k.src="/assets/dashcaddy-logo-dark.png",x.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",N.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",M=null,j=null,B=null,C.value=R,p(R),A="left",f("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const n=document.querySelector('link[rel="icon"]'),t=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");n&&(n.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),t&&(t.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),s=null}}catch(a){showNotification("Error resetting branding: "+a.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const v=document.getElementById("favicon-preview"),m=document.getElementById("favicon-status"),r=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let s=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function h(){try{const a=await fetch("/api/v1/favicon");if(a.ok){const e=await a.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),v.src=e.customFavicon+"?t="+Date.now(),m.textContent="Using custom favicon")}}catch(a){console.warn("Could not load custom favicon:",a.message)}}r?.addEventListener("change",a=>{const e=a.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),r.value="";return}const n=new FileReader;n.onload=t=>{s=t.target.result,v.src=s,m.textContent="New favicon ready to save"},n.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(s)try{const a=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:s})});if(a.ok){const e=await a.json();c.href=e.path+"?t="+Date.now(),v.src=e.path+"?t="+Date.now(),m.textContent="Using custom favicon",s=null}else{const e=await a.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(a){showNotification("Error saving favicon: "+a.message,"error")}}),h(),y();const u=document.getElementById("settings-timezone");u&&(new MutationObserver(()=>{b.classList.contains("show")&&u.options.length===0&&(async()=>{let e;try{const n=await fetch("/api/v1/config");n.ok&&(e=(await n.json()).timezone)}catch{}window.populateTimezoneSelect(u,e)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=u.value;if(e)try{const n=await fetch("/api/v1/config");if(!n.ok)return;const t=await n.json();t.timezone=e,t.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}catch(n){console.warn("Failed to save timezone:",n.message)}}))})(),window.populateTimezoneSelect=function(b,E){const N=Intl.supportedValuesOf("timeZone"),S=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const T of N){const P=document.createElement("option");P.value=T,P.textContent=T.replace(/_/g," "),T===S&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",E=null;async function N(){try{const z=await fetch("/api/v1/config");if(z.ok&&(E=await z.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(z){console.warn("Could not fetch server config, checking localStorage fallback:",z.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}N();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function T(w){document.querySelectorAll(".setup-step").forEach(f=>{f.style.display="none"});const z=document.getElementById(w);z&&(z.style.display="block")}function P(){const w=document.getElementById("setup-summary-content");if(!w)return;let z='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const p=document.getElementById("setup-tld")?.value?.trim()||".home",y=document.getElementById("setup-ca-name")?.value?.trim()||"",v=document.getElementById("setup-dns-ip")?.value?.trim()||"",m=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;z+=`
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(b.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",E.src="/assets/dashcaddy-logo-dark.png",P.src="/assets/dashcaddy-logo-light.png",w.textContent="Using default logos",T=null,j=null,H=null,B.value=S,u(S),R="left",g("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const o=document.querySelector('link[rel="icon"]'),a=document.getElementById("favicon-preview"),r=document.getElementById("favicon-status");o&&(o.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),r&&(r.textContent="Using DashCaddy favicon"),i=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(h,document.getElementById("logo-cancel"));const m=document.getElementById("favicon-preview"),p=document.getElementById("favicon-status"),d=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let i=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function $(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const e=await n.json();e.customFavicon&&(c.href=e.customFavicon+"?t="+Date.now(),m.src=e.customFavicon+"?t="+Date.now(),p.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}d?.addEventListener("change",n=>{const e=n.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),d.value="";return}const o=new FileReader;o.onload=a=>{i=a.target.result,m.src=i,p.textContent="New favicon ready to save"},o.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(i)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),m.src=e.path+"?t="+Date.now(),p.textContent="Using custom favicon",i=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),$(),f();const y=document.getElementById("settings-timezone");y&&(new MutationObserver(()=>{h.classList.contains("show")&&y.options.length===0&&(async()=>{let e;try{const o=await fetch("/api/v1/config");o.ok&&(e=(await o.json()).timezone)}catch{}window.populateTimezoneSelect(y,e)})()}).observe(h,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=y.value;if(e)try{const o=await fetch("/api/v1/config");if(!o.ok)return;const a=await o.json();a.timezone=e,a.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})}catch(o){console.warn("Failed to save timezone:",o.message)}}))})(),window.populateTimezoneSelect=function(h,E){const P=Intl.supportedValuesOf("timeZone"),w=E||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";h.innerHTML="";for(const N of P){const O=document.createElement("option");O.value=N,O.textContent=N.replace(/_/g," "),N===w&&(O.selected=!0),h.appendChild(O)}},(function(){let h="homelab",E=null;async function P(){try{const D=await fetch("/api/v1/config");if(D.ok&&(E=await D.json(),E&&E.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(D){console.warn("Could not fetch server config, checking localStorage fallback:",D.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}P();const w=document.getElementById("setup-timezone");w&&window.populateTimezoneSelect(w);function N(x){document.querySelectorAll(".setup-step").forEach(g=>{g.style.display="none"});const D=document.getElementById(x);D&&(D.style.display="block")}function O(){const x=document.getElementById("setup-summary-content");if(!x)return;let D='<div style="display: grid; gap: 20px;">';if(h==="homelab"){const u=document.getElementById("setup-tld")?.value?.trim()||".home",f=document.getElementById("setup-ca-name")?.value?.trim()||"",m=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;D+=`
|
|
|
|
|
<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> ${p}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${y}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${v}:${m}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${p}, https://nextcloud${p}</div>
|
|
|
|
|
<div><strong>TLD:</strong> ${u}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${f}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${m}:${p}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${u}, https://nextcloud${u}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(b==="simple"){const p=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";z+=`
|
|
|
|
|
`}else if(h==="simple"){const u=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";D+=`
|
|
|
|
|
<div>
|
|
|
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
|
|
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
|
|
|
<div><strong>Access Method:</strong> IP:Port only</div>
|
|
|
|
|
<div><strong>Default IP:</strong> ${p}</div>
|
|
|
|
|
<div><strong>Default IP:</strong> ${u}</div>
|
|
|
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${p}:8080, http://${p}:3000</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${u}:8080, http://${u}:3000</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(b==="public"){const p=document.getElementById("setup-public-domain")?.value?.trim()||"",y=document.getElementById("setup-public-email")?.value?.trim()||"",v=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",m=v==="subdirectory"?`https://${p}/sonarr, https://${p}/grafana`:`https://sonarr.${p}, https://grafana.${p}`;z+=`
|
|
|
|
|
`}else if(h==="public"){const u=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"",m=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=m==="subdirectory"?`https://${u}/sonarr, https://${u}/grafana`:`https://sonarr.${u}, https://grafana.${u}`;D+=`
|
|
|
|
|
<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> ${p}</div>
|
|
|
|
|
<div><strong>Domain:</strong> ${u}</div>
|
|
|
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
|
|
|
<div><strong>Email:</strong> ${y}</div>
|
|
|
|
|
<div><strong>Routing:</strong> ${v==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${m}</div>
|
|
|
|
|
<div><strong>Email:</strong> ${f}</div>
|
|
|
|
|
<div><strong>Routing:</strong> ${m==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${p}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}const f=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";z+=`
|
|
|
|
|
`}const g=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";D+=`
|
|
|
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
|
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${f.replace(/_/g," ")}</div>
|
|
|
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${g.replace(/_/g," ")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`,z+="</div>",w.innerHTML=z,T("setup-step-summary")}async function L(w){try{const z=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(w)});return z.ok?(await z.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${z.status}`),{function:"saveConfigToServer"}),!1)}catch(z){return errorHandler.logError("[SetupWizard] Save Config",z,{function:"saveConfigToServer"}),!1}}async function H(){const w={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};b==="homelab"?(w.tld=document.getElementById("setup-tld")?.value?.trim()||".home",w.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",w.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()||""},w.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):b==="simple"?(w.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",w.defaults={dnsType:"none",sslType:"none",targetIP:w.defaultIP}):b==="public"&&(w.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",w.email=document.getElementById("setup-public-email")?.value?.trim()||"",w.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",w.defaults={dnsType:w.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const z=await L(w);safeSet("dashcaddy-config",JSON.stringify(w)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const f=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",p=z?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${f}. Settings saved to: ${p}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(w){w.preventDefault();const z=document.querySelector('input[name="config-type"]:checked');z&&(b=z.value),T(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const I=document.getElementById("setup-skip");I&&(I.onclick=async function(w){w.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await L({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(w){const z=w.target.value||".home",f=document.getElementById("tld-preview"),p=document.getElementById("tld-preview-2");f&&(f.textContent=z),p&&(p.textContent=z)});const x=document.getElementById("setup-homelab-back");x&&(x.onclick=function(w){w.preventDefault(),T("setup-step-1")});const $=document.getElementById("setup-homelab-next");$&&($.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-tld")?.value?.trim()||"",f=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!z||!z.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!f){showNotification("Please enter a Certificate Authority name","warning");return}if(!p){showNotification("Please enter your DNS server IP address","warning");return}P()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(w){w.preventDefault(),T("setup-step-1")});const R=document.getElementById("setup-simple-next");R&&(R.onclick=function(w){w.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(w){w.onchange=function(){var z=document.getElementById("dns-requirement-note");z&&(z.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const M=document.getElementById("setup-public-back");M&&(M.onclick=function(w){w.preventDefault(),T("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(w){w.preventDefault();const z=document.getElementById("setup-public-domain")?.value?.trim()||"",f=document.getElementById("setup-public-email")?.value?.trim()||"";if(!z){showNotification("Please enter your domain name","warning");return}if(!f||!f.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const B=document.getElementById("setup-summary-back");B&&(B.onclick=function(w){w.preventDefault(),b==="homelab"?T("setup-step-homelab"):b==="simple"?T("setup-step-simple"):b==="public"&&T("setup-step-pub
Line truncated
|
|
|
|
|
`,D+="</div>",x.innerHTML=D,N("setup-step-summary")}async function z(x){try{const D=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)});return D.ok?(await D.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${D.status}`),{function:"saveConfigToServer"}),!1)}catch(D){return errorHandler.logError("[SetupWizard] Save Config",D,{function:"saveConfigToServer"}),!1}}async function A(){const x={setupComplete:!0,configurationType:h,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};h==="homelab"?(x.tld=document.getElementById("setup-tld")?.value?.trim()||".home",x.caName=document.getElementById("setup-ca-name")?.value?.trim()||"",x.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()||""},x.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}):h==="simple"?(x.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",x.defaults={dnsType:"none",sslType:"none",targetIP:x.defaultIP}):h==="public"&&(x.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",x.email=document.getElementById("setup-public-email")?.value?.trim()||"",x.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",x.defaults={dnsType:x.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const D=await z(x);safeSet("dashcaddy-config",JSON.stringify(x)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const g=h==="homelab"?"Professional Home Lab":h==="simple"?"Simple Setup":"Public Server",u=D?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${g}. Settings saved to: ${u}`,"success",5e3),setTimeout(()=>location.reload(),500)}const v=document.getElementById("setup-step-1-next");v&&(v.onclick=function(x){x.preventDefault();const D=document.querySelector('input[name="config-type"]:checked');D&&(h=D.value),N(h==="homelab"?"setup-step-homelab":h==="simple"?"setup-step-simple":h==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(x){x.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await z({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const b=document.getElementById("setup-tld");b&&(b.oninput=function(x){const D=x.target.value||".home",g=document.getElementById("tld-preview"),u=document.getElementById("tld-preview-2");g&&(g.textContent=D),u&&(u.textContent=D)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(x){x.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-tld")?.value?.trim()||"",g=document.getElementById("setup-ca-name")?.value?.trim()||"",u=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!D||!D.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!g){showNotification("Please enter a Certificate Authority name","warning");return}if(!u){showNotification("Please enter your DNS server IP address","warning");return}O()});const B=document.getElementById("setup-simple-back");B&&(B.onclick=function(x){x.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(x){x.preventDefault(),O()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(x){x.onchange=function(){var D=document.getElementById("dns-requirement-note");D&&(D.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(x){x.preventDefault(),N("setup-step-1")});const j=document.getElementById("setup-public-next");j&&(j.onclick=function(x){x.preventDefault();const D=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"";if(!D){showNotification("Please enter your domain name","warning");return}if(!g||!g.includes("@")){showNotification("Please enter a valid email address","warning");return}O()});const H=document.getElementById("setup-summary-back");H&&(H.onclick=function(x){x.preventDefault(),h==="homelab"?N("setup-step-homelab"):h==="simple"?N("setup-step-simple"):h==="public"&&N("setup-step-pub
Line truncated
|
|
|
|
|
<div class="app-selector-content">
|
|
|
|
|
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
|
|
|
|
<div id="app-selector-grid" class="app-selector-grid"></div>
|
|
|
|
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const E="custom-apps";let N=null,S=null;const T=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function L(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return N=c.templates,S=c.categories,!0}catch(r){b.logError("[AppSelector] Fetch Templates",r,{function:"fetchApiTemplates"})}return!1}async function H(r){try{return await(await fetch(`/api/v1/apps/ports/${r}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function g(r){try{const s=await(await fetch(`/api/v1/apps/ports/${r}/suggest`)).json();if(s.success)return s.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return r}async function I(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!N&&!await L()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const r={};for(const[s,h]of Object.entries(N)){const u=h.category||"Other";r[u]||(r[u]=[]),r[u].push({id:s,...h})}const c=S?Object.keys(S):Object.keys(r).sort();for(const s of c){const h=r[s];if(!h||h.length===0)continue;h.sort((e,n)=>(n.popularity||0)-(e.popularity||0));const u=document.createElement("div");u.className="app-category-header";const a=S?.[s]||{};u.innerHTML=`${escapeHtml(a.icon||"")} ${escapeHtml(s)}`,a.color&&(u.style.borderBottomColor=a.color),P.appendChild(u),h.forEach(e=>{const n=document.createElement("div");n.className="app-option";const t=e.isDashboardWidget,i=t&&safeGet("widget-"+e.id+"-enabled")!=="false",o=t?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",d=!t&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";n.innerHTML=`
|
|
|
|
|
</div>`);const E="custom-apps";let P=null,w=null;const N=document.getElementById("app-selector-modal"),O=document.getElementById("app-selector-grid");async function z(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return P=c.templates,w=c.categories,!0}catch(d){h.logError("[AppSelector] Fetch Templates",d,{function:"fetchApiTemplates"})}return!1}async function A(d){try{return await(await fetch(`/api/v1/apps/ports/${d}/check`)).json()}catch(c){return h.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function v(d){try{const i=await(await fetch(`/api/v1/apps/ports/${d}/suggest`)).json();if(i.success)return i.suggestedPort}catch(c){h.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return d}async function L(){if(O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!P&&!await z()){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}O.innerHTML="";const d={};for(const[i,$]of Object.entries(P)){const y=$.category||"Other";d[y]||(d[y]=[]),d[y].push({id:i,...$})}const c=w?Object.keys(w):Object.keys(d).sort();for(const i of c){const $=d[i];if(!$||$.length===0)continue;$.sort((e,o)=>(o.popularity||0)-(e.popularity||0));const y=document.createElement("div");y.className="app-category-header";const n=w?.[i]||{};y.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(i)}`,n.color&&(y.style.borderBottomColor=n.color),O.appendChild(y),$.forEach(e=>{const o=document.createElement("div");o.className="app-option";const a=e.isDashboardWidget,r=a&&safeGet("widget-"+e.id+"-enabled")!=="false",t=a?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${r?"#2ecc7130":"#e74c3c30"}; color: ${r?"#2ecc71":"#e74c3c"}; font-weight: 600;">${r?"ON":"OFF"}</div>`:"",s=!a&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";o.innerHTML=`
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
|
|
|
|
${o}${d}
|
|
|
|
|
`,t?n.onclick=()=>k(e,n):n.onclick=()=>x(e),P.appendChild(n)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function k(r,c){const s="widget-"+r.id+"-enabled",u=!(safeGet(s)!=="false");safeSet(s,String(u));const a=r.widgetSelector;if(a){const n=document.querySelector(a);n&&(n.style.display=u?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=u?"ON":"OFF",e.style.background=u?"#2ecc7130":"#e74c3c30",e.style.color=u?"#2ecc71":"#e74c3c"),showNotification(`${r.name} widget ${u?"enabled":"disabled"}`,"success",2e3)}async function x(r){const c=document.getElementById("app-deploy-modal"),s=document.getElementById("app-deploy-title"),h=document.getElementById("deploy-subdomain"),u=document.getElementById("deploy-url-preview"),a=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),n=document.getElementById("deploy-tailscale-only"),t=document.getElementById("tailscale-status");try{const J=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:r.id})})).json();if(J.success&&J.exists){const V=J.container;confirm(`Found existing ${r.name} container:
|
|
|
|
|
${t}${s}
|
|
|
|
|
`,a?o.onclick=()=>b(e,o):o.onclick=()=>M(e),O.appendChild(o)})}window.renderRecipeCards&&await window.renderRecipeCards(O)}function b(d,c){const i="widget-"+d.id+"-enabled",y=!(safeGet(i)!=="false");safeSet(i,String(y));const n=d.widgetSelector;if(n){const o=document.querySelector(n);o&&(o.style.display=y?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=y?"ON":"OFF",e.style.background=y?"#2ecc7130":"#e74c3c30",e.style.color=y?"#2ecc71":"#e74c3c"),showNotification(`${d.name} widget ${y?"enabled":"disabled"}`,"success",2e3)}async function M(d){const c=document.getElementById("app-deploy-modal"),i=document.getElementById("app-deploy-title"),$=document.getElementById("deploy-subdomain"),y=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),o=document.getElementById("deploy-tailscale-only"),a=document.getElementById("tailscale-status");try{const W=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:d.id})})).json();if(W.success&&W.exists){const V=W.container;confirm(`Found existing ${d.name} container:
|
|
|
|
|
|
|
|
|
|
Container: ${V.name}
|
|
|
|
|
Status: ${V.status}
|
|
|
|
@@ -347,38 +347,38 @@ Port: ${V.primaryPort||"N/A"}
|
|
|
|
|
Would you like to use this existing container?
|
|
|
|
|
|
|
|
|
|
Click OK to configure DNS/Caddy for the existing container.
|
|
|
|
|
Click Cancel to deploy a new container.`)&&(r._useExisting=!0,r._existingContainer=V)}}catch{}s.textContent=`Deploy ${r.name}`;const i=r.subdomain||r.id.replace(/-/g,"");h.value=i;const o=document.getElementById("subpath-compat-warning");if(o)if(SITE.routingMode==="subdirectory"){const _=r.subpathSupport||"strip";_==="none"?(o.style.display="block",o.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+r.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(o.style.display="block",o.innerHTML='<span style="color: var(--muted);">ⓘ '+r.name+" has unverified subdirectory support. It may require additional configuration.</span>"):o.style.display="none"}else o.style.display="none";const d=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),D=document.querySelector(`input[name="dns-type"][value="${d}"]`),O=document.querySelector(`input[name="ssl-type"][value="${l}"]`);D?D.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,O?O.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,a.value=SITE.defaults.targetIP||"localhost",n.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,J=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),J&&!J.dataset.moved&&(U.appendChild(J),J.dataset.moved="1")}const G=document.getElementById("media-path-section"),W=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(r.mediaMount){G.style.display="block",W.value="",W.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),J=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",J.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];W.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const de=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${de?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const le=W.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(ee.hostPath);pe>=0?(le.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),W.value=le.join(", ")},J.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(W)}}else G.style.display="none",W.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(r.id==="plex"||r.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",r.docker?.volumes?.length){const _=r.mediaMount?.containerPath,J=r.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));J.length>0?(ne.style.display="block",J.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
|
|
|
|
Click Cancel to deploy a new container.`)&&(d._useExisting=!0,d._existingContainer=V)}}catch{}i.textContent=`Deploy ${d.name}`;const r=d.subdomain||d.id.replace(/-/g,"");$.value=r;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const _=d.subpathSupport||"strip";_==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+d.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):_==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">ⓘ '+d.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),C=document.querySelector(`input[name="dns-type"][value="${s}"]`),I=document.querySelector(`input[name="ssl-type"][value="${l}"]`);C?C.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,I?I.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",o.checked=!1;const F=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),q=document.querySelector("#app-deploy-modal details"),U=q?.querySelector("div");if(q&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const _=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,W=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;_&&!_.dataset.moved&&(U.appendChild(_),_.dataset.moved="1"),W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1")}const G=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),X=document.getElementById("media-path-description");if(d.mediaMount){G.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const _=document.getElementById("detected-mounts-container"),W=document.getElementById("detected-mounts-list");try{const K=await(await fetch("/api/v1/media/detected-mounts")).json();if(K.success&&K.mounts.length>0){_.style.display="block",W.innerHTML="";const te=[...new Set(K.mounts.map(ee=>ee.hostPath))];J.value=te.join(", "),K.mounts.forEach(ee=>{const Z=document.createElement("button");Z.type="button";const le=te.includes(ee.hostPath);Z.style.cssText=`padding: 8px 14px; font-size: 0.85rem; background: color-mix(in srgb, var(--success) ${le?"40%":"15%"}, var(--card-bg)); border: 1px solid var(--success); border-radius: 6px; cursor: pointer; color: var(--fg);`,Z.innerHTML=`<span style="font-weight: 500;">${escapeHtml(ee.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(ee.sourceImage)}</span>`,Z.title=`${ee.hostPath} (from ${ee.sourceContainer})`,Z.onclick=()=>{const de=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=de.indexOf(ee.hostPath);pe>=0?(de.splice(pe,1),Z.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(de.push(ee.hostPath),Z.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=de.join(", ")},W.appendChild(Z)})}else _.style.display="none"}catch{_.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else G.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const Q=document.getElementById("plex-claim-section");Q&&(d.id==="plex"||d.claimToken?(Q.style.display="block",document.getElementById("deploy-plex-claim").value=""):Q.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",d.docker?.volumes?.length){const _=d.mediaMount?.containerPath,W=d.docker.volumes.filter(V=>!V.includes("{{MEDIA_PATH}}")&&!(_&&V.endsWith(":"+_)));W.length>0?(ne.style.display="block",W.forEach((V,K)=>{const[te,ee]=V.split(":"),Z=document.createElement("div");Z.style.cssText="display: flex; gap: 6px; align-items: center;",Z.innerHTML=`
|
|
|
|
|
<input type="text" class="vol-host-path" data-container-path="${ee}" value="${te}"
|
|
|
|
|
style="flex: 1; padding: 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-size: 0.85rem;" />
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.85rem; white-space: nowrap;">\u2192 ${ee}</span>
|
|
|
|
|
<button type="button" class="vol-browse-btn" style="padding: 8px 10px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.8rem;">\u{1F4C2}</button>
|
|
|
|
|
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const de=Z.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const se=r.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const J=await H(_);if(J.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await g(se);Y.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(J.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const J=await(await fetch("/api/v1/tailscale/status")).json();J.success&&J.installed&&J.connected?t.innerHTML=`
|
|
|
|
|
`,oe.appendChild(Z),Z.querySelector(".vol-browse-btn").onclick=()=>{const le=Z.querySelector(".vol-host-path");openFolderBrowser(le)}})):ne.style.display="none"}else ne.style.display="none";const se=d.defaultPort||8080;e.value="",e.placeholder=`Default: ${se}`;let Y=document.getElementById("deploy-port-status");Y||(Y=document.createElement("div"),Y.id="deploy-port-status",Y.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(Y));async function ie(){const _=e.value||se;Y.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const W=await A(_);if(W.available)Y.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(_))} is available</span>`;else{const V=await v(se);Y.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(_)} in use by ${escapeHtml(W.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;const K=document.createElement("button");K.type="button",K.textContent=`Use ${V}`,K.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",K.onclick=()=>{document.getElementById("deploy-port").value=V,Y.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(V))}</span>`},Y.appendChild(K)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const W=await(await fetch("/api/v1/tailscale/status")).json();W.success&&W.installed&&W.connected?a.innerHTML=`
|
|
|
|
|
<span style="color: #4caf50;">Connected</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${J.self?.hostname} (${J.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${J.deviceCount} devices</span>
|
|
|
|
|
`:J.installed?t.innerHTML='<span style="color: #ff9800;">Not connected</span>':(t.innerHTML='<span style="color: var(--muted);">Not available</span>',n.disabled=!0)}catch{t.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=h.value||"subdomain",J=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(J==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(J==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||r.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${a.value}:${te}`}u.textContent=K}h.oninput=ae,a.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),T.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(r)}async function $(r){const c=r.appTemplate,s=safeGetJSON(E,[]),h=c._useExisting&&c._existingContainer,u=s.find(a=>a.id===r.subdomain);if(!(u&&!h&&!confirm(`An app with subdomain "${r.subdomain}" already exists. Redeploy?`))){if(u){const a=s.indexOf(u);s.splice(a,1),safeSet(E,JSON.stringify(s))}if(h)r.port=c._existingContainer.primaryPort;else{const a=r.port||c.defaultPort||8080;showNotification(`Checking port ${a} availability...`,"info",0);const e=await H(a);if(!e.available){const n=await g(c.defaultPort||8080);if(confirm(`Port ${a} is already in use by ${e.conflict?.usedBy||"another container"}.
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${W.self?.hostname} (${W.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${W.deviceCount} devices</span>
|
|
|
|
|
`:W.installed?a.innerHTML='<span style="color: #ff9800;">Not connected</span>':(a.innerHTML='<span style="color: var(--muted);">Not available</span>',o.disabled=!0)}catch{a.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const _=$.value||"subdomain",W=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${_}`;else if(W==="private")K=`${V==="none"?"http":"https"}://${buildDomain(_)}`;else if(W==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||_;K=SITE.domain?`${te}://${_}.${SITE.domain}`:`${te}://${_}`}else{const te=e.value||d.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${n.value}:${te}`}y.textContent=K}$.oninput=ae,n.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(_=>{_.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(_=>{_.onchange=ae}),ae(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(d)}async function k(d){const c=d.appTemplate,i=safeGetJSON(E,[]),$=c._useExisting&&c._existingContainer,y=i.find(n=>n.id===d.subdomain);if(!(y&&!$&&!confirm(`An app with subdomain "${d.subdomain}" already exists. Redeploy?`))){if(y){const n=i.indexOf(y);i.splice(n,1),safeSet(E,JSON.stringify(i))}if($)d.port=c._existingContainer.primaryPort;else{const n=d.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await A(n);if(!e.available){const o=await v(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}.
|
|
|
|
|
|
|
|
|
|
Would you like to use port ${n} instead?`))r.port=n;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(h?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const a={appId:c.id,config:{subdomain:r.subdomain,ip:r.ip,createDns:r.dnsType==="private",port:r.port||c.defaultPort||null,sslType:r.sslType,dnsType:r.dnsType,tailscaleOnly:r.tailscaleOnly||!1,mediaPath:r.mediaPath||null,plexClaimToken:r.plexClaimToken||null,customVolumes:r.customVolumes||null}};h&&(a.config.useExisting=!0,a.config.existingContainerId=c._existingContainer.id,a.config.existingPort=c._existingContainer.primaryPort,!r.port&&c._existingContainer.primaryPort&&(a.config.port=c._existingContainer.primaryPort));const n=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)})).json();if(n.success){const t={id:r.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:n.containerId,url:n.url,ip:r.ip,appTemplate:c.id,tailscaleOnly:r.tailscaleOnly||!1};s.push(t),safeSet(E,JSON.stringify(s)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(t),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=n.usedExisting?`${c.name} configured with existing container!
|
|
|
|
|
URL: ${n.url}`:`${c.name} deployed successfully!
|
|
|
|
|
URL: ${n.url}`;n.warning&&(i+=`
|
|
|
|
|
Would you like to use port ${o} instead?`))d.port=o;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification($?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:d.subdomain,ip:d.ip,createDns:d.dnsType==="private",port:d.port||c.defaultPort||null,sslType:d.sslType,dnsType:d.dnsType,tailscaleOnly:d.tailscaleOnly||!1,mediaPath:d.mediaPath||null,plexClaimToken:d.plexClaimToken||null,customVolumes:d.customVolumes||null}};$&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!d.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const o=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(n)})).json();if(o.success){const a={id:d.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:o.containerId,url:o.url,ip:d.ip,appTemplate:c.id,tailscaleOnly:d.tailscaleOnly||!1};i.push(a),safeSet(E,JSON.stringify(i)),window.APPS&&!window.APPS.some(t=>t.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let r=o.usedExisting?`${c.name} configured with existing container!
|
|
|
|
|
URL: ${o.url}`:`${c.name} deployed successfully!
|
|
|
|
|
URL: ${o.url}`;o.warning&&(r+=`
|
|
|
|
|
|
|
|
|
|
\u26A0 Warning: ${n.warning}`),showNotification(i,"success",8e3),delete c._useExisting,delete c._existingContainer,n.url&&n.url.startsWith("https://")&&C(n.url,c.name),n.setupInstructions&&n.setupInstructions.length>0&&setTimeout(()=>{const o=n.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(n.error||"Deployment failed")}catch(a){b.logError("[AppSelector] Deployment",a,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${a.message}`,"error",8e3)}}}async function C(r,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let s=0;const h=12,u=async()=>{s++;try{const a=await fetch(r,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return s<h?setTimeout(u,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
|
|
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(u,3e3)}function R(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(s=>s.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{I(),T.classList.add("show")}),wireModal(T,document.getElementById("app-selector-cancel"));const M=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const r=JSON.parse(M.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),s=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(u=>{s.push({hostPath:u.value.trim(),containerPath:u.dataset.containerPath})});const h={appTemplate:r,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes: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(!h.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(r.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}M.classList.remove("show"),$(h)}),wireModal(M);const j=document.getElementById("folder-browser-modal"),B=document.getElementById("folder-browser-path"),A=document.getElementById("folder-browser-list"),w=document.getElementById("folder-browser-selected"),z=document.getElementById("folder-browser-selected-list");let f="",p=[],y=null;window.openFolderBrowser=function(r){y=r,p=r.value.split(",").map(c=>c.trim()).filter(c=>c),f="",m(),v(""),j.classList.add("show")};async function v(r){B.textContent=r||"Select a drive...",A.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(r)}`)).json();if(!s.success){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(s.error)}</div>`;return}f=s.path||"",B.textContent=f||"Select a drive...";let h="";s.parent&&s.parent!==s.path&&(h+=`<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;">
|
|
|
|
|
\u26A0 Warning: ${o.warning}`),showNotification(r,"success",8e3),delete c._useExisting,delete c._existingContainer,o.url&&o.url.startsWith("https://")&&B(o.url,c.name),o.setupInstructions&&o.setupInstructions.length>0&&setTimeout(()=>{const t=o.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${t}`,"info",1e4)},1e3)}else throw new Error(o.error||"Deployment failed")}catch(n){h.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function B(d,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let i=0;const $=12,y=async()=>{i++;try{const n=await fetch(d,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return i<$?setTimeout(y,5e3):showNotification(`\u26A0\uFE0F ${c} deployed but SSL certificate may still be generating.
|
|
|
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(y,3e3)}function S(){safeGetJSON(E,[]).forEach(c=>{window.APPS.some(i=>i.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),wireModal(N,document.getElementById("app-selector-cancel"));const T=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const d=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),i=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(y=>{i.push({hostPath:y.value.trim(),containerPath:y.dataset.containerPath})});const $={appTemplate:d,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:i.length>0?i:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!$.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(d.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),k($)}),wireModal(T);const j=document.getElementById("folder-browser-modal"),H=document.getElementById("folder-browser-path"),R=document.getElementById("folder-browser-list"),x=document.getElementById("folder-browser-selected"),D=document.getElementById("folder-browser-selected-list");let g="",u=[],f=null;window.openFolderBrowser=function(d){f=d,u=d.value.split(",").map(c=>c.trim()).filter(c=>c),g="",p(),m(""),j.classList.add("show")};async function m(d){H.textContent=d||"Select a drive...",R.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const i=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(d)}`)).json();if(!i.success){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(i.error)}</div>`;return}g=i.path||"",H.textContent=g||"Select a drive...";let $="";i.parent&&i.parent!==i.path&&($+=`<div class="folder-item" data-path="${escapeHtml(i.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
|
|
|
|
<span style="color: var(--muted);">.. Parent Directory</span>
|
|
|
|
|
</div>`),s.items.length===0&&!s.parent?h+='<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?h+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':s.items.forEach(u=>{const a=u.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=p.includes(u.path),n=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";h+=`<div class="folder-item" data-path="${escapeHtml(u.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${n}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${a}</span>
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(u.name)}</span>
|
|
|
|
|
</div>`),i.items.length===0&&!i.parent?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':i.items.length===0?$+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':i.items.forEach(y=>{const n=y.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=u.includes(y.path),o=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";$+=`<div class="folder-item" data-path="${escapeHtml(y.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${o}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${n}</span>
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(y.name)}</span>
|
|
|
|
|
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
|
|
|
|
</div>`}),A.innerHTML=h,A.querySelectorAll(".folder-item").forEach(u=>{u.addEventListener("click",()=>{v(u.dataset.path)}),u.addEventListener("mouseenter",()=>{u.style.background="var(--card-bg)"}),u.addEventListener("mouseleave",()=>{const a=p.includes(u.dataset.path);u.style.background=a?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){A.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function m(){if(p.length===0){w.style.display="none";return}w.style.display="block",z.innerHTML=p.map(r=>`
|
|
|
|
|
</div>`}),R.innerHTML=$,R.querySelectorAll(".folder-item").forEach(y=>{y.addEventListener("click",()=>{m(y.dataset.path)}),y.addEventListener("mouseenter",()=>{y.style.background="var(--card-bg)"}),y.addEventListener("mouseleave",()=>{const n=u.includes(y.dataset.path);y.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){R.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function p(){if(u.length===0){x.style.display="none";return}x.style.display="block",D.innerHTML=u.map(d=>`
|
|
|
|
|
<span style="padding: 6px 12px; background: var(--card-bg); border: 1px solid var(--success); border-radius: 4px; display: flex; align-items: center; gap: 6px;">
|
|
|
|
|
${escapeHtml(r)}
|
|
|
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(r)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
|
|
|
${escapeHtml(d)}
|
|
|
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(d)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
|
|
|
</span>
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(r){p=p.filter(c=>c!==r),m(),v(f)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{f&&!p.includes(f)&&(p.push(f),m(),v(f))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{y&&(y.value=p.join(", ")),j.classList.remove("show")}),R()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(d){u=u.filter(c=>c!==d),p(),m(g)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{g&&!u.includes(g)&&(u.push(g),p(),m(g))}),wireModal(j,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{f&&(f.value=u.join(", ")),j.classList.remove("show")}),S()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 620px; max-width: 740px;">
|
|
|
|
|
<h3 id="recipe-deploy-title">Deploy Recipe</h3>
|
|
|
|
|
|
|
|
|
@@ -445,70 +445,70 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);let b=null,E=null,N=null,S=1,T=!1;const P=document.getElementById("recipe-deploy-modal"),L=document.getElementById("recipe-cancel"),H=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(P,L);async function I(){try{const f=await fetch("/api/v1/recipes/templates"),p=await f.json();if(p.success)return b=p.templates,E=p.categories,!0;if(f.status===403)return T=!1,!1}catch(f){console.warn("Failed to fetch recipe templates:",f.message)}return!1}async function k(){try{T=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{T=!1}return T}window.renderRecipeCards=async function(f){await k();let p;if(T&&b?p=b:p=x(),!p||p.length===0)return;const y=document.createElement("div");y.className="app-category-header",y.innerHTML="\u{1F9EA} Recipes",y.style.borderBottomColor="#8e44ad",f.appendChild(y);const v=Array.isArray(p)?p:Object.values(p);v.sort((m,r)=>(r.popularity||0)-(m.popularity||0));for(const m of v){const r=document.createElement("div");r.className="app-option",r.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${m.componentCount||m.components?.length||"?"} apps</div>`,s=T?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';r.innerHTML=`
|
|
|
|
|
${s}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(m.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(m.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(m.description||"")}</div>
|
|
|
|
|
</div>`);let h=null,E=null,P=null,w=1,N=!1;const O=document.getElementById("recipe-deploy-modal"),z=document.getElementById("recipe-cancel"),A=document.getElementById("recipe-prev"),v=document.getElementById("recipe-next");wireModal(O,z);async function L(){try{const g=await fetch("/api/v1/recipes/templates"),u=await g.json();if(u.success)return h=u.templates,E=u.categories,!0;if(g.status===403)return N=!1,!1}catch(g){console.warn("Failed to fetch recipe templates:",g.message)}return!1}async function b(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(g){await b();let u;if(N&&h?u=h:u=M(),!u||u.length===0)return;const f=document.createElement("div");f.className="app-category-header",f.innerHTML="\u{1F9EA} Recipes",f.style.borderBottomColor="#8e44ad",g.appendChild(f);const m=Array.isArray(u)?u:Object.values(u);m.sort((p,d)=>(d.popularity||0)-(p.popularity||0));for(const p of m){const d=document.createElement("div");d.className="app-option",d.style.position="relative";const c=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${p.componentCount||p.components?.length||"?"} apps</div>`,i=N?"":'<div style="position: absolute; top: 6px; right: 6px; font-size: 0.65rem; padding: 2px 8px; border-radius: 10px; background: rgba(241,196,15,0.2); color: #f1c40f; font-weight: 600;">PREMIUM</div>';d.innerHTML=`
|
|
|
|
|
${i}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(p.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(p.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(p.description||"")}</div>
|
|
|
|
|
${c}
|
|
|
|
|
`,r.onclick=()=>{if(!T){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}$(m)},f.appendChild(r)}};function x(){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 $(f){N=f,S=1;const p=document.getElementById("app-selector-modal");p&&p.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${f.name}`,C(),R(),P.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(f=>{const p=parseInt(f.dataset.step);f.classList.toggle("active",p===S),f.classList.toggle("completed",p<S)});for(let f=1;f<=4;f++){const p=document.getElementById(`recipe-step-${f}`);p&&(p.style.display=f===S?"":"none")}H.style.display=S>1&&S<4?"":"none",S===4?(g.style.display="none",L.textContent="Close"):S===3?(g.textContent="\u{1F680} Deploy",g.style.display="",L.textContent="Cancel"):(g.textContent="Next",g.style.display="",L.textContent="Cancel")}function R(){const f=document.getElementById("recipe-component-list");f.innerHTML="";const p=N.components||[];for(const y of p){const v=document.createElement("div");v.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const m=y.required,r=y.internal;v.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${m?"checked disabled":"checked"} data-component-id="${y.id}"
|
|
|
|
|
`,d.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(p)},g.appendChild(d)}};function M(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function k(g){P=g,w=1;const u=document.getElementById("app-selector-modal");u&&u.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${g.name}`,B(),S(),O.classList.add("show")}function B(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(g=>{const u=parseInt(g.dataset.step);g.classList.toggle("active",u===w),g.classList.toggle("completed",u<w)});for(let g=1;g<=4;g++){const u=document.getElementById(`recipe-step-${g}`);u&&(u.style.display=g===w?"":"none")}A.style.display=w>1&&w<4?"":"none",w===4?(v.style.display="none",z.textContent="Close"):w===3?(v.textContent="\u{1F680} Deploy",v.style.display="",z.textContent="Cancel"):(v.textContent="Next",v.style.display="",z.textContent="Cancel")}function S(){const g=document.getElementById("recipe-component-list");g.innerHTML="";const u=P.components||[];for(const f of u){const m=document.createElement("div");m.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const p=f.required,d=f.internal;m.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${p?"checked disabled":"checked"} data-component-id="${f.id}"
|
|
|
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(y.role||y.id)}</div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(f.role||f.id)}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">
|
|
|
|
|
${y.templateRef?escapeHtml(y.templateRef):"Built-in"}
|
|
|
|
|
${m?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
|
|
|
${r?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
|
|
|
${f.templateRef?escapeHtml(f.templateRef):"Built-in"}
|
|
|
|
|
${p?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
|
|
|
${d?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
|
|
|
</div>
|
|
|
|
|
${y.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(y.note)}</div>`:""}
|
|
|
|
|
${f.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(f.note)}</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,f.appendChild(v)}}function M(){const f=document.getElementById("recipe-volumes-section"),p=document.getElementById("recipe-volume-list"),y=N.sharedVolumes;if(y&&Object.keys(y).length>0){f.style.display="",p.innerHTML="";for(const[v,m]of Object.entries(y)){const r=document.createElement("div");r.style.cssText="display: grid; gap: 4px;",r.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(m.label||v)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${v}" value="${escapeHtml(m.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(m.defaultPath||"/path")}"
|
|
|
|
|
`,g.appendChild(m)}}function T(){const g=document.getElementById("recipe-volumes-section"),u=document.getElementById("recipe-volume-list"),f=P.sharedVolumes;if(f&&Object.keys(f).length>0){g.style.display="",u.innerHTML="";for(const[m,p]of Object.entries(f)){const d=document.createElement("div");d.style.cssText="display: grid; gap: 4px;",d.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(p.label||m)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${m}" value="${escapeHtml(p.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(p.defaultPath||"/path")}"
|
|
|
|
|
style="width: 100%; padding: 8px 12px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font-family: monospace; font-size: 0.85rem;" />
|
|
|
|
|
<div class="form-hint-sm">${escapeHtml(m.description||"")}</div>
|
|
|
|
|
`,p.appendChild(r)}}else f.style.display="none"}function j(){const f=document.getElementById("recipe-review-content"),p=B(),y=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),v={};y.forEach(s=>{v[s.dataset.volumeKey]=s.value});const m=document.getElementById("recipe-timezone").value||"UTC",r=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;f.innerHTML=`
|
|
|
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(N.name)}</div>
|
|
|
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(N.description||"")}</div>
|
|
|
|
|
<div class="form-hint-sm">${escapeHtml(p.description||"")}</div>
|
|
|
|
|
`,u.appendChild(d)}}else g.style.display="none"}function j(){const g=document.getElementById("recipe-review-content"),u=H(),f=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};f.forEach(i=>{m[i.dataset.volumeKey]=i.value});const p=document.getElementById("recipe-timezone").value||"UTC",d=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;g.innerHTML=`
|
|
|
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(P.name)}</div>
|
|
|
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(P.description||"")}</div>
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Components (${p.length}):</strong>
|
|
|
|
|
<strong>Components (${u.length}):</strong>
|
|
|
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
|
|
|
${p.map(s=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
\u2022 <strong>${escapeHtml(s.role||s.id)}</strong> ${s.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
|
|
|
${u.map(i=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
\u2022 <strong>${escapeHtml(i.role||i.id)}</strong> ${i.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
|
|
|
</div>`).join("")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${Object.keys(v).length>0?`<div style="margin-bottom: 12px;">
|
|
|
|
|
${Object.keys(m).length>0?`<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Volumes:</strong>
|
|
|
|
|
${Object.entries(v).map(([s,h])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${s}: ${escapeHtml(h)}</div>`).join("")}
|
|
|
|
|
${Object.entries(m).map(([i,$])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${i}: ${escapeHtml($)}</div>`).join("")}
|
|
|
|
|
</div>`:""}
|
|
|
|
|
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
|
|
|
Timezone: ${escapeHtml(m)} • IP: ${escapeHtml(r)} ${c?"• Tailscale only":""}
|
|
|
|
|
Timezone: ${escapeHtml(p)} • IP: ${escapeHtml(d)} ${c?"• Tailscale only":""}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${N.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(N.network.name)}</code></div>`:""}
|
|
|
|
|
`}function B(){const f=document.querySelectorAll("#recipe-component-list input[data-component-id]"),p=new Set;f.forEach(v=>{v.checked&&p.add(v.dataset.componentId)});const y=N.components||[];return y.filter(v=>v.required).forEach(v=>p.add(v.id)),y.filter(v=>p.has(v.id))}async function A(){const f=document.getElementById("recipe-progress-list"),p=document.getElementById("recipe-deploy-result");p.style.display="none",f.innerHTML="";const y=B();for(const c of y){const s=document.createElement("div");s.id=`recipe-progress-${c.id}`,s.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;",s.innerHTML=`
|
|
|
|
|
${P.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(P.network.name)}</code></div>`:""}
|
|
|
|
|
`}function H(){const g=document.querySelectorAll("#recipe-component-list input[data-component-id]"),u=new Set;g.forEach(m=>{m.checked&&u.add(m.dataset.componentId)});const f=P.components||[];return f.filter(m=>m.required).forEach(m=>u.add(m.id)),f.filter(m=>u.has(m.id))}async function R(){const g=document.getElementById("recipe-progress-list"),u=document.getElementById("recipe-deploy-result");u.style.display="none",g.innerHTML="";const f=H();for(const c of f){const i=document.createElement("div");i.id=`recipe-progress-${c.id}`,i.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",i.innerHTML=`
|
|
|
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
|
|
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
|
|
|
`,f.appendChild(s)}const v=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),m={};v.forEach(c=>{m[c.dataset.volumeKey]=c.value});const r={selectedComponents:y.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:m},componentOverrides:{}};for(const c of y)w(c.id,"deploying","Deploying...");try{const s=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:N.id,config:r})})).json();if(s.success){for(const h of s.deployed||[])w(h.id,"success",h.url?`Running \u2192 ${h.url}`:"Running");for(const h of s.errors||[])w(h.componentId,"error",h.error);p.style.display="",p.innerHTML=`
|
|
|
|
|
`,g.appendChild(i)}const m=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),p={};m.forEach(c=>{p[c.dataset.volumeKey]=c.value});const d={selectedComponents:f.map(c=>c.id),sharedConfig:{ip:document.getElementById("recipe-ip").value||"host.docker.internal",timezone:document.getElementById("recipe-timezone").value||"UTC",tailscaleOnly:document.getElementById("recipe-tailscale").checked,volumes:p},componentOverrides:{}};for(const c of f)x(c.id,"deploying","Deploying...");try{const i=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:P.id,config:d})})).json();if(i.success){for(const $ of i.deployed||[])x($.id,"success",$.url?`Running \u2192 ${$.url}`:"Running");for(const $ of i.errors||[])x($.componentId,"error",$.error);u.style.display="",u.innerHTML=`
|
|
|
|
|
<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(s.message||"Deployed!")}</div>
|
|
|
|
|
${s.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
|
|
|
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(i.message||"Deployed!")}</div>
|
|
|
|
|
${i.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
|
|
|
<strong>Setup tips:</strong>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${s.setupInstructions.map(h=>`<li>${escapeHtml(h)}</li>`).join("")}</ul>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${i.setupInstructions.map($=>`<li>${escapeHtml($)}</li>`).join("")}</ul>
|
|
|
|
|
</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,showNotification(`${N.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else p.style.display="",p.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(s.error||"Unknown error")}
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${s.error}`,"error",5e3)}catch(c){p.style.display="",p.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(`${P.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
|
|
|
<strong>Deployment failed:</strong> ${escapeHtml(i.error||"Unknown error")}
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${i.error}`,"error",5e3)}catch(c){u.style.display="",u.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
|
|
|
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
|
|
|
|
</div>`}}function w(f,p,y){const v=document.getElementById(`recipe-progress-${f}`);if(!v)return;const m=v.querySelector(".recipe-progress-icon"),r=v.querySelector(".recipe-progress-status");p==="deploying"?(m.textContent="\u23F3",r.style.color="var(--accent)"):p==="success"?(m.textContent="\u2705",r.style.color="var(--ok-fg)"):p==="error"&&(m.textContent="\u274C",r.style.color="var(--bad-fg)"),r.textContent=y}g.addEventListener("click",()=>{if(S===3){S=4,C(),A();return}S<3&&(S++,C(),S===2&&M(),S===3&&j())}),H.addEventListener("click",()=>{S>1&&S<4&&(S--,C())}),window.groupRecipeCards=function(){const f=document.querySelectorAll(".service-card[data-recipe-id]");if(f.length===0)return;const p={};f.forEach(y=>{const v=y.dataset.recipeId;p[v]||(p[v]=[]),p[v].push(y)});for(const[y,v]of Object.entries(p))v.length<2||v.forEach((m,r)=>{if(m.style.borderLeft="3px solid rgba(142,68,173,0.5)",r===0){let c=m.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=y.replace(/-/g," "),m.style.position="relative",m.appendChild(c))}})},window.manageRecipe=async function(f,p){const y=`/api/v1/recipes/${f}/${p}`,v=p==="remove"?"DELETE":"POST",m=p==="remove"?`/api/v1/recipes/${f}`:y;if(!(p==="remove"&&!confirm(`Remove the entire ${f} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(m,{method:v})).json();c.success?(showNotification(`Recipe ${p}: ${c.results?.filter(s=>s.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${p} failed: ${c.error}`,"error",5e3)}catch(r){showNotification(`Network error: ${r.message}`,"error",5e3)}};const z=document.createElement("style");z.textContent=`
|
|
|
|
|
</div>`}}function x(g,u,f){const m=document.getElementById(`recipe-progress-${g}`);if(!m)return;const p=m.querySelector(".recipe-progress-icon"),d=m.querySelector(".recipe-progress-status");u==="deploying"?(p.textContent="\u23F3",d.style.color="var(--accent)"):u==="success"?(p.textContent="\u2705",d.style.color="var(--ok-fg)"):u==="error"&&(p.textContent="\u274C",d.style.color="var(--bad-fg)"),d.textContent=f}v.addEventListener("click",()=>{if(w===3){w=4,B(),R();return}w<3&&(w++,B(),w===2&&T(),w===3&&j())}),A.addEventListener("click",()=>{w>1&&w<4&&(w--,B())}),window.groupRecipeCards=function(){const g=document.querySelectorAll(".service-card[data-recipe-id]");if(g.length===0)return;const u={};g.forEach(f=>{const m=f.dataset.recipeId;u[m]||(u[m]=[]),u[m].push(f)});for(const[f,m]of Object.entries(u))m.length<2||m.forEach((p,d)=>{if(p.style.borderLeft="3px solid rgba(142,68,173,0.5)",d===0){let c=p.querySelector(".recipe-group-label");c||(c=document.createElement("div"),c.className="recipe-group-label",c.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",c.textContent=f.replace(/-/g," "),p.style.position="relative",p.appendChild(c))}})},window.manageRecipe=async function(g,u){const f=`/api/v1/recipes/${g}/${u}`,m=u==="remove"?"DELETE":"POST",p=u==="remove"?`/api/v1/recipes/${g}`:f;if(!(u==="remove"&&!confirm(`Remove the entire ${g} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(p,{method:m})).json();c.success?(showNotification(`Recipe ${u}: ${c.results?.filter(i=>i.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${u} failed: ${c.error}`,"error",5e3)}catch(d){showNotification(`Network error: ${d.message}`,"error",5e3)}};const D=document.createElement("style");D.textContent=`
|
|
|
|
|
.recipe-step {
|
|
|
|
|
flex: 1;
|
|
|
|
|
text-align: center;
|
|
|
|
@@ -550,16 +550,16 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
.recipe-step-panel {
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
}
|
|
|
|
|
`,document.head.appendChild(z),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),E=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const N=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await N.json();if(N.ok&&S.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(N){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${N.message}`,"error"),setTimeout(()=>{b.textContent=E,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const b=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),N=document.getElementById("view-error-logs"),S=document.getElementById("error-log-refresh"),T=document.getElementById("error-log-clear"),P=document.getElementById("error-log-close");async function L(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const I=await(await fetch("/api/v1/error-logs")).json();I.success&&I.logs?I.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=I.logs.map(k=>`
|
|
|
|
|
`,document.head.appendChild(D),b()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const h=document.getElementById("reload-caddy-top"),E=h.textContent;try{h.textContent="\u23F3 Reloading...",h.disabled=!0;const P=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),w=await P.json();if(P.ok&&w.success)h.textContent="\u2705 Reloaded!",setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3);else throw new Error(w.error||"Reload failed")}catch(P){h.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${P.message}`,"error"),setTimeout(()=>{h.textContent=E,h.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",'<div id="error-log-modal" class="logs-modal"><div class="logs-modal-content"><div class="logs-header"><h3>\u{1F4CB} Error Logs</h3><div class="logs-controls"><button id="error-log-refresh" style="padding:4px 12px!important;font-size:.85rem!important">\u{1F504} Refresh</button><button id="error-log-clear" style="padding:4px 12px!important;font-size:.85rem!important;background:color-mix(in srgb,var(--bad-fg) 15%,transparent)!important;border-color:var(--bad-fg)!important;color:var(--bad-fg)!important">\u{1F5D1}\uFE0F Clear</button><button id="error-log-close" class="close-btn">\u2715</button></div></div><div class="logs-container"><div id="error-log-content" class="logs-content"><div class="logs-loading">Loading error logs...</div></div></div></div></div>');const h=document.getElementById("error-log-modal"),E=document.getElementById("error-log-content"),P=document.getElementById("view-error-logs"),w=document.getElementById("error-log-refresh"),N=document.getElementById("error-log-clear"),O=document.getElementById("error-log-close");async function z(){E.innerHTML='<div class="logs-loading">Loading error logs...</div>';try{const L=await(await fetch("/api/v1/error-logs")).json();L.success&&L.logs?L.logs.length===0?E.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">\u2705 No errors logged! Everything is working smoothly.</div>':E.innerHTML=L.logs.map(b=>`
|
|
|
|
|
<div class="log-entry error">
|
|
|
|
|
<span class="log-timestamp">${new Date(k.timestamp).toLocaleString()}</span>
|
|
|
|
|
<span class="log-timestamp">${new Date(b.timestamp).toLocaleString()}</span>
|
|
|
|
|
<span class="log-level">ERROR</span>
|
|
|
|
|
<div class="log-message">
|
|
|
|
|
<strong>${escapeHtml(k.context)}</strong>: ${escapeHtml(k.error)}
|
|
|
|
|
${k.details?`<br><small style="opacity: 0.7;">${escapeHtml(k.details)}</small>`:""}
|
|
|
|
|
<strong>${escapeHtml(b.context)}</strong>: ${escapeHtml(b.error)}
|
|
|
|
|
${b.details?`<br><small style="opacity: 0.7;">${escapeHtml(b.details)}</small>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(g){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(g.message)}</div>`}}async function H(){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),L()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(g){showNotification(`\u274C Error: ${g.message}`,"error",3e3)}}N?.addEventListener("click",()=>{b.classList.add("show"),L()}),S?.addEventListener("click",L),T?.addEventListener("click",H),wireModal(b,P)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
|
|
|
|
`).join(""):E.innerHTML='<div style="padding: 20px; color: var(--bad-fg);">\u274C Failed to load error logs</div>'}catch(v){E.innerHTML=`<div style="padding: 20px; color: var(--bad-fg);">\u274C Error loading logs: ${escapeHtml(v.message)}</div>`}}async function A(){if(confirm("Clear all error logs?"))try{(await(await secureFetch("/api/v1/error-logs",{method:"DELETE"})).json()).success?(showNotification("\u2705 Error logs cleared","success",3e3),z()):showNotification("\u274C Failed to clear logs","error",3e3)}catch(v){showNotification(`\u274C Error: ${v.message}`,"error",3e3)}}P?.addEventListener("click",()=>{h.classList.add("show"),z()}),w?.addEventListener("click",z),N?.addEventListener("click",A),wireModal(h,O)})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
|
|
|
|
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
|
|
|
|
<div>
|
|
|
|
@@ -609,14 +609,14 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="cl-close-btn" class="btn-secondary">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),N=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),T=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),L=document.getElementById("cl-stream"),H=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),I=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),x=document.getElementById("cl-stream-status"),$=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),R=document.getElementById("cl-line-count"),M=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),B=document.getElementById("cl-status"),A=document.getElementById("cl-created");let w=null,z=[],f=[],p=null,y=!1,v=null;function m(d){if(!d)return"-";const l=new Date(d);return isNaN(l.getTime())?d:l.toLocaleString()}function r(d){if(!d)return"";const l=document.createElement("div");return l.textContent=d,l.innerHTML}function c(d,l){const D=d.stream==="stderr"?"log-stderr":"log-stdout",O=d.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
<div class="log-entry ${D}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
|
|
|
|
</div>`);const h=document.getElementById("container-logs-modal"),E=document.getElementById("cl-container-select"),P=document.getElementById("cl-log-content"),w=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),O=document.getElementById("cl-refresh"),z=document.getElementById("cl-stream"),A=document.getElementById("cl-download"),v=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),b=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),B=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),j=document.getElementById("cl-image"),H=document.getElementById("cl-status"),R=document.getElementById("cl-created");let x=null,D=[],g=[],u=null,f=!1,m=null;function p(s){if(!s)return"-";const l=new Date(s);return isNaN(l.getTime())?s:l.toLocaleString()}function d(s){if(!s)return"";const l=document.createElement("div");return l.textContent=s,l.innerHTML}function c(s,l){const C=s.stream==="stderr"?"log-stderr":"log-stdout",I=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
<div class="log-entry ${C}" data-index="${l}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
|
|
|
|
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${l+1}</span>
|
|
|
|
|
<span class="log-stream-icon" style="color: ${d.stream==="stderr"?"#f59e0b":"#22c55e"};">${O}</span>
|
|
|
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${r(d.text)}</span>
|
|
|
|
|
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${I}</span>
|
|
|
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${d(s.text)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`}function s(d,l=""){if(!d||d.length===0){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',R.textContent="0 lines",M.textContent="0 filtered";return}if(z=d,f=l?d.filter(D=>D.text&&D.text.toLowerCase().includes(l.toLowerCase())):d,R.textContent=`${d.length} lines`,M.textContent=l?`${f.length} of ${d.length} shown`:`${d.length} shown`,f.length===0){N.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${r(l)}"</div>`;return}N.innerHTML=f.map((D,O)=>c(D,O)).join(""),N.scrollTop=N.scrollHeight}async function h(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],D=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(O=>{const F=document.createElement("option");F.value=O.id,F.textContent=`${O.name} (${O.image.split(":")[0]}) - ${O.status}`,F.dataset.name=O.name,F.dataset.image=O.image,F.dataset.status=O.status,F.dataset.created=O.created,E.appendChild(F)}),D&&E.querySelector(`option[value="${D}"]`)&&(E.value=D,u(D))}catch(d){console.error("Failed to load containers:",d)}}function u(d){const l=E.querySelector(`option[value="${d}"]`);l&&(j.textContent=l.dataset.image||"-",B.textContent=l.dataset.status||"-",B.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",A.textContent=m(l.dataset.created))}async function a(){const d=E.value;if(!d){N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}n(),w=d,u(d);const l=T.value,D=S.value.trim();N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const O=`/api/v1/logs/container/${d}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(O);F.logs&&F.logs.length>0?s(F.logs,D):(N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',R.textContent="0 lines",M.textContent="0 filtered")}catch(O){N.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${r(O.message)}</div>`}}function e(){const d=E.value;if(!d)return;n(),y=!0,L.textContent="\u23F9 Stop",x.style.display="flex",$.textContent="\u{1F7E2}",C.textContent="Connecting...";const l=`/api/v1/logs/stream/${d}`;p=new EventSource(l),p.onopen=()=>{$.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},p.onmessage=D=>{try{const O=JSON.parse(D.data);if(O.error){$.textContent="\u{1F534}",C.textContent=`Error: ${O.error}`;return}z.push(O),f.push(O),R.textContent=`${z.length} lines`,M.textContent=`${f.length} shown`;const F=S.value.trim();if(!F||O.text&&O.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(O,f.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",N.appendChild(U),N.scrollTop=N.scrollHeight}}catch(O){console.error("Error parsing log:",O)}},p.onerror=()=>{$.textContent="\u{1F534}",C.textContent="Disconnected",y=!1,L.textContent="\u25B6 Stream"},b._eventSource=p}function n(){p&&(p.close(),p=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),y=!1,L.textContent="\u25B6 Stream",x.style.display="none"}function t(){if(!z||z.length===0){showNotification("No logs to download","error");return}const d=E.querySelector(`option[value="${w}"]`)?.dataset.name||w,l=new Date().toISOString().replace(/[:.]/g,"-"),D=`${d}-logs-${l}.txt`,O=z.map(G=>{const W=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${W?W+" ":""}${X} ${G.text}`}).join(`
|
|
|
|
|
`),F=new Blob([O],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=D,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${z.length} log lines`,"success")}E?.addEventListener("change",()=>{a()}),T?.addEventListener("change",()=>{a()}),P?.addEventListener("click",()=>{a()}),L?.addEventListener("click",()=>{y?n():e()}),H?.addEventListener("click",()=>{t()}),g?.addEventListener("click",()=>{S.value="",s(z,"")}),S?.addEventListener("input",()=>{clearTimeout(v),v=setTimeout(()=>{s(z,S.value.trim())},300)}),S?.addEventListener("keydown",d=>{d.key==="Escape"&&(S.value="",s(z,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),h()});function o(){n(),b.classList.remove("show")}I?.addEventListener("click",o),k?.addEventListener("click",o),document.addEventListener("keydown",d=>{d.key==="Escape"&&b.classList.contains("show")&&o()}),b.addEventListener("click",d=>{d.target===b&&o()}),window.openContainerLogsModal=function(d,l){b.classList.add("show"),h().then(()=>{const D=Array.from(E.options).find(O=>O.value===d||O.dataset.name===l);D?(E.value=D.value,u(D.value),a()):d?(w=d,j.textContent=l||d,B.textContent="-",A.textContent="-",a()):N.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
|
|
|
|
`}function i(s,l=""){if(!s||s.length===0){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',S.textContent="0 lines",T.textContent="0 filtered";return}if(D=s,g=l?s.filter(C=>C.text&&C.text.toLowerCase().includes(l.toLowerCase())):s,S.textContent=`${s.length} lines`,T.textContent=l?`${g.length} of ${s.length} shown`:`${s.length} shown`,g.length===0){P.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${d(l)}"</div>`;return}P.innerHTML=g.map((C,I)=>c(C,I)).join(""),P.scrollTop=P.scrollHeight}async function $(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],C=E.value;E.innerHTML='<option value="">Select a container...</option>',l.forEach(I=>{const F=document.createElement("option");F.value=I.id,F.textContent=`${I.name} (${I.image.split(":")[0]}) - ${I.status}`,F.dataset.name=I.name,F.dataset.image=I.image,F.dataset.status=I.status,F.dataset.created=I.created,E.appendChild(F)}),C&&E.querySelector(`option[value="${C}"]`)&&(E.value=C,y(C))}catch(s){console.error("Failed to load containers:",s)}}function y(s){const l=E.querySelector(`option[value="${s}"]`);l&&(j.textContent=l.dataset.image||"-",H.textContent=l.dataset.status||"-",H.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",R.textContent=p(l.dataset.created))}async function n(){const s=E.value;if(!s){P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}o(),x=s,y(s);const l=N.value,C=w.value.trim();P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const I=`/api/v1/logs/container/${s}${l!=="all"?`?tail=${l}`:""}`,F=await getJSON(I);F.logs&&F.logs.length>0?i(F.logs,C):(P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',S.textContent="0 lines",T.textContent="0 filtered")}catch(I){P.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${d(I.message)}</div>`}}function e(){const s=E.value;if(!s)return;o(),f=!0,z.textContent="\u23F9 Stop",M.style.display="flex",k.textContent="\u{1F7E2}",B.textContent="Connecting...";const l=`/api/v1/logs/stream/${s}`;u=new EventSource(l),u.onopen=()=>{k.textContent="\u{1F7E2}",B.textContent="Connected - streaming logs"},u.onmessage=C=>{try{const I=JSON.parse(C.data);if(I.error){k.textContent="\u{1F534}",B.textContent=`Error: ${I.error}`;return}D.push(I),g.push(I),S.textContent=`${D.length} lines`,T.textContent=`${g.length} shown`;const F=w.value.trim();if(!F||I.text&&I.text.toLowerCase().includes(F.toLowerCase())){const q=document.createElement("div");q.innerHTML=c(I,g.length-1);const U=q.firstElementChild;U.style.background="#1a3a1a",P.appendChild(U),P.scrollTop=P.scrollHeight}}catch(I){console.error("Error parsing log:",I)}},u.onerror=()=>{k.textContent="\u{1F534}",B.textContent="Disconnected",f=!1,z.textContent="\u25B6 Stream"},h._eventSource=u}function o(){u&&(u.close(),u=null),h._eventSource&&(h._eventSource.close(),h._eventSource=null),f=!1,z.textContent="\u25B6 Stream",M.style.display="none"}function a(){if(!D||D.length===0){showNotification("No logs to download","error");return}const s=E.querySelector(`option[value="${x}"]`)?.dataset.name||x,l=new Date().toISOString().replace(/[:.]/g,"-"),C=`${s}-logs-${l}.txt`,I=D.map(G=>{const J=G.timestamp||"",X=G.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${G.text}`}).join(`
|
|
|
|
|
`),F=new Blob([I],{type:"text/plain"}),q=URL.createObjectURL(F),U=document.createElement("a");U.href=q,U.download=C,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(q),showNotification(`Downloaded ${D.length} log lines`,"success")}E?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),O?.addEventListener("click",()=>{n()}),z?.addEventListener("click",()=>{f?o():e()}),A?.addEventListener("click",()=>{a()}),v?.addEventListener("click",()=>{w.value="",i(D,"")}),w?.addEventListener("input",()=>{clearTimeout(m),m=setTimeout(()=>{i(D,w.value.trim())},300)}),w?.addEventListener("keydown",s=>{s.key==="Escape"&&(w.value="",i(D,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{h.classList.add("show"),$()});function t(){o(),h.classList.remove("show")}L?.addEventListener("click",t),b?.addEventListener("click",t),document.addEventListener("keydown",s=>{s.key==="Escape"&&h.classList.contains("show")&&t()}),h.addEventListener("click",s=>{s.target===h&&t()}),window.openContainerLogsModal=function(s,l){h.classList.add("show"),$().then(()=>{const C=Array.from(E.options).find(I=>I.value===s||I.dataset.name===l);C?(E.value=C.value,y(C.value),n()):s?(x=s,j.textContent=l||s,H.textContent="-",R.textContent="-",n()):P.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 850px;">
|
|
|
|
|
<h3>\u{1F4BE} Container Snapshots</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -670,7 +670,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="snapshot-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),N=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),T=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),L=document.getElementById("snapshot-create-status");let H=null;async function g(){try{const R=await(await fetch("/api/v1/containers")).json();if(!R.success||!R.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const M of R.containers){const j=document.createElement("option");j.value=M.id,j.textContent=`${M.name||M.id} (${M.image||"unknown"})`,j.dataset.name=M.name,j.dataset.image=M.image,j.dataset.status=M.status,j.dataset.created=M.created,S.appendChild(j)}}catch(C){console.error("Failed to load containers:",C)}}function I(C){if(!C||!C.value){T.style.display="none",H=null;return}H=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),T.style.display=""}async function k(){if(!H){L.textContent="Please select a container first",L.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){L.textContent="Please enter a snapshot name",L.style.color="var(--bad-fg)";return}const R=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",L.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(H)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:R})})).json();j.success?(L.textContent=`\u2713 Snapshot "${C}" created successfully`,L.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(L.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,L.style.color="var(--bad-fg)")}catch(M){L.textContent=`\u2717 Error: ${M.message}`,L.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function x(){b.classList.add("show"),g()}function $(){b.classList.remove("show"),T.style.display="none",H=null,S.selectedIndex=0}E?.addEventListener("click",x),N?.addEventListener("click",$),wireModal(b,N),S?.addEventListener("change",C=>{const R=S.options[S.selectedIndex];I(R)}),P?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(R=>R.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(R=>R.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
</div>`);const h=document.getElementById("snapshot-modal"),E=document.getElementById("snapshot-btn"),P=document.getElementById("snapshot-close"),w=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),O=document.getElementById("snapshot-create-btn"),z=document.getElementById("snapshot-create-status");let A=null;async function v(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;w.innerHTML='<option value="">-- Select a container --</option>';for(const T of S.containers){const j=document.createElement("option");j.value=T.id,j.textContent=`${T.name||T.id} (${T.image||"unknown"})`,j.dataset.name=T.name,j.dataset.image=T.image,j.dataset.status=T.status,j.dataset.created=T.created,w.appendChild(j)}}catch(B){console.error("Failed to load containers:",B)}}function L(B){if(!B||!B.value){N.style.display="none",A=null;return}A=B.value,document.getElementById("snapshot-image").textContent=B.dataset.image||"-",document.getElementById("snapshot-status").textContent=B.dataset.status||"-",document.getElementById("snapshot-created").textContent=B.dataset.created?new Date(B.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=B.value.substring(0,12),N.style.display=""}async function b(){if(!A){z.textContent="Please select a container first",z.style.color="var(--bad-fg)";return}const B=document.getElementById("snapshot-name").value.trim();if(!B){z.textContent="Please enter a snapshot name",z.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;O.disabled=!0,O.textContent="Creating...",z.textContent="";try{const j=await(await fetch(`/api/v1/containers/${encodeURIComponent(A)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:B,leaveRunning:S})})).json();j.success?(z.textContent=`\u2713 Snapshot "${B}" created successfully`,z.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(z.textContent=`\u2717 Failed: ${j.error||"Unknown error"}`,z.style.color="var(--bad-fg)")}catch(T){z.textContent=`\u2717 Error: ${T.message}`,z.style.color="var(--bad-fg)"}finally{O.disabled=!1,O.textContent="\u{1F4BE} Create Snapshot"}}function M(){h.classList.add("show"),v()}function k(){h.classList.remove("show"),N.style.display="none",A=null,w.selectedIndex=0}E?.addEventListener("click",M),P?.addEventListener("click",k),wireModal(h,P),w?.addEventListener("change",B=>{const S=w.options[w.selectedIndex];L(S)}),O?.addEventListener("click",b),h?.querySelectorAll(".panel-tab").forEach(B=>{B.addEventListener("click",()=>{h.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),h.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),B.classList.add("active"),h.querySelector(`#${B.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 600px; max-width: 720px;">
|
|
|
|
|
<h3>\u{1F3AC} Smart Arr Connect</h3>
|
|
|
|
|
<p style="color: var(--muted); margin: 0 0 16px; font-size: 0.9rem;">
|
|
|
|
@@ -749,73 +749,73 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="arr-setup-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),N=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),T=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),L=document.getElementById("smart-phase-progress"),H=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),I=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),x=document.getElementById("smart-results-content"),$=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let R=null;const M={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function B(v){T.style.display=v==="detect"?"block":"none",P.style.display=v==="credentials"?"block":"none",L.style.display=v==="progress"?"block":"none",H.style.display=v==="results"?"block":"none"}function A(v){const m={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"}},r=m[v]||m.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${r.bg} 20%, transparent); color: ${r.bg};">${r.icon} ${r.text}</span>`}async function w(){B("detect"),g.style.display="none";try{if(R=await(await fetch("/api/v1/arr/smart-detect")).json(),!R.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(R.error)}</div>`,g.style.display="block";return}let m='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,s]of Object.entries(R.services)){const h=M[c]||"\u{1F4E6}",u=j[c]||c,a=s.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(s.source)}</span>`:"",e=s.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(s.version)}</span>`:"",n=(s.hasApiKey||s.hasToken)&&s.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";m+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<span style="font-size: 1.1rem;">${h}</span>
|
|
|
|
|
</div>`);const h=document.getElementById("arr-setup-modal"),E=document.getElementById("arr-setup-btn"),P=document.getElementById("arr-setup-cancel"),w=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),O=document.getElementById("smart-phase-credentials"),z=document.getElementById("smart-phase-progress"),A=document.getElementById("smart-phase-results"),v=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),b=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),B=document.getElementById("smart-retry-btn");let S=null;const T={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},j={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function H(m){N.style.display=m==="detect"?"block":"none",O.style.display=m==="credentials"?"block":"none",z.style.display=m==="progress"?"block":"none",A.style.display=m==="results"?"block":"none"}function R(m){const p={connected:{bg:"var(--ok-fg)",icon:"✓",text:"Connected"},needs_key:{bg:"#f39c12",icon:"🔑",text:"Needs API Key"},not_found:{bg:"var(--muted)",icon:"—",text:"Not Found"},error:{bg:"var(--bad-fg)",icon:"✗",text:"Error"}},d=p[m]||p.not_found;return`<span style="display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: color-mix(in srgb, ${d.bg} 20%, transparent); color: ${d.bg};">${d.icon} ${d.text}</span>`}async function x(){H("detect"),v.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,v.style.display="block";return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,i]of Object.entries(S.services)){const $=T[c]||"\u{1F4E6}",y=j[c]||c,n=i.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(i.source)}</span>`:"",e=i.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(i.version)}</span>`:"",o=(i.hasApiKey||i.hasToken)&&i.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";p+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<span style="font-size: 1.1rem;">${$}</span>
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${u}</div>
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${y}</div>
|
|
|
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
|
|
|
${a} ${e} ${n}
|
|
|
|
|
${n} ${e} ${o}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
${A(s.status)}
|
|
|
|
|
</div>`}m+="</div>";const r=R.summary;m+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
|
|
|
|
${escapeHtml(String(r.fullyConnected))}/${escapeHtml(String(r.totalDetected+(5-r.totalDetected)))} services detected ·
|
|
|
|
|
${escapeHtml(String(r.fullyConnected))} connected${r.needsApiKey>0?` · <strong>${escapeHtml(String(r.needsApiKey))} needs API key</strong>`:""}
|
|
|
|
|
</div>`,g.innerHTML=m,g.style.display="block",z(R),setTimeout(()=>{B("credentials")},800)}catch(v){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(v.message)}</div>`,g.style.display="block"}}function z(v){let m="";const r=v.services,c=["radarr","sonarr","prowlarr"];for(const u of c){const a=r[u];if(!a||a.status==="not_found"&&!a.url)continue;const e=M[u],n=j[u],t=a.status==="connected";m+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${t?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
${R(i.status)}
|
|
|
|
|
</div>`}p+="</div>";const d=S.summary;p+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
|
|
|
|
${escapeHtml(String(d.fullyConnected))}/${escapeHtml(String(d.totalDetected+(5-d.totalDetected)))} services detected ·
|
|
|
|
|
${escapeHtml(String(d.fullyConnected))} connected${d.needsApiKey>0?` · <strong>${escapeHtml(String(d.needsApiKey))} needs API key</strong>`:""}
|
|
|
|
|
</div>`,v.innerHTML=p,v.style.display="block",D(S),setTimeout(()=>{H("credentials")},800)}catch(m){v.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(m.message)}</div>`,v.style.display="block"}}function D(m){let p="";const d=m.services,c=["radarr","sonarr","prowlarr"];for(const y of c){const n=d[y];if(!n||n.status==="not_found"&&!n.url)continue;const e=T[y],o=j[y],a=n.status==="connected";p+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${a?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">${e}</span>
|
|
|
|
|
<span style="font-weight: 500;">${n}</span>
|
|
|
|
|
<span id="smart-${u}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${t?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
|
|
|
<span style="font-weight: 500;">${o}</span>
|
|
|
|
|
<span id="smart-${y}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${a?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">URL:</label>
|
|
|
|
|
<input type="text" id="smart-${u}-url" value="${escapeHtml(a.url||"")}" placeholder="https://seedbox.com/${u}/"
|
|
|
|
|
<input type="text" id="smart-${y}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${y}/"
|
|
|
|
|
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">API Key:</label>
|
|
|
|
|
<input type="password" id="smart-${u}-key" placeholder="${t?"(saved)":"Paste API key"}"
|
|
|
|
|
<input type="password" id="smart-${y}-key" placeholder="${a?"(saved)":"Paste API key"}"
|
|
|
|
|
style="width: 100%; font-size: 0.85rem; padding: 6px 8px; background: var(--card-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;" />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button onclick="smartTestConnection('${u}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const s=r.plex;if(s){const u=s.status==="connected";m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
|
|
|
<button onclick="smartTestConnection('${y}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const i=d.plex;if(i){const y=i.status==="connected";p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
|
|
|
<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>
|
|
|
|
|
${A(s.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(s.source||"")}</span>
|
|
|
|
|
${R(i.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(i.source||"")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}const h=r.seerr;if(h){const u=h.status==="connected";let a="";if(h.configuredServices){const e=h.configuredServices;a=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
</div>`}const $=d.seerr;if($){const y=$.status==="connected";let n="";if($.configuredServices){const e=$.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
Configured: ${e.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
|
|
|
${e.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
|
|
|
${e.plex?"✓ Plex":"✗ Plex"}
|
|
|
|
|
</div>`}m+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${u?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`}p+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${y?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
|
|
|
|
<span style="font-weight: 500;">Seerr</span>
|
|
|
|
|
${A(h.status)}
|
|
|
|
|
${R($.status)}
|
|
|
|
|
</div>
|
|
|
|
|
${a}
|
|
|
|
|
</div>`}I.innerHTML=m}window.smartTestConnection=async function(v){const m=document.getElementById(`smart-${v}-url`),r=document.getElementById(`smart-${v}-key`),c=document.getElementById(`smart-${v}-status`),s=m?.value.trim(),h=r?.value.trim();if(!s||!h){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const a=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:v,url:s,apiKey:h})})).json();a.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(a.appName||"Connected")} v${escapeHtml(a.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(a.error)}</span>`}catch(u){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(u.message)}</span>`}};async function f(){B("progress"),k.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 v={};for(const r of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${r}-url`)?.value.trim(),s=document.getElementById(`smart-${r}-key`)?.value.trim();s&&c?v[r]={apiKey:s,url:c}:s&&(v[r]={apiKey:s})}const m={services:Object.keys(v).length>0?v:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(m)})).json();let s="";for(const h of c.steps||[]){const u=h.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',a=h.status==="success"?"var(--muted)":"var(--bad-fg)";s+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${u}
|
|
|
|
|
<span>${escapeHtml(h.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${a};">${escapeHtml(h.details||"")}</span>
|
|
|
|
|
</div>`}k.innerHTML=s,setTimeout(()=>p(c),500)}catch(r){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(r.message)}</div>`}}function p(v){B("results");const m=v.summary||{},r=m.failed===0&&m.succeeded>0,c=r?"var(--ok-fg)":"#f39c12",s=r?"✓":"⚠",h=r?"All Connected!":`${escapeHtml(String(m.succeeded))}/${escapeHtml(String(m.totalSteps))} Steps Succeeded`;let u=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${c};">${s}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${h}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(m.succeeded))} succeeded, ${escapeHtml(String(m.failed))} failed</div>
|
|
|
|
|
</div>`;u+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const a of v.steps||[]){const e=a.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';u+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
|
|
|
${e} ${escapeHtml(a.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(a.details||"")}</span>
|
|
|
|
|
</div>`}u+="</div>",x.innerHTML=u,C.style.display=m.failed>0?"block":"none",v.steps?.some(a=>a.step.includes("Plex")&&a.status==="success")&&y()}async function y(){try{const m=await(await fetch("/api/v1/plex/libraries")).json();if(m.success&&m.libraries?.length>0){let r=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(m.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of m.libraries){const s=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";r+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${s} <strong>${escapeHtml(c.title)}</strong>
|
|
|
|
|
${n}
|
|
|
|
|
</div>`}L.innerHTML=p}window.smartTestConnection=async function(m){const p=document.getElementById(`smart-${m}-url`),d=document.getElementById(`smart-${m}-key`),c=document.getElementById(`smart-${m}-status`),i=p?.value.trim(),$=d?.value.trim();if(!i||!$){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const n=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:m,url:i,apiKey:$})})).json();n.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(n.error)}</span>`}catch(y){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(y.message)}</span>`}};async function g(){H("progress"),b.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const m={};for(const d of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${d}-url`)?.value.trim(),i=document.getElementById(`smart-${d}-key`)?.value.trim();i&&c?m[d]={apiKey:i,url:c}:i&&(m[d]={apiKey:i})}const p={services:Object.keys(m).length>0?m:void 0,configurePlex:document.getElementById("smart-opt-plex")?.checked,configureProwlarr:document.getElementById("smart-opt-prowlarr")?.checked,configureSeerr:document.getElementById("smart-opt-seerr")?.checked,saveCredentials:document.getElementById("smart-opt-save")?.checked};try{const c=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(p)})).json();let i="";for(const $ of c.steps||[]){const y=$.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',n=$.status==="success"?"var(--muted)":"var(--bad-fg)";i+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${y}
|
|
|
|
|
<span>${escapeHtml($.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml($.details||"")}</span>
|
|
|
|
|
</div>`}b.innerHTML=i,setTimeout(()=>u(c),500)}catch(d){b.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(d.message)}</div>`}}function u(m){H("results");const p=m.summary||{},d=p.failed===0&&p.succeeded>0,c=d?"var(--ok-fg)":"#f39c12",i=d?"✓":"⚠",$=d?"All Connected!":`${escapeHtml(String(p.succeeded))}/${escapeHtml(String(p.totalSteps))} Steps Succeeded`;let y=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${c};">${i}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${$}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(p.succeeded))} succeeded, ${escapeHtml(String(p.failed))} failed</div>
|
|
|
|
|
</div>`;y+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of m.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';y+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
|
|
|
${e} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span>
|
|
|
|
|
</div>`}y+="</div>",M.innerHTML=y,B.style.display=p.failed>0?"block":"none",m.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&f()}async function f(){try{const p=await(await fetch("/api/v1/plex/libraries")).json();if(p.success&&p.libraries?.length>0){let d=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<h4 style="margin: 0 0 8px; font-size: 0.85rem; color: var(--accent);">\u{1F3AC} ${escapeHtml(p.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of p.libraries){const i=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";d+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${i} <strong>${escapeHtml(c.title)}</strong>
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
|
|
|
|
</div>`}r+="</div></div>",$.innerHTML=r,$.style.display="block"}}catch{}}E?.addEventListener("click",()=>{b.classList.add("show"),$.style.display="none",w()}),wireModal(b,N),S?.addEventListener("click",f),C?.addEventListener("click",f)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
|
|
|
</div>`}d+="</div></div>",k.innerHTML=d,k.style.display="block"}}catch{}}E?.addEventListener("click",()=>{h.classList.add("show"),k.style.display="none",x()}),wireModal(h,P),w?.addEventListener("click",g),B?.addEventListener("click",g)})(),(function(){const h=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
|
|
|
<h3>\u{1F514} Notification Settings</h3>
|
|
|
|
|
|
|
|
|
@@ -981,6 +981,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<label class="checkbox-label-sm">
|
|
|
|
|
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
|
|
|
|
</label>
|
|
|
|
|
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
|
|
|
|
|
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- History -->
|
|
|
|
@@ -988,22 +991,24 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
|
|
|
|
|
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="last-notification-sent" style="font-size: 0.75rem; color: var(--muted); text-align: center; margin-top: 6px;"></div>
|
|
|
|
|
|
|
|
|
|
<!-- Buttons -->
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar">
|
|
|
|
|
<button id="notifications-send-test" class="btn-secondary" style="margin-right: auto;">Send Test</button>
|
|
|
|
|
<button id="notifications-cancel">Cancel</button>
|
|
|
|
|
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const E=document.getElementById("notifications-modal"),N=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),T=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const $=document.getElementById(`${x}-enabled`),C=document.getElementById(`${x}-config`);$?.addEventListener("change",()=>{C.style.display=$.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),L=document.getElementById("health-check-config");P?.addEventListener("change",()=>{L.style.opacity=P.checked?"1":"0.5"});async function H(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const C=$.config;document.getElementById("notifications-enabled").checked=C.enabled,document.getElementById("discord-enabled").checked=C.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=C.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=C.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=C.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=C.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=C.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=C.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=C.providers?.email?.enabled?"block":"none",C.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=C.providers.ntfy.serverUrl),C.providers?.email?.host&&(document.getElementById("email-host").value=C.providers.email.host),C.providers?.email?.from&&(document.getElementById("email-from").value=C.providers.email.from),document.getElementById("health-check-enabled").checked=C.healthCheck?.enabled||!1,C.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=C.healthCheck.intervalMinutes),C.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=C.events?.containerDown!==!1,document.getElementById("event-container-up").checked=C.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=C.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=C.events?.deploymentFailed!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),C=document.getElementById("notification-history");$.success&&$.history?.length>0?C.innerHTML=$.history.map(R=>{const M=new Date(R.timestamp).toLocaleString();return`
|
|
|
|
|
</div>`);const E=document.getElementById("notifications-modal"),P=document.getElementById("manage-notifications"),w=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const B=document.getElementById(`${k}-enabled`),S=document.getElementById(`${k}-config`);B?.addEventListener("change",()=>{S.style.display=B.checked?"block":"none"})});const O=document.getElementById("health-check-enabled"),z=document.getElementById("health-check-config");O?.addEventListener("change",()=>{z.style.opacity=O.checked?"1":"0.5"});async function A(){try{const B=await(await fetch("/api/v1/notifications/config")).json();if(B.success){const S=B.config;document.getElementById("notifications-enabled").checked=S.enabled,document.getElementById("discord-enabled").checked=S.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=S.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=S.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=S.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=S.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=S.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=S.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=S.providers?.email?.enabled?"block":"none",S.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=S.providers.ntfy.serverUrl),S.providers?.email?.host&&(document.getElementById("email-host").value=S.providers.email.host),S.providers?.email?.from&&(document.getElementById("email-from").value=S.providers.email.from),document.getElementById("health-check-enabled").checked=S.healthCheck?.enabled||!1,S.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=S.healthCheck.intervalMinutes),S.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(S.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=S.events?.containerDown!==!1,document.getElementById("event-container-up").checked=S.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=S.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=S.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=S.events?.resourceAlert!==!1}}catch(k){h.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function v(){try{const B=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");B.success&&B.history?.length>0?S.innerHTML=B.history.map(T=>{const j=new Date(T.timestamp).toLocaleString();return`
|
|
|
|
|
<div 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)"}[R.type]||"var(--muted)"}">${R.type==="success"?"\u2713":R.type==="error"?"\u2717":"\u2139"}</span>
|
|
|
|
|
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span>
|
|
|
|
|
<div style="flex: 1; min-width: 0;">
|
|
|
|
|
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(R.title)}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${M}</div>
|
|
|
|
|
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(T.title)}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${j}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""):C.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function I(){try{const x={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}},C=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();C.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${C.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function k(x){try{const C=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();C.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${C.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>k("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>k("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>k("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>k("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),N?.addEventListener("click",()=>{E.classList.add("show"),H(),g()}),S?.addEventListener("click",I),wireModal(E,T)})(),(function(){document.addEventListener("click",b=>{const E=b.target.closest(".panel-tab");if(!E)return;const N=E.dataset.panel;if(!N)return;const S=E.closest(".panel-tabs"),T=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(L=>L.classList.remove("active")),E.classList.add("active"),T.querySelectorAll(".panel-section").forEach(L=>L.classList.remove("active"));const P=T.querySelector("#"+N);P&&P.classList.add("active")})})(),(function(){var b=["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 e={},n=0;n<b.length;n++){var t=b[n],i=safeGet(t);i!=null&&(e[t]=i)}try{for(var o=0;o<localStorage.length;o++){var d=localStorage.key(o);/^widget-.+-enabled$/.test(d)&&(e[d]=localStorage.getItem(d))}}catch{}return e}function N(e){if(!e||typeof e!="object")return 0;var n=0;for(var t in e)e.hasOwnProperty(t)&&(safeSet(t,e[t]),n++);return n}function S(e){return e.version&&!e.files&&e.services}function T(e){var n={};e.customServices&&(n["custom-services"]=JSON.stringify(e.customServices)),e.customApps&&(n["custom-apps"]=JSON.stringify(e.customApps)),e.weatherZip&&(n["weather-zip"]=e.weatherZip),e.theme&&(n.theme=e.theme),e.userThemes&&Object.keys(e.userThemes).length&&(n["user-themes"]=JSON.stringify(e.userThemes)),N(n),e.services&&Array.isArray(e.serv
Line truncated
|
|
|
|
|
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){h.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function L(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},S=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();S.success?(showNotification("Notification settings saved","success",3e3),E.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function b(k){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();S.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(B){showNotification(`Error: ${B.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>b("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>b("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>b("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>b("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const B=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();B.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(B.lastCheck).toLocaleString()} (${B.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),P?.addEventListener("click",()=>{E.classList.add("show"),A(),v()}),w?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),B=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const T=await(await secureFetch("/api/v1/notifications/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({event:"test",data:{message:"This is a test notification from DashCaddy."},type:"info"})})).json();T.success?(showNotification("Test notification sent!","success",3e3),M()):showNotification(`Test failed: ${T.results?.map(j=>`${j.provider}: ${j.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{k.textContent=B,k.disabled=!1}});async function M(){try{const B=await(await fetch("/api/v1/notifications/status")).json();if(B.success&&B.lastSent){const S=document.getElementById("last-notification-sent");S&&(S.textContent=`Last sent: ${new Date(B.lastSent).toLocaleString()}`)}}catch{}}wireModal(E,N)})(),(function(){document.addEventListener("click",h=>{const E=h.target.closest(".panel-tab");if(!E)return;const P=E.dataset.panel;if(!P)return;const w=E.closest(".panel-tabs"),N=w.closest(".weather-modal-content");w.querySelectorAll(".panel-tab").forEach(z=>z.classList.remove("active")),E.classList.add("active"),N.querySelectorAll(".panel-section").forEach(z=>z.classList.remove("active"));const O=N.querySelector("#"+P);O&&O.classList.add("active")})})(),(function(){var h=["dashcaddy_site_config","dashca
Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
|
|
|
<h3>\u{1F4BE} Backup & Restore</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1013,7 +1018,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<!-- Tab bar -->
|
|
|
|
|
<div class="panel-tabs">
|
|
|
|
|
<button class="panel-tab active" data-panel="backup-manual">Manual</button>
|
|
|
|
|
<button class="panel-tab" data-panel="backup-automated">Automated</button>
|
|
|
|
|
<button class="panel-tab" data-panel="backup-schedules-tab">Schedules</button>
|
|
|
|
|
<button class="panel-tab" data-panel="backup-disk-tab">Backups on Disk</button>
|
|
|
|
|
<button class="panel-tab" data-panel="backup-pointintime-tab">Point-in-Time</button>
|
|
|
|
|
<button class="panel-tab" data-panel="backup-history-tab">History</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
@@ -1062,12 +1069,32 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div id="backup-result" style="display: none; margin-top: 16px; padding: 12px; border-radius: 8px;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tab: Automated Backups -->
|
|
|
|
|
<div id="backup-automated" class="panel-section">
|
|
|
|
|
<div id="backup-schedule-container">
|
|
|
|
|
<!-- Tab: Schedules (Premium) -->
|
|
|
|
|
<div id="backup-schedules-tab" class="panel-section">
|
|
|
|
|
<div id="backup-schedules-container">
|
|
|
|
|
<div class="panel-empty">
|
|
|
|
|
<span class="empty-icon">\u23F0</span>
|
|
|
|
|
<span class="brand-spinner"></span> Loading backup schedule...
|
|
|
|
|
<span class="brand-spinner"></span> Loading schedules...
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tab: Backups on Disk -->
|
|
|
|
|
<div id="backup-disk-tab" class="panel-section">
|
|
|
|
|
<div id="backup-disk-container">
|
|
|
|
|
<div class="panel-empty">
|
|
|
|
|
<span class="empty-icon">\u{1F4BE}</span>
|
|
|
|
|
<span class="brand-spinner"></span> Loading backup files...
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<!-- Tab: Point-in-Time Restore -->
|
|
|
|
|
<div id="backup-pointintime-tab" class="panel-section">
|
|
|
|
|
<div id="pointintime-container">
|
|
|
|
|
<div class="panel-empty">
|
|
|
|
|
<span class="empty-icon">\u23EA</span>
|
|
|
|
|
<span class="brand-spinner"></span> Loading...
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -1087,7 +1114,9 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="backup-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);var P=document.getElementById("backup-modal"),L=document.getElementById("backup-restore-btn"),H=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),I=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),x=document.getElementById("backup-file-name"),$=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),R=document.getElementById("backup-do-restore-btn"),M=document.getElementById("backup-result"),j=document.getElementById("backup-schedule-container"),B=document.getElementById("backup-history-container"),A=null;L?.addEventListener("click",function(){P.classList.add("show"),M&&(M.style.display="none"),$&&($.style.display="none"),x&&(x.style.display="none"),A=null}),wireModal(P,H),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),n=await e.json();n.browserState=E();var t=new Blob([JSON.stringify(n,null,2)],{type:"application/json"}),i=URL.createObjectURL(t),o=document.createElement("a");o.href=i,o.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(o),o.click(),document.body.removeChild(o),URL.revokeObjectURL(i);var d=Object.keys(n.browserState).length,l=n.themes?Object.keys(n.themes).length:0;M.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+d+" browser settings"+(l?" + "+l+" themes":""),M.style.display="block",M.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",M.style.border="1px solid var(--ok-fg)"}catch(D){M.innerHTML="\u274C Export failed: "+escapeHtml(D.message),M.style.display="block",M.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",M.style.border="1px solid var(--bad-fg)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),I?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(e){var n=e.target.files[0];if(n){x.textContent="\u{1F4C4} "+n.name,x.style.display="block",M.style.display="none";try{var t=await n.text(),i=JSON.parse(t);if(S(i)){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";o+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),o+="</div>",C.innerHTML=o,$.style.display="block";return}var d=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),l=await d.json();if(l.success){A=i;var o='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";o+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',o+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var D in l.preview.files){var O=l.preview.files[D],F=O.action==="create"?"\u{1F195}":"\u{1F4DD}";o+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(O.description)+"</span>"}o+="</div>",l.preview.serviceCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(o+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(o+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',o+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=o,$.style.display="block"}else M.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),M.style.display="block",M.style.background="color-mix(in srgb, #f39c12 15%, transparent)",M.style.border="1px solid #f39c12",$.style.display="none"}catch(q){M.innerHTML="\u274C Could not read file: "+escapeHtml(q.message),M.style.display="block",M.style.background="color-mix(in Line truncated
|
|
|
|
|
</div>`);var O=document.getElementById("backup-modal"),z=document.getElementById("backup-restore-btn"),A=document.getElementById("backup-cancel"),v=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),b=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),B=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),j=document.getElementById("backup-schedules-container"),H=document.getElementById("backup-history-container"),R=document.getElementById("backup-disk-container"),x=document.getElementById("pointintime-container"),D=null;z?.addEventListener("click",function(){O.classList.add("show"),T&&(T.style.display="none"),k&&(k.style.display="none"),M&&(M.style.display="none"),D=null}),wireModal(O,A),v?.addEventListener("click",async function(){v.disabled=!0,v.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),o=await e.json();o.browserState=E();var a=new Blob([JSON.stringify(o,null,2)],{type:"application/json"}),r=URL.createObjectURL(a),t=document.createElement("a");t.href=r,t.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(t),t.click(),document.body.removeChild(t),URL.revokeObjectURL(r);var s=Object.keys(o.browserState).length,l=o.themes?Object.keys(o.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(l?" + "+l+" themes":""),T.style.display="block",T.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",T.style.border="1px solid var(--ok-fg)"}catch(C){T.innerHTML="\u274C Export failed: "+escapeHtml(C.message),T.style.display="block",T.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",T.style.border="1px solid var(--bad-fg)"}v.disabled=!1,v.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){b.click()}),b?.addEventListener("change",async function(e){var o=e.target.files[0];if(o){M.textContent="\u{1F4C4} "+o.name,M.style.display="block",T.style.display="none";try{var a=await o.text(),r=JSON.parse(a);if(w(r)){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(r.version)+")</div>";t+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',r.services?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+r.services.length+" services</span>"),r.customApps?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+r.customApps.length+" custom apps</span>"),r.theme&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(r.theme)+"</span>"),r.userThemes&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(r.userThemes).length+" custom themes</span>"),t+="</div>",B.innerHTML=t,k.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(r)}),l=await s.json();if(l.success){D=r;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(r.exportedAt).toLocaleString()+" (v"+escapeHtml(r.version)+")</div>";t+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',t+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var C in l.preview.files){var I=l.preview.files[C],F=I.action==="create"?"\u{1F195}":"\u{1F4DD}";t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+F+" "+escapeHtml(I.description)+"</span>"}t+="</div>",l.preview.serviceCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+l.preview.serviceCount+" services</div>"),l.preview.themeCount&&(t+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+l.preview.themeCount+" custom themes</div>"),l.preview.browserStateCount&&(t+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',t+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+l.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),B.innerHTML=t,k.style.display="block"}else T.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(l.error),T.style.display="block",T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12",k.style.display="none"}catch(q){T.innerHTML="\u274CLine truncated
|
|
|
|
|
|
|
|
|
|
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(o),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),r=await a.json();r.success?(showNotification(e+" restored to "+o,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(r.error||"Unknown"),"error")}catch(t){showNotification("Restore error: "+t.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
|
|
|
<h3>\u{1F4CA} Resource Monitor</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1167,10 +1196,10 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="stats-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),N=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),T=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),L=document.getElementById("stats-aggregated-container"),H=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let I=null,k=null;function x(s){if(s===0||!s)return"0 B";const h=1024,u=["B","KB","MB","GB"],a=Math.floor(Math.log(s)/Math.log(h));return parseFloat((s/Math.pow(h,a)).toFixed(1))+" "+u[a]}function $(s){return s<30?"#2ecc71":s<70?"#f39c12":"#e74c3c"}function C(s){return s<50?"#2ecc71":s<80?"#f39c12":"#e74c3c"}async function R(){try{let s=null,h=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(s=e.stats,h=!0,k=e.stats)}catch{}if(!h){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){s={};for(const n of e.stats)s[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};k=s}}if(!s||Object.keys(s).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[a,e]of Object.entries(s)){const n=e.current||e,t=n.cpu?.percent||0,i=n.memory?.percent||0,o=$(t),d=C(i),l=n.memory?.usage||n.memory?.used||0,D=n.memory?.limit||0,O=n.network?.rxBytes||n.network?.rx||0,F=n.network?.txBytes||n.network?.tx||0,q=e.aggregated;u+=`
|
|
|
|
|
</div>`);const h=document.getElementById("stats-modal"),E=document.getElementById("container-stats-btn"),P=document.getElementById("stats-cancel"),w=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),O=document.getElementById("stats-container"),z=document.getElementById("stats-aggregated-container"),A=document.getElementById("stats-alerts-container"),v=document.getElementById("stats-last-update");let L=null,b=null;function M(i){if(i===0||!i)return"0 B";const $=1024,y=["B","KB","MB","GB"],n=Math.floor(Math.log(i)/Math.log($));return parseFloat((i/Math.pow($,n)).toFixed(1))+" "+y[n]}function k(i){return i<30?"#2ecc71":i<70?"#f39c12":"#e74c3c"}function B(i){return i<50?"#2ecc71":i<80?"#f39c12":"#e74c3c"}async function S(){try{let i=null,$=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(i=e.stats,$=!0,b=e.stats)}catch{}if(!$){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){i={};for(const o of e.stats)i[o.name]={name:o.name,current:{cpu:o.cpu,memory:{percent:o.memory.percent,usage:o.memory.used,limit:o.memory.limit,usageMB:Math.round(o.memory.used/1048576),limitMB:Math.round(o.memory.limit/1048576)},network:{rxBytes:o.network.rx,txBytes:o.network.tx,rxMB:(o.network.rx/1048576).toFixed(1),txMB:(o.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:o.status};b=i}}if(!i||Object.keys(i).length===0){O.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(i)){const o=e.current||e,a=o.cpu?.percent||0,r=o.memory?.percent||0,t=k(a),s=B(r),l=o.memory?.usage||o.memory?.used||0,C=o.memory?.limit||0,I=o.network?.rxBytes||o.network?.rx||0,F=o.network?.txBytes||o.network?.tx||0,q=e.aggregated;y+=`
|
|
|
|
|
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${e.name||a}</span>
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${e.name||n}</span>
|
|
|
|
|
${q?`<span style="font-size: 0.65rem; color: var(--muted); padding: 2px 6px; background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px;">avg ${q.cpu?.avg?.toFixed(0)||0}% cpu</span>`:""}
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted); background: var(--base); padding: 2px 8px; border-radius: 4px;">${e.status||"running"}</span>
|
|
|
|
|
</div>
|
|
|
|
@@ -1179,32 +1208,32 @@ 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(t,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${t.toFixed(1)}%</span>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Memory</div>
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(i,100)}%; background: ${d}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(r,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${d}; min-width: 45px; text-align: right;">${i.toFixed(1)}%</span>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${r.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${x(l)} / ${x(D)}</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(l)} / ${M(C)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
|
|
|
|
<div style="font-size: 0.8rem;">
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${x(O)}</span>
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${M(I)}</span>
|
|
|
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${x(F)}</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${M(F)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}u+="</div>",P.innerHTML=u,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(s){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(s.message)}</div>`}}async function M(){if(!L)return;const s=k;if(!s||Object.keys(s).length===0){L.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 h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.aggregated;e&&(h+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${a.name||u}</div>
|
|
|
|
|
</div>`}y+="</div>",O.innerHTML=y,v.textContent="Updated: "+new Date().toLocaleTimeString()}catch(i){O.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(i.message)}</div>`}}async function T(){if(!z)return;const i=b;if(!i||Object.keys(i).length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let $='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[y,n]of Object.entries(i)){const e=n.aggregated;e&&($+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||y}</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
|
|
@@ -1212,49 +1241,94 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
${e.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${e.dataPoints} data points over ${e.timeRange||24}h</div>`:""}
|
|
|
|
|
</div>`)}h+="</div>",L.innerHTML=h}async function j(){if(!H)return;H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const s=k;if(!s||Object.keys(s).length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let h='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[u,a]of Object.entries(s)){const e=a.alertConfig||{};h+=`<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;">${a.name||u}</span>
|
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
|
|
|
<input type="checkbox" class="alert-enabled" data-container="${u}" ${e.enabled?"checked":""} /> Enabled
|
|
|
|
|
</label>
|
|
|
|
|
</div>`)}$+="</div>",z.innerHTML=$}async function j(){if(!A)return;A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const i=b;if(!i||Object.keys(i).length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let $=!1;try{$=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{$=!1}let y=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(y=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const o=Object.entries(i).map(([t,s])=>{const l=n[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
|
|
|
|
<tr data-container="${t}">
|
|
|
|
|
<td style="font-weight: 600; padding: 8px;">${s.name||t}</td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${l.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${l.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${l.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${$?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${$?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;">
|
|
|
|
|
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${s.name||t}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`}).join(""),a=y.map(t=>{const s=new Date(t.timestamp).toLocaleString(),l=t.notified?"\u2713":"\u2014";return`
|
|
|
|
|
<tr>
|
|
|
|
|
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${s}</td>
|
|
|
|
|
<td style="padding: 6px 8px; font-weight: 500;">${t.containerName||t.containerId}</td>
|
|
|
|
|
<td style="padding: 6px 8px; text-transform: capitalize;">${t.metric||t.type}</td>
|
|
|
|
|
<td style="padding: 6px 8px;">${typeof t.value=="number"?t.value.toFixed(1):t.value}${t.metric==="disk"?" MB/s":"%"}</td>
|
|
|
|
|
<td style="padding: 6px 8px; text-align: center;">${l}</td>
|
|
|
|
|
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`}).join(""),r=$?`
|
|
|
|
|
<div style="margin-bottom: 20px;">
|
|
|
|
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
|
|
|
|
<h4 style="margin: 0; font-size: 0.9rem;">\u2699\uFE0F Alert Configuration</h4>
|
|
|
|
|
<a href="#" id="go-to-notifications" style="font-size: 0.8rem; color: var(--accent); text-decoration: none;">Configure notifications \u2192</a>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px;">
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">CPU Threshold %</label>
|
|
|
|
|
<input type="number" class="alert-cpu" data-container="${u}" value="${e.cpuThreshold||80}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Memory Threshold %</label>
|
|
|
|
|
<input type="number" class="alert-mem" data-container="${u}" value="${e.memoryThreshold||85}" min="1" max="100" style="width: 100%; font-size: 0.85rem;" />
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<label style="font-size: 0.75rem; color: var(--muted);">Cooldown (min)</label>
|
|
|
|
|
<input type="number" class="alert-cooldown" data-container="${u}" value="${e.cooldownMinutes||15}" min="1" max="1440" style="width: 100%; font-size: 0.85rem;" />
|
|
|
|
|
</div>
|
|
|
|
|
<div style="overflow-x: auto;">
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">CPU %</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Mem %</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Disk I/O MB/s</th>
|
|
|
|
|
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">Auto-Restart</th>
|
|
|
|
|
<th style="padding: 6px 8px;"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>${o}</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="display: flex; gap: 8px; margin-top: 8px; align-items: center;">
|
|
|
|
|
<label style="display: flex; align-items: center; gap: 6px; font-size: 0.8rem; cursor: pointer;">
|
|
|
|
|
<input type="checkbox" class="alert-autorestart" data-container="${u}" ${e.autoRestart?"checked":""} /> Auto-restart on breach
|
|
|
|
|
</label>
|
|
|
|
|
<span style="flex: 1;"></span>
|
|
|
|
|
<button class="alert-save-btn" data-container="${u}" 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 style="margin-top: 12px; display: flex; justify-content: flex-end;">
|
|
|
|
|
<button id="save-all-alerts" style="padding: 6px 16px; font-size: 0.85rem; background: var(--accent); color: var(--base); border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Save All</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}h+="</div>",H.innerHTML=h,H.querySelectorAll(".alert-save-btn").forEach(u=>{u.addEventListener("click",async()=>{const a=u.dataset.container,e=H.querySelector(`.alert-enabled[data-container="${a}"]`)?.checked||!1,n=parseInt(H.querySelector(`.alert-cpu[data-container="${a}"]`)?.value)||80,t=parseInt(H.querySelector(`.alert-mem[data-container="${a}"]`)?.value)||85,i=parseInt(H.querySelector(`.alert-cooldown[data-container="${a}"]`)?.value)||15,o=H.querySelector(`.alert-autorestart[data-container="${a}"]`)?.checked||!1;try{const l=await(await secureFetch(`/api/v1/monitoring/alerts/${a}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:e,cpuThreshold:n,memoryThreshold:t,cooldownMinutes:i,autoRestart:o})})).json();u.textContent=l.success?"\u2705 Saved":"\u26A0\uFE0F Failed",setTimeout(()=>{u.textContent="Save"},2e3)}catch{u.textContent="\u274C Error",setTimeout(()=>{u.textContent="Save"},2e3)}})})}function B(){I&&clearInterval(I),T?.checked&&(I=setInterval(R,DC.POLL.STATS))}function A(){I&&(clearInterval(I),I=null)}E?.addEventListener("click",()=>{b.classList.add("show"),R(),B()}),N?.addEventListener("click",()=>{b.classList.remove("show"),A()}),b?.addEventListener("click",s=>{s.target===b&&(b.classList.remove("show"),A())}),S?.addEventListener("click",R),T?.addEventListener("change",()=>{T.checked?B():A()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",M),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const w=document.getElementById("stats-history-container"),z=document.getElementById("stats-history-container-area"),f=document.querySelectorAll(".stats-range-btn");let p="1h";function y(s){switch(s){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function v(s){return s==="raw"?"live (10s samples)":s==="hourly"?"hourly average":s==="daily"?"daily average":s}function m(s,h,u,a,e){if(!s||s.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const n=s.map(h).filter(G=>G!=null);if(n.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(a)}</div>`;const t=Math.max(...n,1),i=Math.min(...n,0),o=t-i||1,d=600,l=80,D=4,O=(d-D*2)/Math.max(n.length-1,1),F=n.map((G,W)=>{const X=D+W*O,Q=l-D-(G-i)/o*(l-D*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=n[n.length-1],U=n.reduce((G,W)=>G+W,0)/n.length;return`
|
|
|
|
|
</div>
|
|
|
|
|
`:`
|
|
|
|
|
<div style="margin-bottom: 20px; padding: 12px; background: rgba(241,196,15,0.1); border: 1px solid rgba(241,196,15,0.3); border-radius: 8px; text-align: center;">
|
|
|
|
|
<span style="color: #f1c40f; font-weight: 600; font-size: 0.85rem;">\u2B50 Premium Feature</span>
|
|
|
|
|
<p style="margin: 6px 0 0; font-size: 0.75rem; color: var(--muted);">Upgrade to configure resource alert thresholds per container.</p>
|
|
|
|
|
<button id="upgrade-for-alerts" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; background: #f1c40f; color: #000; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Upgrade Now</button>
|
|
|
|
|
</div>
|
|
|
|
|
`;A.innerHTML=`
|
|
|
|
|
${r}
|
|
|
|
|
<div>
|
|
|
|
|
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
|
|
|
|
${a?`
|
|
|
|
|
<div style="overflow-x: auto;">
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
|
|
|
|
<thead>
|
|
|
|
|
<tr style="border-bottom: 1px solid var(--border);">
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Time</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Container</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Metric</th>
|
|
|
|
|
<th style="text-align: left; padding: 6px 8px; color: var(--muted);">Value</th>
|
|
|
|
|
<th style="text-align: center; padding: 6px 8px; color: var(--muted);">\u2713?</th>
|
|
|
|
|
<th style="padding: 6px 8px;"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>${a}</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
|
|
|
|
</div>
|
|
|
|
|
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const l=s.dataset.container;t[l]={cpuThreshold:parseInt(s.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(s.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(s.querySelector(".alert-disk")?.value)||50,autoRestart:!!s.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const l=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),C=document.getElementById("save-all-alerts");C.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{C.textContent="Save All"},2e3)}catch{const l=document.getElementById("save-all-alerts");l.textContent="\u274C Error",setTimeout(()=>{l.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),h.classList.remove("show"),R(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.textContent;t.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${t.dataset.container}/test`,{method:"POST"}),t.textContent="\u2705",showNotification("Test alert sent for "+t.dataset.name,"success",3e3)}catch{t.textContent="\u274C"}setTimeout(()=>{t.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{h.classList.remove("show"),R(),typeof openLicenseModal=="function"&&openLicenseModal()})}function H(){L&&clearInterval(L),N?.checked&&(L=setInterval(S,DC.POLL.STATS))}function R(){L&&(clearInterval(L),L=null)}E?.addEventListener("click",()=>{h.classList.add("show"),S(),H()}),P?.addEventListener("click",()=>{h.classList.remove("show"),R()}),h?.addEventListener("click",i=>{i.target===h&&(h.classList.remove("show"),R())}),w?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?H():R()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",j);const x=document.getElementById("stats-history-container"),D=document.getElementById("stats-history-container-area"),g=document.querySelectorAll(".stats-range-btn");let u="1h";function f(i){switch(i){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function m(i){return i==="raw"?"live (10s samples)":i==="hourly"?"hourly average":i==="daily"?"daily average":i}function p(i,$,y,n,e){if(!i||i.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const o=i.map($).filter(G=>G!=null);if(o.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const a=Math.max(...o,1),r=Math.min(...o,0),t=a-r||1,s=600,l=80,C=4,I=(s-C*2)/Math.max(o.length-1,1),F=o.map((G,J)=>{const X=C+J*I,Q=l-C-(G-r)/t*(l-C*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=o[o.length-1],U=o.reduce((G,J)=>G+J,0)/o.length;return`
|
|
|
|
|
<div style="margin-bottom: 14px;">
|
|
|
|
|
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
|
|
|
|
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(a)}</span>
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${t.toFixed(1)}${e}</span>
|
|
|
|
|
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(n)}</span>
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${q.toFixed(1)}${e} \xB7 avg ${U.toFixed(1)}${e} \xB7 max ${a.toFixed(1)}${e}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<svg viewBox="0 0 ${d} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
|
|
|
|
<polyline fill="none" stroke="${u}" stroke-width="1.5" points="${F}" />
|
|
|
|
|
<svg viewBox="0 0 ${s} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
|
|
|
|
<polyline fill="none" stroke="${y}" stroke-width="1.5" points="${F}" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
`}function r(){if(!w)return;const s=k||{},h=w.value,u=Object.entries(s);if(u.length===0){w.innerHTML='<option value="">No containers</option>';return}w.innerHTML=u.map(([a,e])=>`<option value="${escapeHtml(a)}">${escapeHtml(e.name||a)}</option>`).join(""),h&&s[h]&&(w.value=h)}async function c(){if(!z||!w)return;const s=w.value;if(!s){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const h=Date.now(),u=h-y(p);z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(s)}?startTime=${u}&endTime=${h}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const n=e.samples||[],t=e.tier||"raw";if(n.length===0){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${p}. Tier: ${v(t)}.</div>`;return}const i=t==="raw",o=i?F=>F.cpu?.percent:F=>F.cpu?.avg,d=i?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=i?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,D=i?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let O=`
|
|
|
|
|
`}function d(){if(!x)return;const i=b||{},$=x.value,y=Object.entries(i);if(y.length===0){x.innerHTML='<option value="">No containers</option>';return}x.innerHTML=y.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),$&&i[$]&&(x.value=$)}async function c(){if(!D||!x)return;const i=x.value;if(!i){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const $=Date.now(),y=$-f(u);D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(i)}?startTime=${y}&endTime=${$}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const o=e.samples||[],a=e.tier||"raw";if(o.length===0){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${u}. Tier: ${m(a)}.</div>`;return}const r=a==="raw",t=r?F=>F.cpu?.percent:F=>F.cpu?.avg,s=r?F=>F.memory?.percent:F=>F.memory?.avgPercent,l=r?F=>F.network?.rxMB||0:F=>F.network?.rxMB||0,C=r?F=>F.network?.txMB||0:F=>F.network?.txMB||0;let I=`
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
|
|
|
${n.length} samples \xB7 ${escapeHtml(v(t))} \xB7 ${new Date(u).toLocaleString()} \u2192 ${new Date(h).toLocaleString()}
|
|
|
|
|
${o.length} samples \xB7 ${escapeHtml(m(a))} \xB7 ${new Date(y).toLocaleString()} \u2192 ${new Date($).toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
`;O+=m(n,o,"#2ecc71","CPU","%"),O+=m(n,d,"#3498db","Memory","%"),O+=m(n,l,"#9b59b6","Network RX"," MB"),O+=m(n,D,"#e67e22","Network TX"," MB"),z.innerHTML=O}catch(a){z.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(a.message)}</div>`}}f.forEach(s=>{s.addEventListener("click",()=>{f.forEach(h=>h.classList.remove("active")),s.classList.add("active"),p=s.dataset.range,c()})}),w?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{r(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
|
|
|
`;I+=p(o,t,"#2ecc71","CPU","%"),I+=p(o,s,"#3498db","Memory","%"),I+=p(o,l,"#9b59b6","Network RX"," MB"),I+=p(o,C,"#e67e22","Network TX"," MB"),D.innerHTML=I}catch(n){D.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}g.forEach(i=>{i.addEventListener("click",()=>{g.forEach($=>$.classList.remove("active")),i.classList.add("active"),u=i.dataset.range,c()})}),x?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{d(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 800px; max-width: 1000px;">
|
|
|
|
|
<h3>\u{1F3E5} Health Check Dashboard</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1340,24 +1414,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 b=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),N=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),T=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),L=document.getElementById("health-config-container"),H=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),I=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),x=document.getElementById("health-form-cancel"),$=document.getElementById("health-form-save");let C=null;function R(f){return f>=99.9?"var(--ok-fg)":f>=95?"#f39c12":"var(--bad-fg)"}function M(f){const p={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: ${p[f]||"var(--muted)"}20; color: ${p[f]||"var(--muted)"};">${f}</span>`}async function j(){try{const p=await(await fetch("/api/v1/health-checks/status")).json();if(!p.success||!p.status||Object.keys(p.status).length===0){T.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 y=Object.values(p.status);let v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',v+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',v+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',v+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of y){const r=m.status==="up",c=r?"var(--dot-ok)":"var(--dot-bad)",s=m.uptime?.["24h"]??"-",h=m.uptime?.["7d"]??"-",u=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",a=m.timestamp?timeAgo(m.timestamp):"-";v+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px; color: ${typeof s=="number"?R(s):"var(--muted)"};">${typeof s=="number"?s.toFixed(1)+"%":s}</td>`,v+=`<td style="padding: 8px; color: ${typeof h=="number"?R(h):"var(--muted)"};">${typeof h=="number"?h.toFixed(1)+"%":h}</td>`,v+=`<td style="padding: 8px;">${u}</td>`,v+=`<td style="padding: 8px; color: var(--muted);">${a}</td>`,v+="</tr>",v+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}v+="</table>",T.innerHTML=v,H.textContent="Updated "+new Date().toLocaleTimeString(),T.querySelectorAll("tr[data-health-id]").forEach(m=>{m.addEventListener("click",async()=>{const r=m.dataset.healthId,c=document.getElementById("health-detail-"+r);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const h=await(await fetch(`/api/v1/health-checks/${r}/stats?hours=24`)).json();if(h.success&&h.stats){const u=h.stats,a=u.responseTime||{};c.querySelector("td").innerHTML=`
|
|
|
|
|
</div>`);const h=document.getElementById("health-modal"),E=document.getElementById("health-check-btn"),P=document.getElementById("health-cancel"),w=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),O=document.getElementById("health-incidents-container"),z=document.getElementById("health-config-container"),A=document.getElementById("health-last-update"),v=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),b=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save");let B=null;function S(g){return g>=99.9?"var(--ok-fg)":g>=95?"#f39c12":"var(--bad-fg)"}function T(g){const u={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${u[g]||"var(--muted)"}20; color: ${u[g]||"var(--muted)"};">${g}</span>`}async function j(){try{const u=await(await fetch("/api/v1/health-checks/status")).json();if(!u.success||!u.status||Object.keys(u.status).length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F3E5}</span>No health checks configured. Go to the Configure tab to add services.</div>';return}const f=Object.values(u.status);let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',m+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',m+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',m+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const p of f){const d=p.status==="up",c=d?"var(--dot-ok)":"var(--dot-bad)",i=p.uptime?.["24h"]??"-",$=p.uptime?.["7d"]??"-",y=p.avgResponseTime!=null?Math.round(p.avgResponseTime)+"ms":"-",n=p.timestamp?timeAgo(p.timestamp):"-";m+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(p.serviceId)}">`,m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${c}; margin-right: 6px;"></span>${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px; color: ${typeof i=="number"?S(i):"var(--muted)"};">${typeof i=="number"?i.toFixed(1)+"%":i}</td>`,m+=`<td style="padding: 8px; color: ${typeof $=="number"?S($):"var(--muted)"};">${typeof $=="number"?$.toFixed(1)+"%":$}</td>`,m+=`<td style="padding: 8px;">${y}</td>`,m+=`<td style="padding: 8px; color: var(--muted);">${n}</td>`,m+="</tr>",m+=`<tr id="health-detail-${escapeHtml(p.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading details...</div></td></tr>`}m+="</table>",N.innerHTML=m,A.textContent="Updated "+new Date().toLocaleTimeString(),N.querySelectorAll("tr[data-health-id]").forEach(p=>{p.addEventListener("click",async()=>{const d=p.dataset.healthId,c=document.getElementById("health-detail-"+d);if(c){if(c.style.display!=="none"){c.style.display="none";return}c.style.display="";try{const $=await(await fetch(`/api/v1/health-checks/${d}/stats?hours=24`)).json();if($.success&&$.stats){const y=$.stats,n=y.responseTime||{};c.querySelector("td").innerHTML=`
|
|
|
|
|
<div 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>${u.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${R(u.uptime||0)};">${(u.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(a.avg||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(a.p95||0)}ms / ${Math.round(a.p99||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(a.min||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(a.max||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${u.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${u.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(s){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`}}})})}catch(f){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(f.message)}</div>`}}async function B(){try{const[f,p]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),y=await f.json(),v=await p.json();let m="";const r=y.success&&y.incidents?y.incidents:[];if(r.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+r.length+")</h4>";for(const s of r)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
|
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${y.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${S(y.uptime||0)};">${(y.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(n.avg||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(n.p95||0)}ms / ${Math.round(n.p99||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(n.min||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(n.max||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${y.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${y.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else c.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(i){c.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}})})}catch(g){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(g.message)}</div>`}}async function H(){try{const[g,u]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),f=await g.json(),m=await u.json();let p="";const d=f.success&&f.incidents?f.incidents:[];if(d.length>0){p+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+d.length+")</h4>";for(const i of d)p+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
|
|
|
<span style="font-weight: 500;">${escapeHtml(s.serviceId)}</span>
|
|
|
|
|
<span>${M(s.severity)}</span>
|
|
|
|
|
<span style="font-weight: 500;">${escapeHtml(i.serviceId)}</span>
|
|
|
|
|
<span>${T(i.severity)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(s.message)}</div>
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(s.createdAt)} \xB7 ${s.occurrences||1} occurrence(s)</div>
|
|
|
|
|
</div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=v.success&&v.history?v.history:[];if(c.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const s of c){const h=s.status==="resolved",u=h&&s.duration?s.duration<6e4?Math.round(s.duration/1e3)+"s":Math.round(s.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(s.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(s.type)}</td>`,m+=`<td style="padding: 6px;">${M(s.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${h?"var(--ok-fg)":"var(--bad-fg)"};">${s.status}</span></td>`,m+=`<td style="padding: 6px;">${u}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(s.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(f){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}async function A(){try{const p=await(await fetch("/api/v1/health-checks/status")).json(),y=p.success&&p.status?Object.values(p.status):[];if(y.length===0){L.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 v='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of y){const r=m.status==="up";v+='<tr style="border-bottom: 1px solid var(--border);">',v+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,v+=`<td style="padding: 8px; color: ${r?"var(--ok-fg)":"var(--bad-fg)"};">${r?"Up":"Down"}</td>`,v+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,v+='<td style="padding: 8px; text-align: right;">',v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,v+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(m.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,v+="</td></tr>"}v+="</table>",L.innerHTML=v}catch(f){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}function w(f,p,y,v,m,r,c){C=f||null,k.textContent=f?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=f||"",document.getElementById("health-form-id").disabled=!!f,document.getElementById("health-form-name").value=p||"",document.getElementById("health-form-url").value=y||"",document.getElementById("health-form-timeout").value=v||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=r||99.9,document.getElementById("health-form-slow").value=c||5e3,I.style.display="",g.style.display="none"}function z(){I.style.display="none",g.style.display="",C=null}g?.addEventListener("click",()=>w("","","",1e4,"200",99.9,5e3)),x?.addEventListener("click",z),$?.addEventListener("click",async()=>{const f=C||document.getElementById("health-form-id").value.trim();if(!f)return showNotification("Service ID is required","warning");const p=document.getElementById("health-form-url").value.trim();if(!p)return showNotification("URL is required","warning");const y=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),v={name:document.getElementById("health-form-name").value.trim()||f,url:p,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:y.length?y:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
|
|
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(i.message)}</div>
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(i.createdAt)} \xB7 ${i.occurrences||1} occurrence(s)</div>
|
|
|
|
|
</div>`;p+="</div>"}else p+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const c=m.success&&m.history?m.history:[];if(c.length>0){p+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',p+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',p+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const i of c){const $=i.status==="resolved",y=$&&i.duration?i.duration<6e4?Math.round(i.duration/1e3)+"s":Math.round(i.duration/6e4)+"m":"-";p+='<tr style="border-bottom: 1px solid var(--border);">',p+=`<td style="padding: 6px;">${escapeHtml(i.serviceId)}</td>`,p+=`<td style="padding: 6px;">${escapeHtml(i.type)}</td>`,p+=`<td style="padding: 6px;">${T(i.severity)}</td>`,p+=`<td style="padding: 6px;"><span style="color: ${$?"var(--ok-fg)":"var(--bad-fg)"};">${i.status}</span></td>`,p+=`<td style="padding: 6px;">${y}</td>`,p+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(i.createdAt)}</td>`,p+="</tr>"}p+="</table>"}O.innerHTML=p||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(g){O.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}async function R(){try{const u=await(await fetch("/api/v1/health-checks/status")).json(),f=u.success&&u.status?Object.values(u.status):[];if(f.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let m='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const p of f){const d=p.status==="up";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(p.name||p.serviceId)}</td>`,m+=`<td style="padding: 8px; color: ${d?"var(--ok-fg)":"var(--bad-fg)"};">${d?"Up":"Down"}</td>`,m+=`<td style="padding: 8px;">${p.sla?.target?p.sla.target+"%":"-"}</td>`,m+='<td style="padding: 8px; text-align: right;">',m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,m+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(p.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,m+="</td></tr>"}m+="</table>",z.innerHTML=m}catch(g){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}function x(g,u,f,m,p,d,c){B=g||null,b.textContent=g?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=g||"",document.getElementById("health-form-id").disabled=!!g,document.getElementById("health-form-name").value=u||"",document.getElementById("health-form-url").value=f||"",document.getElementById("health-form-timeout").value=m||1e4,document.getElementById("health-form-codes").value=p||"200",document.getElementById("health-form-sla").value=d||99.9,document.getElementById("health-form-slow").value=c||5e3,L.style.display="",v.style.display="none"}function D(){L.style.display="none",v.style.display="",B=null}v?.addEventListener("click",()=>x("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",D),k?.addEventListener("click",async()=>{const g=B||document.getElementById("health-form-id").value.trim();if(!g)return showNotification("Service ID is required","warning");const u=document.getElementById("health-form-url").value.trim();if(!u)return showNotification("URL is required","warning");const f=document.getElementById("health-form-codes").value.split(",").map(p=>parseInt(p.trim())).filter(Boolean),m={name:document.getElementById("health-form-name").value.trim()||g,url:u,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:f.length?f:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
|
|
|
<h3>\u2B06\uFE0F Update Management</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1373,8 +1447,10 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
|
|
|
|
|
<!-- Tab: Available Updates -->
|
|
|
|
|
<div id="updates-available" class="panel-section active">
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<div style="margin-bottom: 12px; display: flex; gap: 8px; align-items: center;">
|
|
|
|
|
<button id="updates-check-btn" class="btn-accent-solid">\u{1F50D} Check for Updates</button>
|
|
|
|
|
<button id="updates-update-all-btn" style="display: none; padding: 6px 14px; font-size: 0.82rem; background: #f97316; color: #fff; border: 1px solid #f97316; border-radius: 6px; cursor: pointer;">\u2B06\uFE0F Update All</button>
|
|
|
|
|
<span id="updates-count-badge" style="display: none; padding: 4px 10px; border-radius: 12px; font-size: 0.78rem; font-weight: 600; background: var(--accent); color: var(--bg);"></span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="updates-available-container" style="max-height: 450px; overflow-y: auto;">
|
|
|
|
|
<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span> Click "Check for Updates" to scan containers.</div>
|
|
|
|
@@ -1429,17 +1505,17 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="updates-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),N=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),T=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),L=document.getElementById("updates-auto-container"),H=document.getElementById("updates-last-check");async function g(){try{const u=await(await fetch("/api/v1/updates/available")).json();if(!u.success)throw new Error(u.error);const a=u.updates||[];if(a.length===0){T.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',H.textContent="";return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of a)e+='<tr style="border-bottom: 1px solid var(--border);">',e+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,e+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,e+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,e+='<td style="padding: 8px; text-align: right;">',e+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,e+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,e+="</td></tr>";e+="</table>",T.innerHTML=e,H.textContent=a.length+" update(s) available",T.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Update "${i}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(t)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(d.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Update failed")}catch(o){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+o.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),T.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const t=n.dataset.id,i=n.dataset.name;if(confirm(`Rollback "${i}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const d=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(t)}`,{method:"POST"})).json();if(d.success)n.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(d.error||"Rollback failed")}catch(o){n.textContent="Failed",showNotification("Rollback error: "+o.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(h){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}async function I(){S.textContent="\u{1F50D} Checking...",S.disabled=!0;try{const u=await(await secureFetch("/api/v1/updates/check",{method:"POST"})).json();if(!u.success)throw new Error(u.error);S.textContent="\u2705 Done!",await g()}catch(h){S.textContent="\u274C Failed",showNotification("Check error: "+h.message,"error")}setTimeout(()=>{S.textContent="\u{1F50D} Check for Updates",S.disabled=!1},3e3)}async function k(){try{P.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>';const u=await(await fetch("/api/v1/updates/history?limit=50")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){P.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>No update history yet.</div>';return}let 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;">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(l)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
<option value=""${O?"":" selected"}>Disabled</option>
|
|
|
|
|
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
|
|
|
|
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
|
|
|
|
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
|
|
|
|
</select></td>`,i+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(l)}" value="${escapeHtml(q)}" 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>`,i+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(l)}"${F?" checked":""} /></td>`,i+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${U}</td>`,i+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(l)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,i+="</tr>"}i+="</table>",L.innerHTML=i,L.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const d=o.dataset.id,l=o.closest("tr"),D=l.querySelector(".auto-schedule").value,O=l.querySelector(".auto-rollback").checked,F=l.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const U=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!D,schedule:D||"weekly",autoRollback:O,maintenanceWindow:F||void 0})})).json();if(U.success)o.textContent="\u2713 Saved";else throw new Error(U.error)}catch(q){o.textContent="\u2717 Error",showNotification("Save error: "+q.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(h){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),C=document.getElementById("dashcaddy-update-badge"),R=document.getElementById("dashcaddy-update-details"),M=document.getElementById("dashcaddy-new-version"),j=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),A=document.getElementById("dashcaddy-check-btn"),w=document.getElementById("dashcaddy-rollback-btn"),z=document.getElementById("dashcaddy-status-bar"),f=document.getElementById("dashcaddy-history-container");let p=null;function y(h,u){z&&(z.style.display="block",z.style.background=u==="error"?"var(--bad-bg)":u==="success"?"var(--ok-bg)":"var(--bg)",z.style.color=u==="error"?"var(--bad-fg)":u==="success"?"var(--ok-fg)":"var(--fg)",z.textContent=h)}async function v(){try{const u=await(await fetch("/api/v1/system/version")).json();if(u.success){const a=u.commit&&u.commit!=="unknown"?u.commit:null;$.textContent="v"+u.version+(a?" ("+a.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function m(h){h||(A.textContent="Checking...",A.disabled=!0);try{const a=await(await fetch("/api/v1/system/update-check")).json();if(p=a,a.success&&a.available&&a.remote){C.style.display="",R.style.display="",M.textContent="v"+a.remote.version,j.textContent=a.remote.changelog||"No changelog available.";const e=document.getElementById("updates-btn");if(e&&!e.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",e.style.position="relative",e.appendChild(t)}const n=document.getElementById("updates-dashcaddy-tab");if(n&&!n.querySelector(".update-dot")){const t=document.createElement("span");t.className="update-dot",t.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",n.appendChild(t)}}else C.style.display="none",R.style.display="none",await v(),h||y("You are running the latest version.","success");h||(A.textContent="Check for Updates",A.disabled=!1)}catch(u){h||(y("Failed to check: "+u.message,"error"),A.textContent="Check for Updates",A.disabled=!1)}}async function r(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,y("Downloading and applying update...","info");try{const u=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(u.success)return y("Update initiated: v"+(u.fromVersion||"?")+" \u2192 v"+(u.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(a=>a.remove()),!0;throw new Error(u.error||"Update failed")}catch(h){throw y("Update failed: "+h.message,"error"),B.textContent="Update Now",B.disabled=!1,h}}async function c(){try{const u=await(await fetch("/api/v1/system/update-history")).json(),a=u.success&&u.history?u.history:[];if(a.length===0){f.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';e+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
`+a.join(`
|
|
|
|
|
</div>`);const h=document.getElementById("updates-modal"),E=document.getElementById("updates-btn"),P=document.getElementById("updates-cancel"),w=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),O=document.getElementById("updates-history-container"),z=document.getElementById("updates-auto-container"),A=document.getElementById("updates-last-check");async function v(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const e=n.updates||[];if(e.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',A.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const t of e){const s=(()=>{const l=window.APPS||[];for(const C of l)if(C.containerId===t.containerId||C.name===t.containerName||C.id===t.containerName)return C.id;return t.containerName})();o+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,o+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,o+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,o+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.latestDigest)}</code></td>`,o+='<td style="padding: 8px; text-align: right;">',o+=`<button class="update-now-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,o+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,o+="</td></tr>"}o+="</table>",N.innerHTML=o,A.textContent=e.length+" update(s) available";const a=document.getElementById("updates-count-badge"),r=document.getElementById("updates-update-all-btn");a&&(a.textContent=e.length+" pending",a.style.display=""),r&&e.length>0&&(r.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Update "${l}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(I.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Update failed")}catch(C){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+C.message,"error"),setTimeout(()=>{t.textContent="Update",t.disabled=!1,t.style.color="",t.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(t=>{t.addEventListener("click",async()=>{const s=t.dataset.id,l=t.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const I=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(I.success)t.textContent="Rolled back!",setTimeout(()=>v(),2e3);else throw new Error(I.error||"Rollback failed")}catch(C){t.textContent="Failed",showNotification("Rollback error: "+C.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(y){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}async function L(){const y=window._pendingUpdates||[];if(!y.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${y.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,o=0;for(const a of y)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(a.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:o++}catch{o++}n.textCLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(C)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
<option value=""${F?"":" selected"}>Disabled</option>
|
|
|
|
|
<option value="daily"${F==="daily"?" selected":""}>Daily</option>
|
|
|
|
|
<option value="weekly"${F==="weekly"?" selected":""}>Weekly</option>
|
|
|
|
|
<option value="monthly"${F==="monthly"?" selected":""}>Monthly</option>
|
|
|
|
|
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(C)}" value="${escapeHtml(U)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(C)}"${q?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${G}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(C)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",z.innerHTML=t,z.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const l=s.dataset.id,C=s.closest("tr"),I=C.querySelector(".auto-schedule").value,F=C.querySelector(".auto-rollback").checked,q=C.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const G=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!I,schedule:I||"weekly",autoRollback:F,maintenanceWindow:q||void 0})})).json();if(G.success)s.textContent="\u2713 Saved";else throw new Error(G.error)}catch(U){s.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(y){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}const B=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),j=document.getElementById("dashcaddy-new-version"),H=document.getElementById("dashcaddy-changelog"),R=document.getElementById("dashcaddy-apply-btn"),x=document.getElementById("dashcaddy-check-btn"),D=document.getElementById("dashcaddy-rollback-btn"),g=document.getElementById("dashcaddy-status-bar"),u=document.getElementById("dashcaddy-history-container");let f=null;function m(y,n){g&&(g.style.display="block",g.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",g.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",g.textContent=y)}async function p(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;B.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{B.textContent="Unable to fetch version"}}async function d(y){y||(x.textContent="Checking...",x.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(f=e,e.success&&e.available&&e.remote){S.style.display="",T.style.display="",j.textContent="v"+e.remote.version,H.textContent=e.remote.changelog||"No changelog available.";const o=document.getElementById("updates-btn");if(o&&!o.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",o.style.position="relative",o.appendChild(r)}const a=document.getElementById("updates-dashcaddy-tab");if(a&&!a.querySelector(".update-dot")){const r=document.createElement("span");r.className="update-dot",r.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",a.appendChild(r)}}else S.style.display="none",T.style.display="none",await p(),y||m("You are running the latest version.","success");y||(x.textContent="Check for Updates",x.disabled=!1)}catch(n){y||(m("Failed to check: "+n.message,"error"),x.textContent="Check for Updates",x.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;R.textContent="Updating...",R.disabled=!0,m("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return m("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),R.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(y){throw m("Update failed: "+y.message,"error"),R.textContent="Update Now",R.disabled=!1,y}}async function i(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){u.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let o='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';o+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
`+e.join(`
|
|
|
|
|
`)+`
|
|
|
|
|
|
|
|
|
|
Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification("Invalid version: "+e,"error");return}if(!confirm("Rollback DashCaddy to v"+e+"? The container will restart."))return;y("Rolling back to v"+e+"...","info");const t=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:e})})).json();if(t.success)y("Rollback to v"+e+" initiated. Container will restart.","success");else throw new Error(t.error||"Rollback failed")}catch(h){y("Rollback failed: "+h.message,"error")}}A?.addEventListener("click",()=>m(!1)),B?.addEventListener("click",()=>r().catch(()=>{})),w?.addEventListener("click",s),S?.addEventListener("click",I),E?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,N),document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{v(),c(),p||m(!0)}),window.dcApplyUpdate=r,window.dcCheckForUpdate=m,setTimeout(()=>m(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
|
|
|
|
Enter version to rollback to:`);if(!o)return;if(!e.includes(o)){showNotification("Invalid version: "+o,"error");return}if(!confirm("Rollback DashCaddy to v"+o+"? The container will restart."))return;m("Rolling back to v"+o+"...","info");const r=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:o})})).json();if(r.success)m("Rollback to v"+o+" initiated. Container will restart.","success");else throw new Error(r.error||"Rollback failed")}catch(y){m("Rollback failed: "+y.message,"error")}}x?.addEventListener("click",()=>d(!1)),R?.addEventListener("click",()=>c().catch(()=>{})),D?.addEventListener("click",$),w?.addEventListener("click",b),E?.addEventListener("click",()=>{h?.classList.add("show"),v()}),wireModal(h,P),window.openUpdateModal=function(y){h?.classList.add("show"),v().then(()=>{if(!y)return;const n=N.querySelector(`[data-app-id="${y}"]`);n&&(n.scrollIntoView({behavior:"smooth",block:"center"}),n.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{n.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",M),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{p(),i(),f||d(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=d,setTimeout(()=>d(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
|
|
|
|
<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>
|
|
|
|
@@ -1488,7 +1564,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<button id="dr-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),N=document.getElementById("dr-close");function S(H){if(!H||H===0)return"0 B";const g=["B","KB","MB","GB","TB"],I=Math.floor(Math.log(Math.abs(H))/Math.log(1024));return(H/Math.pow(1024,I)).toFixed(1)+" "+g[I]}async function T(){const H=document.getElementById("dr-vol-list");try{const I=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let 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;">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 x of I){const $=x.name==="buildkit"||x.name.length===64;k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(x.name)}">${escapeHtml(x.name.length>40?x.name.substring(0,37)+"...":x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-vol-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete volume "${x.dataset.name}"? Data will be lost.`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(x.dataset.name)}?force=true`),T()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-vol-name"),g=H.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),H.value="",showNotification(`Volume "${g}" created`,"success"),T()}catch(I){showNotification("Create failed: "+I.message,"error")}});async function P(){const H=document.getElementById("dr-net-list");try{const I=(await getJSON("/api/v1/docker/networks")).networks||[];if(I.length===0){H.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let 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;">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 x of I){const $=["bridge","host","none"].includes(x.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(x.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(x.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${x.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',$||(k+=`<button class="dr-net-del" data-id="${escapeHtml(x.id)}" data-name="${escapeHtml(x.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",H.innerHTML=k,H.querySelectorAll(".dr-net-del").forEach(x=>{x.addEventListener("click",async()=>{if(confirm(`Delete network "${x.dataset.name}"?`)){x.textContent="...",x.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(x.dataset.id)}`),P()}catch($){showNotification("Delete failed: "+$.message,"error"),x.textContent="Delete",x.disabled=!1}}})})}catch(g){H.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const H=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),I=H.value.trim();if(!I){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:I,driver:g.value}),H.value="",showNotification(`Network "${I}" created`,"success"),P()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
|
|
|
|
|
</div>`);const h=document.getElementById("docker-resources-modal"),E=document.getElementById("docker-resources-btn"),P=document.getElementById("dr-close");function w(A){if(!A||A===0)return"0 B";const v=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(A))/Math.log(1024));return(A/Math.pow(1024,L)).toFixed(1)+" "+v[L]}async function N(){const A=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let b='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';b+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of L){const k=M.name==="buildkit"||M.name.length===64;b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-vol-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete volume "${M.dataset.name}"? Data will be lost.`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(M.dataset.name)}?force=true`),N()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-vol-name"),v=A.value.trim();if(!v){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:v}),A.value="",showNotification(`Volume "${v}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function O(){const A=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){A.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let b='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';b+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const M of L){const k=["bridge","host","none"].includes(M.name);b+='<tr style="border-bottom: 1px solid var(--border);">',b+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,b+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,b+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,b+='<td style="padding: 6px; text-align: right;">',k||(b+=`<button class="dr-net-del" data-id="${escapeHtml(M.id)}" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),b+="</td></tr>"}b+="</table>",A.innerHTML=b,A.querySelectorAll(".dr-net-del").forEach(M=>{M.addEventListener("click",async()=>{if(confirm(`Delete network "${M.dataset.name}"?`)){M.textContent="...",M.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(M.dataset.id)}`),O()}catch(k){showNotification("Delete failed: "+k.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(v){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const A=document.getElementById("dr-net-name"),v=document.getElementById("dr-net-driver"),L=A.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:v.value}),A.value="",showNotification(`Network "${L}" created`,"success"),O()}catch(b){showNotification("Create failed: "+b.message,"error")}});async fuLine truncated
|
|
|
|
|
<div 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>
|
|
|
|
@@ -1529,8 +1605,8 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<div id="compose-progress-content"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),N=document.getElementById("compose-cancel");wireModal(b,N);let S=null;function T(L){document.getElementById("compose-step-paste").style.display=L==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=L==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=L==="progress"?"":"none"}E?.addEventListener("click",()=>{T("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",L=>{const H=L.target.files[0];if(!H)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(H)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const L=document.getElementById("compose-yaml").value.trim(),H=document.getElementById("compose-stack-name").value.trim()||"stack";if(!L){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),I=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:L,stackName:H});S=k,S.stackName=H,P(k),T("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=I,g.disabled=!1}});function P(L){const H=document.getElementById("compose-preview-content");let g="";L.networks&&L.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${L.networks.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),L.volumes&&L.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${L.volumes.map(I=>`<code>${escapeHtml(I)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${L.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const I of L.services){const k=I.skip?"var(--bad-fg)":"var(--border)";if(g+=`<div style="padding: 10px 14px; border: 1px solid ${k}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,g+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(I.name)}`,I.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(I.reason)}</span>`),g+="</div>",!I.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(I.image)}</code></div>`,I.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${I.ports.map(x=>`${x.host}:${x.container}`).join(", ")}</div>`),I.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${I.volumes.length}</div>`),Object.keys(I.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(I.environment).length}</div>`),I.envFileWarning&&(g+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(I.envFileWarning)}</div>`),I.resources?.cpus||I.resources?.memory)){const x=[];I.resources.cpus&&x.push(`CPU: ${I.resources.cpus}`),I.resources.memory&&x.push(`Mem: ${I.resources.memory}MB`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${x.join(", ")}</div>`}g+="</div>"}g+="</div>",H.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>T("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const L=document.getElementById("compose-deploy-btn");L.textContent="Deploying...",L.disabled=!0,T("progress");const H=document.getElementById("compose-progress-content");H.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let I=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;I+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const x=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";I+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',I+=`${x} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(I+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(I+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(I+=` <span style="color: var(--muted);">(${escapeHtml(k.reason)})</span>`),I+="</div>"}I+="</div>",I+='<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",()=>T("paste"))}finally{L.textContent="Deploy All",L.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
|
|
|
</div>`);const h=document.getElementById("compose-import-modal"),E=document.getElementById("compose-import-btn"),P=document.getElementById("compose-cancel");wireModal(h,P);let w=null;function N(z){document.getElementById("compose-step-paste").style.display=z==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=z==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=z==="progress"?"":"none"}E?.addEventListener("click",()=>{N("paste"),w=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",h?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",z=>{const A=z.target.files[0];if(!A)return;const v=new FileReader;v.onload=()=>{document.getElementById("compose-yaml").value=v.result},v.readAsText(A)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const z=document.getElementById("compose-yaml").value.trim(),A=document.getElementById("compose-stack-name").value.trim()||"stack";if(!z){showNotification("Paste a docker-compose.yml","warning");return}const v=document.getElementById("compose-parse-btn"),L=v.textContent;v.textContent="Parsing...",v.disabled=!0;try{const b=await postJSON("/api/v1/apps/import-compose",{yaml:z,stackName:A});w=b,w.stackName=A,O(b),N("preview")}catch(b){showNotification("Parse failed: "+b.message,"error")}finally{v.textContent=L,v.disabled=!1}});function O(z){const A=document.getElementById("compose-preview-content");let v="";z.networks&&z.networks.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${z.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),z.volumes&&z.volumes.length>0&&(v+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${z.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),v+=`<div style="font-weight: 600; margin-bottom: 8px;">${z.services.length} service(s)</div>`,v+='<div class="scroll-container" style="max-height: 350px;">';for(const L of z.services){const b=L.skip?"var(--bad-fg)":"var(--border)";if(v+=`<div style="padding: 10px 14px; border: 1px solid ${b}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,v+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(v+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),v+="</div>",!L.skip&&(v+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),L.volumes?.length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(v+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(v+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const M=[];L.resources.cpus&&M.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&M.push(`Mem: ${L.resources.memory}MB`),v+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}v+="</div>"}v+="</div>",A.innerHTML=v}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!w)return;const z=document.getElementById("compose-deploy-btn");z.textContent="Deploying...",z.disabled=!0,N("progress");const A=document.getElementById("compose-progress-content");A.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const v=await postJSON("/api/v1/apps/deploy-compose",{services:w.services,networks:w.networks,stackName:w.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(v.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const b of v.results){const M=b.status==="deployed"||b.status==="created"?"\u2705":b.status==="exists"?"\u26A1":b.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${M} <strong>${escapeHtml(b.name)}</strong> (${b.type}) \u2014 ${escapeHtml(b.status)}`,b.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(b.error)}</span>`),b.subdomain&&(L+=` \u2192 <code>${escapeHtml(b.subdomain)}</code>`),b.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(b.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{z.textContent="Deploy All",z.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
|
|
|
|
<h3 id="exec-title">Terminal</h3>
|
|
|
|
|
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
|
|
|
@@ -1538,11 +1614,11 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<button id="exec-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),N=document.getElementById("exec-close");let S=null,T=null,P=null;function L(){if(T){try{T.close()}catch{}T=null}if(S){try{S.dispose()}catch{}S=null}P=null,E.innerHTML=""}function H(g,I){if(L(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${I||g}`,b?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(E),P&&setTimeout(()=>P.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";T=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),T.binaryType="arraybuffer",T.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const $=P.proposeDimensions();$&&T.send(JSON.stringify({type:"resize",cols:$.cols,rows:$.rows}))}},T.onmessage=$=>{if(typeof $.data=="string"){try{const C=JSON.parse($.data);if(C.type==="connected"){S.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
|
|
|
|
|
`);return}if(C.type==="error"){S.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){S.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write($.data)}else S.write(new Uint8Array($.data))},T.onclose=()=>{S&&S.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},T.onerror=()=>{S&&S.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},S.onData($=>{T&&T.readyState===WebSocket.OPEN&&T.send($)}),S.onResize(({cols:$,rows:C})=>{T&&T.readyState===WebSocket.OPEN&&T.send(JSON.stringify({type:"resize",cols:$,rows:C}))});const x=()=>{P&&P.fit()};window.addEventListener("resize",x),b._resizeHandler=x}N?.addEventListener("click",()=>{L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(L(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=H})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
|
|
|
</div>`);const h=document.getElementById("exec-modal"),E=document.getElementById("exec-terminal"),P=document.getElementById("exec-close");let w=null,N=null,O=null;function z(){if(N){try{N.close()}catch{}N=null}if(w){try{w.dispose()}catch{}w=null}O=null,E.innerHTML=""}function A(v,L){if(z(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||v}`,h?.classList.add("show"),typeof Terminal>"u"){E.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}w=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(O=new FitAddon.FitAddon,w.loadAddon(O)),w.open(E),O&&setTimeout(()=>O.fit(),50);const b=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${b}//${location.host}/ws/exec/${encodeURIComponent(v)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(w.writeln("\x1B[32mConnecting...\x1B[0m"),O){const k=O.proposeDimensions();k&&N.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},N.onmessage=k=>{if(typeof k.data=="string"){try{const B=JSON.parse(k.data);if(B.type==="connected"){w.writeln(`\x1B[32mConnected (${B.shell})\x1B[0m\r
|
|
|
|
|
`);return}if(B.type==="error"){w.writeln(`\x1B[31mError: ${B.message}\x1B[0m`);return}if(B.type==="exit"){w.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}w.write(k.data)}else w.write(new Uint8Array(k.data))},N.onclose=()=>{w&&w.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{w&&w.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},w.onData(k=>{N&&N.readyState===WebSocket.OPEN&&N.send(k)}),w.onResize(({cols:k,rows:B})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:k,rows:B}))});const M=()=>{O&&O.fit()};window.addEventListener("resize",M),h._resizeHandler=M}P?.addEventListener("click",()=>{z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show")}),h?.addEventListener("click",v=>{v.target===h&&(z(),h._resizeHandler&&window.removeEventListener("resize",h._resizeHandler),h?.classList.remove("show"))}),window.openExecModal=A})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 850px; max-width: 1050px;">
|
|
|
|
|
<h3>\u{1F4DC} Audit Log</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1578,7 +1654,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<button id="audit-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),N=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),T=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),L=document.getElementById("audit-log-container"),H=document.getElementById("audit-load-more");let g=0;const I=50;async function k(x){try{x||(g=0,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const $=P.value;let C=`/api/v1/audit-logs?limit=${I}&offset=${g}`;$&&(C+=`&action=${encodeURIComponent($)}`);const M=await(await fetch(C)).json(),j=M.success&&M.entries?M.entries:[];if(j.length===0&&!x){L.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',H.style.display="none";return}let B="";x||(B='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',B+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">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 A of j){const w=A.outcome==="success";B+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',B+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(A.timestamp)}</td>`,B+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(A.ip||"-")}</td>`,B+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(A.action||"-")}</td>`,B+=`<td style="padding: 6px;">${escapeHtml(A.resource||"-")}</td>`,B+=`<td style="padding: 6px;"><span style="color: ${w?"var(--ok-fg)":"var(--bad-fg)"};">${w?"\u2713":"\u2717"}</span></td>`,B+="</tr>",A.details&&Object.keys(A.details).length>0&&(B+=`<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(A.details,null,2))}</pre></td></tr>`)}if(!x)B+="</table>",L.innerHTML=B;else{const A=L.querySelector("table");A&&A.insertAdjacentHTML("beforeend",B)}g+=j.length,H.style.display=j.length>=I?"":"none",L.querySelectorAll(".audit-row").forEach(A=>{A.dataset.wired||(A.dataset.wired="true",A.addEventListener("click",()=>{const w=A.nextElementSibling;w&&w.classList.contains("audit-detail")&&(w.style.display=w.style.display==="none"?"":"none")}))})}catch($){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml($.message)}</div>`}}E?.addEventListener("click",()=>{b?.classList.add("show"),k(!1)}),wireModal(b,N),S?.addEventListener("click",()=>k(!1)),P?.addEventListener("change",()=>k(!1)),H?.addEventListener("click",()=>k(!0)),T?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const $=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();$.success?k(!1):showNotification("Error: "+($.error||"Clear failed"),"error")}catch(x){showNotification("Error: "+x.message,"error")}})})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
</div>`);const h=document.getElementById("audit-modal"),E=document.getElementById("audit-log-btn"),P=document.getElementById("audit-cancel"),w=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),O=document.getElementById("audit-filter"),z=document.getElementById("audit-log-container"),A=document.getElementById("audit-load-more");let v=0;const L=50;async function b(M){try{M||(v=0,z.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const k=O.value;let B=`/api/v1/audit-logs?limit=${L}&offset=${v}`;k&&(B+=`&action=${encodeURIComponent(k)}`);const T=await(await fetch(B)).json(),j=T.success&&T.entries?T.entries:[];if(j.length===0&&!M){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>No audit log entries yet. Actions will be logged automatically.</div>',A.style.display="none";return}let H="";M||(H='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',H+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">When</th><th style="padding: 6px; text-align: left;">IP</th><th style="padding: 6px; text-align: left;">Action</th><th style="padding: 6px; text-align: left;">Resource</th><th style="padding: 6px; text-align: left;">Result</th></tr>');for(const R of j){const x=R.outcome==="success";H+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',H+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(R.timestamp)}</td>`,H+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(R.ip||"-")}</td>`,H+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(R.action||"-")}</td>`,H+=`<td style="padding: 6px;">${escapeHtml(R.resource||"-")}</td>`,H+=`<td style="padding: 6px;"><span style="color: ${x?"var(--ok-fg)":"var(--bad-fg)"};">${x?"\u2713":"\u2717"}</span></td>`,H+="</tr>",R.details&&Object.keys(R.details).length>0&&(H+=`<tr class="audit-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(R.details,null,2))}</pre></td></tr>`)}if(!M)H+="</table>",z.innerHTML=H;else{const R=z.querySelector("table");R&&R.insertAdjacentHTML("beforeend",H)}v+=j.length,A.style.display=j.length>=L?"":"none",z.querySelectorAll(".audit-row").forEach(R=>{R.dataset.wired||(R.dataset.wired="true",R.addEventListener("click",()=>{const x=R.nextElementSibling;x&&x.classList.contains("audit-detail")&&(x.style.display=x.style.display==="none"?"":"none")}))})}catch(k){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(k.message)}</div>`}}E?.addEventListener("click",()=>{h?.classList.add("show"),b(!1)}),wireModal(h,P),w?.addEventListener("click",()=>b(!1)),O?.addEventListener("change",()=>b(!1)),A?.addEventListener("click",()=>b(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const k=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"})).json();k.success?b(!1):showNotification("Error: "+(k.error||"Clear failed"),"error")}catch(M){showNotification("Error: "+M.message,"error")}})})(),(function(){const h=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
<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>
|
|
|
|
@@ -1589,23 +1665,23 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<label class="weather-unit-option"><input type="radio" name="weather-unit-radio" value="metric"><span class="weather-unit-card">°C / km/h</span></label>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",N="weather-zip",S="weather-geo",T="weather-unit";!safeGet(E)&&safeGet(N)&&safeSet(E,safeGet(N));function P(){return safeGet(T)||"imperial"}function L(){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 H={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},g={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},I=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(B){return I[Math.round(B/22.5)%16]}async function x(B){const A=safeGet(S);if(A)try{const y=JSON.parse(A);if(y.query===B)return y}catch{}const w=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(B)}&count=1&language=en&format=json`);if(!w.ok)throw new Error("Geocoding failed");const z=await w.json();if(!z.results||!z.results.length)throw new Error("Location not found");const f=z.results[0],p={query:B,lat:f.latitude,lon:f.longitude,city:f.name,state:f.admin1||"",country:f.country||"",countryCode:f.country_code||""};return safeSet(S,JSON.stringify(p)),p}function $(B){return B.countryCode==="US"&&B.state?`${B.city}, ${B.state}`:B.country?`${B.city}, ${B.country}`:B.city}async function C(B){try{const A=await x(B),w=P(),z=w==="metric"?"celsius":"fahrenheit",f=w==="metric"?"kmh":"mph",p=`https://api.open-meteo.com/v1/forecast?latitude=${A.lat}&longitude=${A.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${z}&wind_speed_unit=${f}`,y=await fetch(p);if(!y.ok)throw new Error("Weather fetch failed");const m=(await y.json()).current,r=m.weather_code;return{temp:Math.round(m.temperature_2m),condition:H[r]||"Unknown",icon:g[r]||"\u{1F324}\uFE0F",locationStr:$(A),windSpeed:Math.round(m.wind_speed_10m),windDir:k(m.wind_direction_10m),unit:w}}catch(A){return console.warn("Weather fetch failed:",A),null}}async function R(){const B=L();if(!B.icon||!B.temp||!B.condition||!B.location||!B.wind){console.warn("Weather widget elements not found");return}const A=safeGet(E);if(!A){B.location.textContent="Set Location",B.temp.textContent="--\xB0",B.condition.textContent="Click \u2699\uFE0F to configure",B.wind.textContent="--",B.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const w=await C(A);if(w){const z=w.unit==="metric"?"\xB0C":"\xB0F",f=w.unit==="metric"?"km/h":"mph";B.location.textContent=w.locationStr,B.temp.textContent=`${w.temp}${z}`,B.condition.textContent=w.condition,B.wind.textContent=`Wind: ${w.windSpeed} ${f} ${w.windDir}`,B.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(w.icon)}</span>`}}catch(w){b.logError("[Weather] Update Error",w,{function:"updateWeather"}),B.location.textContent="Weather Error",B.temp.textContent="Error",B.condition.textContent="Failed to load",B.wind.textContent="--"}}const M=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const B=P(),A=M.querySelector(`input[name="weather-unit-radio"][value="${B}"]`);A&&(A.checked=!0),M.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{M.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const B=j.value.trim();if(B){safeGet(E)!==B&&safeSet(S,""),safeSet(E,B);const w=M.querySelector('input[name="weather-unit-radio"]:checked'),z=w?w.value:"imperial",f=P();safeSet(T,z),f!==z&&safeSet(S,""),M.classList.remove("show"),R()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(M),document.addEventListener("keydown",B=>{B.keyLine truncated
|
|
|
|
|
<circle cx="${d}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const E="weather-location",P="weather-zip",w="weather-geo",N="weather-unit";!safeGet(E)&&safeGet(P)&&safeSet(E,safeGet(P));function O(){return safeGet(N)||"imperial"}function z(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const A={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},v={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function b(H){return L[Math.round(H/22.5)%16]}async function M(H){const R=safeGet(w);if(R)try{const f=JSON.parse(R);if(f.query===H)return f}catch{}const x=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(H)}&count=1&language=en&format=json`);if(!x.ok)throw new Error("Geocoding failed");const D=await x.json();if(!D.results||!D.results.length)throw new Error("Location not found");const g=D.results[0],u={query:H,lat:g.latitude,lon:g.longitude,city:g.name,state:g.admin1||"",country:g.country||"",countryCode:g.country_code||""};return safeSet(w,JSON.stringify(u)),u}function k(H){return H.countryCode==="US"&&H.state?`${H.city}, ${H.state}`:H.country?`${H.city}, ${H.country}`:H.city}async function B(H){try{const R=await M(H),x=O(),D=x==="metric"?"celsius":"fahrenheit",g=x==="metric"?"kmh":"mph",u=`https://api.open-meteo.com/v1/forecast?latitude=${R.lat}&longitude=${R.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${D}&wind_speed_unit=${g}`,f=await fetch(u);if(!f.ok)throw new Error("Weather fetch failed");const p=(await f.json()).current,d=p.weather_code;return{temp:Math.round(p.temperature_2m),condition:A[d]||"Unknown",icon:v[d]||"\u{1F324}\uFE0F",locationStr:k(R),windSpeed:Math.round(p.wind_speed_10m),windDir:b(p.wind_direction_10m),unit:x}}catch(R){return console.warn("Weather fetch failed:",R),null}}async function S(){const H=z();if(!H.icon||!H.temp||!H.condition||!H.location||!H.wind){console.warn("Weather widget elements not found");return}const R=safeGet(E);if(!R){H.location.textContent="Set Location",H.temp.textContent="--\xB0",H.condition.textContent="Click \u2699\uFE0F to configure",H.wind.textContent="--",H.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const x=await B(R);if(x){const D=x.unit==="metric"?"\xB0C":"\xB0F",g=x.unit==="metric"?"km/h":"mph";H.location.textContent=x.locationStr,H.temp.textContent=`${x.temp}${D}`,H.condition.textContent=x.condition,H.wind.textContent=`Wind: ${x.windSpeed} ${g} ${x.windDir}`,H.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(x.icon)}</span>`}}catch(x){h.logError("[Weather] Update Error",x,{function:"updateWeather"}),H.location.textContent="Weather Error",H.temp.textContent="Error",H.condition.textContent="Failed to load",H.wind.textContent="--"}}const T=document.getElementById("weather-modal"),j=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{j.value=safeGet(E)||"";const H=O(),R=T.querySelector(`input[name="weather-unit-radio"][value="${H}"]`);R&&(R.checked=!0),T.classList.add("show"),j.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const H=j.value.trim();if(H){safeGet(E)!==H&&safeSet(w,""),safeSet(E,H);const x=T.querySelector('input[name="weather-unit-radio"]:checked'),D=x?x.value:"imperial",g=O();safeSet(N,D),g!==D&&safeSet(w,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",H=>{H.keyLine truncated
|
|
|
|
|
<circle cx="${s}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${U}
|
|
|
|
|
${q}
|
|
|
|
|
<line x1="${d}" y1="${l}" x2="${d+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${d}" y1="${l}" x2="${d+38*Math.cos(O*Math.PI/180)}" y2="${l+38*Math.sin(O*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${d}" y1="${l}" x2="${d+42*Math.cos(D*Math.PI/180)}" y2="${l+42*Math.sin(D*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${d}" cy="${l}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,W=a.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${a.getHours()%12||12}:${String(t).padStart(2,"0")} ${W}</span><span class="analog-date-sm">${C(a)}</span></div></div>`,I="analog"}function p(){const a=new Date,e=a.getHours()%12||12,n=a.getMinutes(),t=a.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==i&&(b.className=i),P){case"lcd":B(a);break;case"lcd-blue":B(a);break;case"lcd-amber":B(a);break;case"lcd-retro":B(a);break;case"lcd-taxi":B(a);break;case"flip":A(a);break;case"binary":z(a);break;case"analog":f(a,!1);break;case"roman":f(a,!0);break;default:j(a)}n===0&&t===0&&e!==L&&(L=e,$(e)),n!==0&&(L=-1)}function y(){clearTimeout(x);const a=document.hidden?6e4:1e3,e=a-Date.now()%a+25;x=setTimeout(()=>{p(),y()},e)}document.addEventListener("visibilitychange",()=>{g="",R(),p(),y()}),p(),y();const v=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let m='<div class="clock-style-grid">';v.forEach(a=>{m+=`<label class="clock-style-option">
|
|
|
|
|
<input type="radio" name="clock-style-radio" value="${a.id}">
|
|
|
|
|
<span class="clock-style-card"><span class="clock-style-icon">${a.icon}</span><span class="clock-style-label">${a.label}</span></span>
|
|
|
|
|
</label>`}),m+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
|
|
|
<line x1="${s}" y1="${l}" x2="${s+28*Math.cos(F*Math.PI/180)}" y2="${l+28*Math.sin(F*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${s}" y1="${l}" x2="${s+38*Math.cos(I*Math.PI/180)}" y2="${l+38*Math.sin(I*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${s}" y1="${l}" x2="${s+42*Math.cos(C*Math.PI/180)}" y2="${l+42*Math.sin(C*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${s}" cy="${l}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,J=n.getHours()>=12?"PM":"AM";E.innerHTML=`<div class="analog-clock-wrap">${G}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(a).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${B(n)}</span></div></div>`,L="analog"}function u(){const n=new Date,e=n.getHours()%12||12,o=n.getMinutes(),a=n.getSeconds(),r="clock-widget"+(O!=="default"?" "+O:"");switch(h.className!==r&&(h.className=r),O){case"lcd":H(n);break;case"lcd-blue":H(n);break;case"lcd-amber":H(n);break;case"lcd-retro":H(n);break;case"lcd-taxi":H(n);break;case"flip":R(n);break;case"binary":D(n);break;case"analog":g(n,!1);break;case"roman":g(n,!0);break;default:j(n)}o===0&&a===0&&e!==z&&(z=e,k(e)),o!==0&&(z=-1)}function f(){clearTimeout(M);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;M=setTimeout(()=>{u(),f()},e)}document.addEventListener("visibilitychange",()=>{v="",S(),u(),f()}),u(),f();const m=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let p='<div class="clock-style-grid">';m.forEach(n=>{p+=`<label class="clock-style-option">
|
|
|
|
|
<input type="radio" name="clock-style-radio" value="${n.id}">
|
|
|
|
|
<span class="clock-style-card"><span class="clock-style-icon">${n.icon}</span><span class="clock-style-label">${n.label}</span></span>
|
|
|
|
|
</label>`}),p+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="max-width: 420px;">
|
|
|
|
|
<h3>Clock Settings</h3>
|
|
|
|
|
<div style="margin-bottom: 16px;">
|
|
|
|
|
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--fg);">Style</label>
|
|
|
|
|
${m}
|
|
|
|
|
${p}
|
|
|
|
|
</div>
|
|
|
|
|
<div style="margin-bottom: 16px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
|
|
|
<label style="display: flex; align-items: center; gap: 8px; cursor: pointer; font-weight: 600; color: var(--fg);">
|
|
|
|
@@ -1629,8 +1705,7 @@ Enter version to rollback to:`);if(!e)return;if(!a.includes(e)){showNotification
|
|
|
|
|
<button id="clock-settings-save">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const r=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),s=document.getElementById("clock-chime-volume"),h=document.getElementById("clock-volume-section");function u(){const a=safeGet("clock-style")||"default",e=r.querySelector(`input[value="${a}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",s.value=safeGet("clock-chime-volume")||"50",h.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{h.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{u(),r.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const a=parseInt(s.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=a,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const a=r.querySelector('input[name="clock-style-radio"]:checked'),e=a?a.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",s.value),P=e,g="",R(),p(),y(),r.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{r.classList.remove("show")}),wireModal(r),r?.querySelectorAll('input[name="clock-style-radio"]').forEach(a=>{a.addEventListener("change",()=>{P=a.value,g="",R(),p()})})})(),(function(){async function b(){try{const L=await(await fetch("/api/v1/health-checks/status")).json();if(!L.success||!L.status)return;for(const[H,g]of Object.entries(L.status)){const I=document.getElementById("uptime-"+H),k=document.getElementById("uptime-bar-"+H);if(!I)continue;const x=g.uptime?.["24h"];if(x!=null){const $=x.toFixed(1);I.textContent=`${$}% uptime`,I.className="uptime-chip",x>=99.9?I.classList.add("excellent"):x>=99?I.classList.add("good"):x>=95?I.classList.add("degraded"):I.classList.add("poor"),k&&(k.style.width=$+"%")}}}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 N(){try{const L=await(await fetch("/api/v1/updates/available")).json();if(!L.success||(document.querySelectorAll(".update-available-badge").forEach(H=>H.classList.remove("visible")),!L.updates?.length))return;for(const H of L.updates){const g=window.APPS||[];for(const I of g)if(I.containerId===H.containerId||I.id===H.containerName||I.name===H.containerName){if(E.has(I.id))break;const k=document.getElementById("update-badge-"+I.id);k&&(k.classList.add("visible"),k.title=`Image digest changed. Click to dismiss if already up to date.
|
|
|
|
|
${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k.classList.remove("visible"),E.add(I.id),safeSessionSet("dismissed-updates",JSON.stringify([...E]))});break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function S(){setTimeout(()=>{b(),N()},5e3),setInterval(()=>{b(),N()},6e4)}const T=window.refreshAll;T&&(window.refreshAll=async function(){try{await T(),setTimeout(b,1e3)}catch(P){console.warn("[Card Badges] Error in refreshAll hook:",P.message)}}),S()})(),(function(){var b=null,E=null,N={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},T=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var L=document.getElementById("theme-label");function H(t){if(S[t])return S[t];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[t]&&i[t].name||t}function g(){L&&(L.textContent=H(window.getActiveTheme()))}P.addEventListener("click",function(){var t=window.THEMES.slice(),i=window.getActiveTheme(),o=t.indexOf(i),d=t[(o+1)%t.length];window.applyTheme(d),g()}),g();function I(){var t={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-derived)"},i={};T.forEach(function(d){i[d[2]]||(i[d[2]]=[]),i[d[2]].push(d)});var o="";return Object.keys(t).forEach(function(d){d==="advanced"?(o+='<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>',o+='<div id="theme-builder-advanced" class="theme-builder-section" style="display:none;">'):o+='<div class="theme-builder-section">',o+='<div class="theme-builder-section-title">'+t[d]+"</div>",(i[d]||[]).forEach(function(l){o+='<div class="theme-builder-row"><span class="theme-builder-label">'+l[1]+'</span><input type="color" class="theme-builder-color" data-prop="'+l[0]+'"'+(d==="advanced"?' data-advanced="1"':"")+' value="#000000" /><span class="theme-builder-hex" data-hex="'+l[0]+'">#000000</span></div>'}),o+="</div>"}),o}function k(){return window.THEMES.map(function(t){return'<option value="'+t+'">'+H(t)+"</option>"}).join("")}var x='<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;">'+k()+'</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 d=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),i=document.getElementById("clock-chime-volume"),$=document.getElementById("clock-volume-section");function y(){const n=safeGet("clock-style")||"default",e=d.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",i.value=safeGet("clock-chime-volume")||"50",$.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{$.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{y(),d.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(i.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=n,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=d.querySelector('input[name="clock-style-radio"]:checked'),e=n?n.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",i.value),O=e,v="",S(),u(),f(),d.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{d.classList.remove("show")}),wireModal(d),d?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{O=n.value,v="",S(),u()})})})(),(function(){async function h(){try{const A=await(await fetch("/api/v1/health-checks/status")).json();if(!A.success||!A.status)return;for(const[v,L]of Object.entries(A.status)){const b=document.getElementById("uptime-"+v),M=document.getElementById("uptime-bar-"+v);if(!b)continue;const k=L.uptime?.["24h"];if(k!=null){const B=k.toFixed(1);b.textContent=`${B}% uptime`,b.className="uptime-chip",k>=99.9?b.classList.add("excellent"):k>=99?b.classList.add("good"):k>=95?b.classList.add("degraded"):b.classList.add("poor"),M&&(M.style.width=B+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let E;try{E=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{E=new Set}let P=[];async function w(z){try{const v=await(await fetch("/api/v1/updates/available")).json();if(!v.success)return;document.querySelectorAll(".update-available-badge").forEach(b=>b.classList.remove("visible"));const L=v.updates||[];if(P=L,z&&L.length>0){const b=window._lastKnownUpdateCount||0;b>0&&L.length>b&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const b of L){const M=window.APPS||[];for(const k of M)if(k.containerId===b.containerId||k.id===b.containerName||k.name===b.containerName){if(E.has(k.id))break;const B=document.getElementById("update-badge-"+k.id),S=document.getElementById("update-btn-"+k.id);B&&(B.classList.add("visible"),B.title="Update available \u2014 click to open Update Management.",B.style.cursor="pointer",B.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),S&&(S.style.background="#f97316",S.style.borderColor="#f97316",S.style.boxShadow="0 0 6px #f9731688",S.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{h(),w()},5e3),setInterval(()=>{h(),w(!0)},6e4)}const O=window.refreshAll;O&&(window.refreshAll=async function(){try{await O(),setTimeout(h,1e3)}catch(z){console.warn("[Card Badges] Error in refreshAll hook:",z.message)}}),N()})(),(function(){var h=null,E=null,P={},w={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},N=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],O=document.getElementById("theme");if(!O)return;var z=document.getElementById("theme-label");function A(a){if(w[a])return w[a];var r=safeGetJSON(window.USER_THEMES_KEY,{});return r[a]&&r[a].name||a}function v(){z&&(z.textContent=A(window.getActiveTheme()))}O.addEventListener("click",function(){var a=window.THEMES.slice(),r=window.getActiveTheme(),t=a.indexOf(r),s=a[(t+1)%a.length];window.applyTheme(s),v()}),v();function L(){var a={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
|
|
|
|
<div 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;">
|
|
|
|
@@ -1667,13 +1742,13 @@ ${H.imageName||""}`,k.style.cursor="pointer",k.onclick=x=>{x.stopPropagation(),k
|
|
|
|
|
<button id="license-activate" class="btn-accent">Activate</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),N=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),T=document.getElementById("license-error"),P=document.getElementById("license-success"),L=document.getElementById("license-badge-icon"),H=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),I=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),x=document.getElementById("license-activate-section");let $=null;function C(){T.style.display="none",P.style.display="none"}function R(y){C(),T.textContent=y,T.style.display="block"}function M(y){C(),P.textContent=y,P.style.display="block"}function j(y){if($=y,y.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",L.textContent="\u2605",H.textContent="Premium Active";const r=y.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(y.expiresAt).toLocaleDateString()}</strong> (${y.daysRemaining} days remaining)</div>`;I.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${y.code||"***"}</code></div>
|
|
|
|
|
${r}
|
|
|
|
|
`,x.style.display="none",N.style.display="none",S.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",L.textContent="\u2606",H.textContent=y.expired?"License Expired":"Free Tier",I.innerHTML=y.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",x.style.display="",N.style.display="",S.style.display="none";const v=y.premiumFeatures||{},m=new Set(y.features||[]);k.innerHTML=Object.entries(v).map(([r,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
|
|
|
|
<span style="font-size: 1.1rem;">${m.has(r)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
</div>`);const h=document.getElementById("license-modal"),E=document.getElementById("license-code-input"),P=document.getElementById("license-activate"),w=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),O=document.getElementById("license-success"),z=document.getElementById("license-badge-icon"),A=document.getElementById("license-badge-text"),v=document.getElementById("license-badge"),L=document.getElementById("license-details"),b=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let k=null;function B(){N.style.display="none",O.style.display="none"}function S(f){B(),N.textContent=f,N.style.display="block"}function T(f){B(),O.textContent=f,O.style.display="block"}function j(f){if(k=f,f.active){v.style.background="rgba(46,204,113,0.15)",v.style.color="var(--ok-fg)",z.textContent="\u2605",A.textContent="Premium Active";const d=f.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(f.expiresAt).toLocaleDateString()}</strong> (${f.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${f.code||"***"}</code></div>
|
|
|
|
|
${d}
|
|
|
|
|
`,M.style.display="none",P.style.display="none",w.style.display=""}else v.style.background="rgba(149,165,166,0.15)",v.style.color="var(--muted)",z.textContent="\u2606",A.textContent=f.expired?"License Expired":"Free Tier",L.innerHTML=f.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",M.style.display="",P.style.display="",w.style.display="none";const m=f.premiumFeatures||{},p=new Set(f.features||[]);b.innerHTML=Object.entries(m).map(([d,c])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
|
|
|
|
<span style="font-size: 1.1rem;">${p.has(d)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`).join("")}async function B(){try{const v=await(await fetch("/api/v1/license/status")).json();v.success&&(j(v.license),z(v.license))}catch(y){console.warn("Failed to load license status:",y.message)}}async function A(){const y=E.value.trim();if(!y){R("Please enter a license code.");return}C(),N.disabled=!0,N.textContent="Activating...";try{const m=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:y})})).json();m.success?(M(m.message),E.value="",j(m.license),showNotification("License activated! Premium features unlocked.","success",5e3),z(m.license)):R(m.error||"Activation failed")}catch(v){R("Network error: "+v.message)}finally{N.disabled=!1,N.textContent="Activate"}}async function w(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const v=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();v.success?(M(v.message),await B(),showNotification("License deactivated.","info",3e3),z({active:!1})):R(v.error||"Deactivation failed")}catch(y){R("Network error: "+y.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function z(y){const v=document.getElementById("license-status-topbar"),m=document.getElementById("license-topbar-icon"),r=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(v)if(v.className="license-status-topbar "+(y.active?"premium":"free"),y.active)if(m.textContent="\u2605",r.textContent="PREMIUM",y.lifetime)c.textContent="\xB7 LIFETIME";else{const s=y.daysRemaining;c.textContent=s!=null?"\xB7 "+s+"d remaining":""}else m.textContent="\u2606",r.textContent=y.expired?"EXPIRED":"FREE TIER",c.textContent=""}function f(){C(),B(),b.classList.add("show")}E.addEventListener("input",function(){let y=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(y.length>this._prevLength&&(y=y.replace(/-/g,""),y.length>2&&!y.startsWith("DC")&&(y="DC"+y),y.startsWith("DC")&&y.length>2)){const v=["DC"],m=y.substring(2);for(let r=0;r<m.length;r+=5)v.push(m.substring(r,r+5));y=v.join("-")}this._prevLength=y.length,this.value=y}),N.addEventListener("click",A),S.addEventListener("click",w),E.addEventListener("keydown",y=>{y.key==="Enter"&&A()}),wireModal(b,document.getElementById("license-cancel"));const p=document.getElementById("license-status-topbar");p&&p.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=f,window.checkPremiumFeature=async function(y){try{return(await(await fetch(`/api/v1/license/feature/${y}`)).json()).available}catch{return!1}},B().then(y=>{$&&z($)})})();
|
|
|
|
|
</div>`).join("")}async function H(){try{const m=await(await fetch("/api/v1/license/status")).json();m.success&&(j(m.license),D(m.license))}catch(f){console.warn("Failed to load license status:",f.message)}}async function R(){const f=E.value.trim();if(!f){S("Please enter a license code.");return}B(),P.disabled=!0,P.textContent="Activating...";try{const p=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:f})})).json();p.success?(T(p.message),E.value="",j(p.license),showNotification("License activated! Premium features unlocked.","success",5e3),D(p.license)):S(p.error||"Activation failed")}catch(m){S("Network error: "+m.message)}finally{P.disabled=!1,P.textContent="Activate"}}async function x(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){w.disabled=!0,w.textContent="Deactivating...";try{const m=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();m.success?(T(m.message),await H(),showNotification("License deactivated.","info",3e3),D({active:!1})):S(m.error||"Deactivation failed")}catch(f){S("Network error: "+f.message)}finally{w.disabled=!1,w.textContent="Deactivate"}}}function D(f){const m=document.getElementById("license-status-topbar"),p=document.getElementById("license-topbar-icon"),d=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(m)if(m.className="license-status-topbar "+(f.active?"premium":"free"),f.active)if(p.textContent="\u2605",d.textContent="PREMIUM",f.lifetime)c.textContent="\xB7 LIFETIME";else{const i=f.daysRemaining;c.textContent=i!=null?"\xB7 "+i+"d remaining":""}else p.textContent="\u2606",d.textContent=f.expired?"EXPIRED":"FREE TIER",c.textContent=""}function g(){B(),H(),h.classList.add("show")}E.addEventListener("input",function(){let f=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(f.length>this._prevLength&&(f=f.replace(/-/g,""),f.length>2&&!f.startsWith("DC")&&(f="DC"+f),f.startsWith("DC")&&f.length>2)){const m=["DC"],p=f.substring(2);for(let d=0;d<p.length;d+=5)m.push(p.substring(d,d+5));f=m.join("-")}this._prevLength=f.length,this.value=f}),P.addEventListener("click",R),w.addEventListener("click",x),E.addEventListener("keydown",f=>{f.key==="Enter"&&R()}),wireModal(h,document.getElementById("license-cancel"));const u=document.getElementById("license-status-topbar");u&&u.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=g,window.checkPremiumFeature=async function(f){try{return(await(await fetch(`/api/v1/license/feature/${f}`)).json()).available}catch{return!1}},H().then(f=>{k&&D(k)})})();
|