|
|
|
@@ -90,44 +90,44 @@
|
|
|
|
|
<button id="logo-save" class="btn-accent">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),m=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),A=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),g=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),k=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),I=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),E=DC.NAME;let T=null,P=null,v=null,B="left",z=E;N?.addEventListener("change",()=>{N.checked?(A.style.display="none",D.style.display="",T=null,P=null):(A.style.display="flex",D.style.display="none",v=null)});function O(n,t){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const r=new FileReader;r.onload=e=>t(e.target.result),r.readAsDataURL(n)}R?.addEventListener("change",n=>{O(n.target.files[0],t=>{T=t,h.src=t,m.textContent="New dark logo ready to save"})}),g?.addEventListener("change",n=>{O(n.target.files[0],t=>{P=t,j.src=t,m.textContent="New light logo ready to save"})}),L?.addEventListener("change",n=>{O(n.target.files[0],t=>{v=t,h.src=t,j.src=t,m.textContent="New logo ready to save (both themes)"})});function u(n){I.setAttribute("data-logo-pos",n),document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.style.background=t.dataset.pos===n?"var(--accent)":"var(--card-bg)",t.style.color=t.dataset.pos===n?"white":"var(--fg)"})}function y(n){z=n||E,document.title=z;const t=document.querySelector(".dashboard-title");t&&(t.textContent=z)}async function C(){try{const n=await fetch("/api/v1/logo");if(n.ok){const t=await n.json();t.customLogoDark&&(k.src=t.customLogoDark,h.src=t.customLogoDark),t.customLogoLight&&(M.src=t.customLogoLight,j.src=t.customLogoLight),!t.customLogoDark&&!t.customLogoLight&&t.customLogo&&(k.src=t.customLogo,M.src=t.customLogo,h.src=t.customLogo,j.src=t.customLogo),t.isDefault||(m.textContent="Using custom logo"),t.position&&(B=t.position,u(t.position)),t.dashboardTitle&&y(t.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{B=n.dataset.pos,u(B)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,P=null,v=null,R&&(R.value=""),g&&(g.value=""),L&&(L.value=""),N&&(N.checked=!1),A.style.display="flex",D.style.display="none",h.src=k.src,j.src=M.src;const n=k.src.includes("custom-logo")||M.src.includes("custom-logo");m.textContent=n?"Using custom logo":"Using default logos",u(B),$.value=z,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=$.value.trim()||E,t={position:B,dashboardTitle:n};N?.checked&&v?(t.dataDark=v,t.dataLight=v):(T&&(t.dataDark=T),P&&(t.dataLight=P));const r=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)});if(r.ok){const e=await r.json(),o="?t="+Date.now();e.pathDark&&(k.src=e.pathDark+o,h.src=e.pathDark+o),e.pathLight&&(M.src=e.pathLight+o,j.src=e.pathLight+o),u(B),y(n),b.classList.remove("show")}else{const e=await r.json();showNotification("Failed to save: "+e.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?
|
|
|
|
|
</div>`);const x=document.getElementById("logo-modal"),b=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),g=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),A=document.getElementById("logo-dual-uploads"),D=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),f=document.getElementById("logo-upload-light"),C=document.getElementById("logo-upload-single"),E=document.querySelector("#brand .brand-logo-dark"),M=document.querySelector("#brand .brand-logo-light"),w=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),S=DC.NAME;let T=null,P=null,v=null,B="left",H=S;N?.addEventListener("change",()=>{N.checked?(A.style.display="none",D.style.display="",T=null,P=null):(A.style.display="flex",D.style.display="none",v=null)});function O(n,e){if(!n||!n.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const r=new FileReader;r.onload=s=>e(s.target.result),r.readAsDataURL(n)}R?.addEventListener("change",n=>{O(n.target.files[0],e=>{T=e,b.src=e,g.textContent="New dark logo ready to save"})}),f?.addEventListener("change",n=>{O(n.target.files[0],e=>{P=e,j.src=e,g.textContent="New light logo ready to save"})}),C?.addEventListener("change",n=>{O(n.target.files[0],e=>{v=e,b.src=e,j.src=e,g.textContent="New logo ready to save (both themes)"})});function u(n){w.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 m(n){H=n||S,document.title=H;const e=document.querySelector(".dashboard-title");e&&(e.textContent=H)}async function L(){try{const n=await fetch("/api/v1/logo");if(n.ok){const e=await n.json();e.customLogoDark&&(E.src=e.customLogoDark,b.src=e.customLogoDark),e.customLogoLight&&(M.src=e.customLogoLight,j.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(E.src=e.customLogo,M.src=e.customLogo,b.src=e.customLogo,j.src=e.customLogo),e.isDefault||(g.textContent="Using custom logo"),e.position&&(B=e.position,u(e.position)),e.dashboardTitle&&m(e.dashboardTitle)}}catch(n){console.warn("Could not load custom logo:",n.message)}}document.querySelectorAll(".logo-pos-btn").forEach(n=>{n.addEventListener("click",()=>{B=n.dataset.pos,u(B)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,P=null,v=null,R&&(R.value=""),f&&(f.value=""),C&&(C.value=""),N&&(N.checked=!1),A.style.display="flex",D.style.display="none",b.src=E.src,j.src=M.src;const n=E.src.includes("custom-logo")||M.src.includes("custom-logo");g.textContent=n?"Using custom logo":"Using default logos",u(B),z.value=H,x.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const n=z.value.trim()||S,e={position:B,dashboardTitle:n};N?.checked&&v?(e.dataDark=v,e.dataLight=v):(T&&(e.dataDark=T),P&&(e.dataLight=P));const r=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(r.ok){const s=await r.json(),a="?t="+Date.now();s.pathDark&&(E.src=s.pathDark+a,b.src=s.pathDark+a),s.pathLight&&(M.src=s.pathLight+a,j.src=s.pathLight+a),u(B),m(n),x.classList.remove("show")}else{const s=await r.json();showNotification("Failed to save: "+s.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",M.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",m.textContent="Using default logos",T=null,P=null,v=null,$.value=E,y(E),B="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const r=document.querySelector('link[rel="icon"]'),e=document.getElementById("favicon-preview"),o=document.getElementById("favicon-status");r&&(r.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),e&&(e.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.textContent="Using DashCaddy favicon"),l=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const w=document.getElementById("favicon-preview"),S=document.getElementById("favicon-status"),i=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function f(){try{const n=await fetch("/api/v1/favicon");if(n.ok){const t=await n.json();t.customFavicon&&(p.href=t.customFavicon+"?t="+Date.now(),w.src=t.customFavicon+"?t="+Date.now(),S.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}i?.addEventListener("change",n=>{const t=n.target.files[0];if(!t)return;if(!t.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),i.value="";return}const r=new FileReader;r.onload=e=>{l=e.target.result,w.src=l,S.textContent="New favicon ready to save"},r.readAsDataURL(t)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(n.ok){const t=await n.json();p.href=t.path+"?t="+Date.now(),w.src=t.path+"?t="+Date.now(),S.textContent="Using custom favicon",l=null}else{const t=await n.json();showNotification("Failed to save favicon: "+t.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),f(),C();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{b.classList.contains("show")&&c.options.length===0&&(async()=>{let t;try{const r=await fetch("/api/v1/config");r.ok&&(t=(await r.json()).timezone)}catch{}window.populateTimezoneSelect(c,t)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const t=c.value;if(t)try{const r=await fetch("/api/v1/config");if(!r.ok)return;const e=await r.json();e.timezone=t,e.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})}catch(r){console.warn("Failed to save timezone:",r.message)}}))})(),window.populateTimezoneSelect=function(b,h){const j=Intl.supportedValuesOf("timeZone"),m=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const N of j){const A=document.createElement("option");A.value=N,A.textContent=N.replace(/_/g," "),N===m&&(A.selected=!0),b.appendChild(A)}},(function(){let b="homelab",h=null;async function j(){try{const y=await fetch("/api/v1/config");if(y.ok&&(h=await y.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(y){console.warn("Could not fetch server config, checking localStorage fallback:",y.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const m=document.getElementById("setup-timezone");m&&window.populateTimezoneSelect(m);function N(u){document.querySelectorAll(".setup-step").forEach(C=>{C.style.display="none"});const y=document.getElementById(u);y&&(y.style.display="block")}function A(){const u=document.getElementById("setup-summary-content");if(!u)return;let y='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const w=document.getElementById("setup-tld")?.value?.trim()||".home",S=document.getElementById("setup-ca-name")?.value?.trim()||"",i=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;y+=`
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(E.src="/assets/dashcaddy-logo-dark.png",M.src="/assets/dashcaddy-logo-light.png",b.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",g.textContent="Using default logos",T=null,P=null,v=null,z.value=S,m(S),B="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const r=document.querySelector('link[rel="icon"]'),s=document.getElementById("favicon-preview"),a=document.getElementById("favicon-status");r&&(r.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),s&&(s.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),a&&(a.textContent="Using DashCaddy favicon"),d=null}}catch(n){showNotification("Error resetting branding: "+n.message,"error")}}),wireModal(x,document.getElementById("logo-cancel"));const k=document.getElementById("favicon-preview"),I=document.getElementById("favicon-status"),i=document.getElementById("favicon-upload"),c=document.querySelector('link[rel="icon"]')||document.createElement("link");let d=null;document.querySelector('link[rel="icon"]')||(c.rel="icon",c.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(c));async function y(){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(),k.src=e.customFavicon+"?t="+Date.now(),I.textContent="Using custom favicon")}}catch(n){console.warn("Could not load custom favicon:",n.message)}}i?.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"),i.value="";return}const r=new FileReader;r.onload=s=>{d=s.target.result,k.src=d,I.textContent="New favicon ready to save"},r.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const n=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(n.ok){const e=await n.json();c.href=e.path+"?t="+Date.now(),k.src=e.path+"?t="+Date.now(),I.textContent="Using custom favicon",d=null}else{const e=await n.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(n){showNotification("Error saving favicon: "+n.message,"error")}}),y(),L();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{x.classList.contains("show")&&p.options.length===0&&(async()=>{let e;try{const r=await fetch("/api/v1/config");r.ok&&(e=(await r.json()).timezone)}catch{}window.populateTimezoneSelect(p,e)})()}).observe(x,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=p.value;if(e)try{const r=await fetch("/api/v1/config");if(!r.ok)return;const s=await r.json();s.timezone=e,s.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}catch(r){console.warn("Failed to save timezone:",r.message)}}))})(),window.populateTimezoneSelect=function(x,b){const j=Intl.supportedValuesOf("timeZone"),g=b||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";x.innerHTML="";for(const N of j){const A=document.createElement("option");A.value=N,A.textContent=N.replace(/_/g," "),N===g&&(A.selected=!0),x.appendChild(A)}},(function(){let x="homelab",b=null;async function j(){try{const m=await fetch("/api/v1/config");if(m.ok&&(b=await m.json(),b&&b.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(m){console.warn("Could not fetch server config, checking localStorage fallback:",m.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const g=document.getElementById("setup-timezone");g&&window.populateTimezoneSelect(g);function N(u){document.querySelectorAll(".setup-step").forEach(L=>{L.style.display="none"});const m=document.getElementById(u);m&&(m.style.display="block")}function A(){const u=document.getElementById("setup-summary-content");if(!u)return;let m='<div style="display: grid; gap: 20px;">';if(x==="homelab"){const k=document.getElementById("setup-tld")?.value?.trim()||".home",I=document.getElementById("setup-ca-name")?.value?.trim()||"",i=document.getElementById("setup-dns-ip")?.value?.trim()||"",c=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;m+=`
|
|
|
|
|
<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> ${w}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${S}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${i}:${p}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${w}, https://nextcloud${w}</div>
|
|
|
|
|
<div><strong>TLD:</strong> ${k}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${I}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${i}:${c}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${k}, https://nextcloud${k}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(b==="simple"){const w=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";y+=`
|
|
|
|
|
`}else if(x==="simple"){const k=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";m+=`
|
|
|
|
|
<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> ${w}</div>
|
|
|
|
|
<div><strong>Default IP:</strong> ${k}</div>
|
|
|
|
|
<div><strong>SSL:</strong> None (HTTP only)</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${w}:8080, http://${w}:3000</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${k}:8080, http://${k}:3000</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(b==="public"){const w=document.getElementById("setup-public-domain")?.value?.trim()||"",S=document.getElementById("setup-public-email")?.value?.trim()||"",i=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=i==="subdirectory"?`https://${w}/sonarr, https://${w}/grafana`:`https://sonarr.${w}, https://grafana.${w}`;y+=`
|
|
|
|
|
`}else if(x==="public"){const k=document.getElementById("setup-public-domain")?.value?.trim()||"",I=document.getElementById("setup-public-email")?.value?.trim()||"",i=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",c=i==="subdirectory"?`https://${k}/sonarr, https://${k}/grafana`:`https://sonarr.${k}, https://grafana.${k}`;m+=`
|
|
|
|
|
<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> ${w}</div>
|
|
|
|
|
<div><strong>Domain:</strong> ${k}</div>
|
|
|
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
|
|
|
<div><strong>Email:</strong> ${S}</div>
|
|
|
|
|
<div><strong>Email:</strong> ${I}</div>
|
|
|
|
|
<div><strong>Routing:</strong> ${i==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${p}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${c}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}const C=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";y+=`
|
|
|
|
|
`}const L=document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";m+=`
|
|
|
|
|
<div style="margin-top: 8px; padding-top: 12px; border-top: 1px solid var(--border);">
|
|
|
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${C.replace(/_/g," ")}</div>
|
|
|
|
|
<div style="font-size: 0.95rem;"><strong>Timezone:</strong> ${L.replace(/_/g," ")}</div>
|
|
|
|
|
</div>
|
|
|
|
|
`,y+="</div>",u.innerHTML=y,N("setup-step-summary")}async function D(u){try{const y=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return y.ok?(await y.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${y.status}`),{function:"saveConfigToServer"}),!1)}catch(y){return errorHandler.logError("[SetupWizard] Save Config",y,{function:"saveConfigToServer"}),!1}}async function R(){const u={setupComplete:!0,configurationType:b,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(b==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const S=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:S,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else b==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):b==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const y=await D(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const C=b==="homelab"?"Professional Home Lab":b==="simple"?"Simple Setup":"Public Server",w=y?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${C}. Settings saved to: ${w}`,"success",5e3),setTimeout(()=>location.reload(),500)}const g=document.getElementById("setup-step-1-next");g&&(g.onclick=function(u){u.preventDefault();const y=document.querySelector('input[name="config-type"]:checked');y&&(b=y.value),N(b==="homelab"?"setup-step-homelab":b==="simple"?"setup-step-simple":b==="public"?"setup-step-public":"setup-step-homelab")});const L=document.getElementById("setup-skip");L&&(L.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const k=document.getElementById("setup-tld");k&&(k.oninput=function(u){const y=u.target.value||".home",C=document.getElementById("tld-preview"),w=document.getElementById("tld-preview-2");C&&(C.textContent=y),w&&(w.textContent=y)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(u){u.preventDefault(),N("setup-step-1")});const I=document.getElementById("setup-homelab-next");I&&(I.onclick=function(u){u.preventDefault();const y=document.getElementById("setup-tld")?.value?.trim()||"",C=document.getElementById("setup-ca-name")?.value?.trim()||"",w=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!y||!y.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!C){showNotification("Please enter a Certificate Authority name","warning");return}if(!w){showNotification("Please enter your DNS server IP address","warning");return}A()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(u){u.preventDefault(),N("setup-step-1")});const E=document.getElementById("setup-simple-next");E&&(E.onclick=function(u){u.preventDefault(),A()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var y=document.getElementById("dns-requirement-note");y&&(y.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(u){u.preventDefault(),N("setup-step-1")});const P=document.getElementById("setup-public-next");P&&(P.onclick=function(u){u.preventDefault();const y=document.getElementById("setup-public-domain")?.value?.trim()||"",C=document.getElementById("setup-public-email")?.value?.trim()||"";if(!y){showNotification("Please enter your domain name","warning");return}if(!C||!C.includes("@")){showNotification("Please enter a valid email address","warning");return}A()});const v=document.getElementById("setup-summary-back");v&&(v.onclick=function(u){u.preventDefault(),b==="homelab"?N("setup-step-homela
Line truncated
|
|
|
|
|
`,m+="</div>",u.innerHTML=m,N("setup-step-summary")}async function D(u){try{const m=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return m.ok?(await m.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${m.status}`),{function:"saveConfigToServer"}),!1)}catch(m){return errorHandler.logError("[SetupWizard] Save Config",m,{function:"saveConfigToServer"}),!1}}async function R(){const u={setupComplete:!0,configurationType:x,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(x==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const I=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:I,ip:document.getElementById("setup-dns-ip")?.value?.trim()||"",port:document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT,token:document.getElementById("setup-dns-token")?.value?.trim()||""},u.defaults={dnsType:"private",sslType:"internal",targetIP:"localhost"}}else x==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):x==="public"&&(u.domain=document.getElementById("setup-public-domain")?.value?.trim()||"",u.email=document.getElementById("setup-public-email")?.value?.trim()||"",u.routingMode=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",u.defaults={dnsType:u.routingMode==="subdirectory"?"none":"public",sslType:"letsencrypt",targetIP:"localhost"});const m=await D(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const L=x==="homelab"?"Professional Home Lab":x==="simple"?"Simple Setup":"Public Server",k=m?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${L}. Settings saved to: ${k}`,"success",5e3),setTimeout(()=>location.reload(),500)}const f=document.getElementById("setup-step-1-next");f&&(f.onclick=function(u){u.preventDefault();const m=document.querySelector('input[name="config-type"]:checked');m&&(x=m.value),N(x==="homelab"?"setup-step-homelab":x==="simple"?"setup-step-simple":x==="public"?"setup-step-public":"setup-step-homelab")});const C=document.getElementById("setup-skip");C&&(C.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await D({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const E=document.getElementById("setup-tld");E&&(E.oninput=function(u){const m=u.target.value||".home",L=document.getElementById("tld-preview"),k=document.getElementById("tld-preview-2");L&&(L.textContent=m),k&&(k.textContent=m)});const M=document.getElementById("setup-homelab-back");M&&(M.onclick=function(u){u.preventDefault(),N("setup-step-1")});const w=document.getElementById("setup-homelab-next");w&&(w.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-tld")?.value?.trim()||"",L=document.getElementById("setup-ca-name")?.value?.trim()||"",k=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!m||!m.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!L){showNotification("Please enter a Certificate Authority name","warning");return}if(!k){showNotification("Please enter your DNS server IP address","warning");return}A()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(u){u.preventDefault(),N("setup-step-1")});const S=document.getElementById("setup-simple-next");S&&(S.onclick=function(u){u.preventDefault(),A()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var m=document.getElementById("dns-requirement-note");m&&(m.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const T=document.getElementById("setup-public-back");T&&(T.onclick=function(u){u.preventDefault(),N("setup-step-1")});const P=document.getElementById("setup-public-next");P&&(P.onclick=function(u){u.preventDefault();const m=document.getElementById("setup-public-domain")?.value?.trim()||"",L=document.getElementById("setup-public-email")?.value?.trim()||"";if(!m){showNotification("Please enter your domain name","warning");return}if(!L||!L.includes("@")){showNotification("Please enter a valid email address","warning");return}A()});const v=document.getElementById("setup-summary-back");v&&(v.onclick=function(u){u.preventDefault(),x==="homelab"?N("setup-step-homela
Line truncated
|
|
|
|
|
<div class="app-selector-content">
|
|
|
|
|
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
|
|
|
|
<div id="app-selector-grid" class="app-selector-grid"></div>
|
|
|
|
@@ -333,52 +333,52 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const h="custom-apps";let j=null,m=null;const N=document.getElementById("app-selector-modal"),A=document.getElementById("app-selector-grid");async function D(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return j=p.templates,m=p.categories,!0}catch(i){b.logError("[AppSelector] Fetch Templates",i,{function:"fetchApiTemplates"})}return!1}async function R(i){try{return await(await fetch(`/api/v1/apps/ports/${i}/check`)).json()}catch(p){return b.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function g(i){try{const l=await(await fetch(`/api/v1/apps/ports/${i}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){b.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return i}async function L(){if(A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await D()){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}A.innerHTML="";const i={};for(const[l,f]of Object.entries(j)){const c=f.category||"Other";i[c]||(i[c]=[]),i[c].push({id:l,...f})}const p=m?Object.keys(m):Object.keys(i).sort();for(const l of p){const f=i[l];if(!f||f.length===0)continue;f.sort((t,r)=>(r.popularity||0)-(t.popularity||0));const c=document.createElement("div");c.className="app-category-header";const n=m?.[l]||{};c.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(l)}`,n.color&&(c.style.borderBottomColor=n.color),A.appendChild(c),f.forEach(t=>{const r=document.createElement("div");r.className="app-option";const e=t.isDashboardWidget,o=e&&safeGet("widget-"+t.id+"-enabled")!=="false",a=e?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${o?"#2ecc7130":"#e74c3c30"}; color: ${o?"#2ecc71":"#e74c3c"}; font-weight: 600;">${o?"ON":"OFF"}</div>`:"",s=!e&&t.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${t.difficulty==="Easy"?"#2ecc71":t.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${t.difficulty==="Easy"?"#2ecc71":t.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(t.difficulty)}</div>`:"";r.innerHTML=`
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(t.icon||"\u{1F4E6}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(t.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(t.description||"")}</div>
|
|
|
|
|
${a}${s}
|
|
|
|
|
`,e?r.onclick=()=>k(t,r):r.onclick=()=>M(t),A.appendChild(r)})}window.renderRecipeCards&&await window.renderRecipeCards(A)}function k(i,p){const l="widget-"+i.id+"-enabled",c=!(safeGet(l)!=="false");safeSet(l,String(c));const n=i.widgetSelector;if(n){const r=document.querySelector(n);r&&(r.style.display=c?"":"none")}const t=p.querySelector('div[style*="border-radius: 4px"]');t&&(t.textContent=c?"ON":"OFF",t.style.background=c?"#2ecc7130":"#e74c3c30",t.style.color=c?"#2ecc71":"#e74c3c"),showNotification(`${i.name} widget ${c?"enabled":"disabled"}`,"success",2e3)}async function M(i){const p=document.getElementById("app-deploy-modal"),l=document.getElementById("app-deploy-title"),f=document.getElementById("deploy-subdomain"),c=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),t=document.getElementById("deploy-port"),r=document.getElementById("deploy-tailscale-only"),e=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:i.id})})).json();if(G.success&&G.exists){const K=G.container;confirm(`Found existing ${i.name} container:
|
|
|
|
|
</div>`);const b="custom-apps";let j=null,g=null;const N=document.getElementById("app-selector-modal"),A=document.getElementById("app-selector-grid");async function D(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return j=c.templates,g=c.categories,!0}catch(i){x.logError("[AppSelector] Fetch Templates",i,{function:"fetchApiTemplates"})}return!1}async function R(i){try{return await(await fetch(`/api/v1/apps/ports/${i}/check`)).json()}catch(c){return x.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function f(i){try{const d=await(await fetch(`/api/v1/apps/ports/${i}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){x.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return i}async function C(){if(A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await D()){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}A.innerHTML="";const i={};for(const[d,y]of Object.entries(j)){const p=y.category||"Other";i[p]||(i[p]=[]),i[p].push({id:d,...y})}const c=g?Object.keys(g):Object.keys(i).sort();for(const d of c){const y=i[d];if(!y||y.length===0)continue;y.sort((e,r)=>(r.popularity||0)-(e.popularity||0));const p=document.createElement("div");p.className="app-category-header";const n=g?.[d]||{};p.innerHTML=`${escapeHtml(n.icon||"")} ${escapeHtml(d)}`,n.color&&(p.style.borderBottomColor=n.color),A.appendChild(p),y.forEach(e=>{const r=document.createElement("div");r.className="app-option";const s=e.isDashboardWidget,a=s&&safeGet("widget-"+e.id+"-enabled")!=="false",t=s?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${a?"#2ecc7130":"#e74c3c30"}; color: ${a?"#2ecc71":"#e74c3c"}; font-weight: 600;">${a?"ON":"OFF"}</div>`:"",o=!s&&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>`:"";r.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>
|
|
|
|
|
${t}${o}
|
|
|
|
|
`,s?r.onclick=()=>E(e,r):r.onclick=()=>M(e),A.appendChild(r)})}window.renderRecipeCards&&await window.renderRecipeCards(A)}function E(i,c){const d="widget-"+i.id+"-enabled",p=!(safeGet(d)!=="false");safeSet(d,String(p));const n=i.widgetSelector;if(n){const r=document.querySelector(n);r&&(r.style.display=p?"":"none")}const e=c.querySelector('div[style*="border-radius: 4px"]');e&&(e.textContent=p?"ON":"OFF",e.style.background=p?"#2ecc7130":"#e74c3c30",e.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${i.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function M(i){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),y=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),n=document.getElementById("deploy-ip"),e=document.getElementById("deploy-port"),r=document.getElementById("deploy-tailscale-only"),s=document.getElementById("tailscale-status");try{const G=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:i.id})})).json();if(G.success&&G.exists){const Y=G.container;confirm(`Found existing ${i.name} container:
|
|
|
|
|
|
|
|
|
|
Container: ${K.name}
|
|
|
|
|
Status: ${K.status}
|
|
|
|
|
Port: ${K.primaryPort||"N/A"}
|
|
|
|
|
Container: ${Y.name}
|
|
|
|
|
Status: ${Y.status}
|
|
|
|
|
Port: ${Y.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.`)&&(i._useExisting=!0,i._existingContainer=K)}}catch{}l.textContent=`Deploy ${i.name}`;const o=i.subdomain||i.id.replace(/-/g,"");f.value=o;const a=document.getElementById("subpath-compat-warning");if(a)if(SITE.routingMode==="subdirectory"){const W=i.subpathSupport||"strip";W==="none"?(a.style.display="block",a.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+i.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(a.style.display="block",a.innerHTML='<span style="color: var(--muted);">ⓘ '+i.name+" has unverified subdirectory support. It may require additional configuration.</span>"):a.style.display="none"}else a.style.display="none";const s=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),d=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),x=document.querySelector(`input[name="dns-type"][value="${s}"]`),H=document.querySelector(`input[name="ssl-type"][value="${d}"]`);x?x.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,H?H.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",r.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),F=_?.querySelector("div");if(_&&F&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(F.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(F.appendChild(G),G.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),V=document.getElementById("media-path-description");if(i.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const Y=await(await fetch("/api/v1/media/detected-mounts")).json();if(Y.success&&Y.mounts.length>0){W.style.display="block",G.innerHTML="";const ae=[...new Set(Y.mounts.map(te=>te.hostPath))];J.value=ae.join(", "),Y.mounts.forEach(te=>{const ee=document.createElement("button");ee.type="button";const de=ae.includes(te.hostPath);ee.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);`,ee.innerHTML=`<span style="font-weight: 500;">${escapeHtml(te.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(te.sourceImage)}</span>`,ee.title=`${te.hostPath} (from ${te.sourceContainer})`,ee.onclick=()=>{const le=J.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(te.hostPath);pe>=0?(le.splice(pe,1),ee.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(te.hostPath),ee.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),J.value=le.join(", ")},G.appendChild(ee)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(J)}}else q.style.display="none",J.value="",document.getElementById("detected-mounts-container").style.display="none";const X=document.getElementById("plex-claim-section");X&&(i.id==="plex"||i.claimToken?(X.style.display="block",document.getElementById("deploy-plex-claim").value=""):X.style.display="none");const ne=document.getElementById("volume-mounts-section"),oe=document.getElementById("volume-mounts-list");if(oe.innerHTML="",i.docker?.volumes?.length){const W=i.mediaMount?.containerPath,G=i.docker.volumes.filter(K=>!K.includes("{{MEDIA_PATH}}")&&!(W&&K.endsWith(":"+W)));G.length>0?(ne.style.display="block",G.forEach((K,Y)=>{const[ae,te]=K.split(":"),ee=document.createElement("div");ee.style.cssText="display: flex; gap: 6px; align-items: center;",ee.innerHTML=`
|
|
|
|
|
Click Cancel to deploy a new container.`)&&(i._useExisting=!0,i._existingContainer=Y)}}catch{}d.textContent=`Deploy ${i.name}`;const a=i.subdomain||i.id.replace(/-/g,"");y.value=a;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const W=i.subpathSupport||"strip";W==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+i.name+"</strong> does not support subdirectory mode. It may not work correctly at a subpath.</span>"):W==="strip"?(t.style.display="block",t.innerHTML='<span style="color: var(--muted);">ⓘ '+i.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const o=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),l=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),h=document.querySelector(`input[name="dns-type"][value="${o}"]`),$=document.querySelector(`input[name="ssl-type"][value="${l}"]`);h?h.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,$?$.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,n.value=SITE.defaults.targetIP||"localhost",r.checked=!1;const U=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),F=document.querySelector("#app-deploy-modal details"),_=F?.querySelector("div");if(F&&_&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,G=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(_.appendChild(W),W.dataset.moved="1"),G&&!G.dataset.moved&&(_.appendChild(G),G.dataset.moved="1")}const J=document.getElementById("media-path-section"),q=document.getElementById("deploy-media-path"),K=document.getElementById("media-path-description");if(i.mediaMount){J.style.display="block",q.value="",q.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),G=document.getElementById("detected-mounts-list");try{const X=await(await fetch("/api/v1/media/detected-mounts")).json();if(X.success&&X.mounts.length>0){W.style.display="block",G.innerHTML="";const ae=[...new Set(X.mounts.map(te=>te.hostPath))];q.value=ae.join(", "),X.mounts.forEach(te=>{const Q=document.createElement("button");Q.type="button";const de=ae.includes(te.hostPath);Q.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);`,Q.innerHTML=`<span style="font-weight: 500;">${escapeHtml(te.folderName)}</span><br><span style="font-size: 0.7rem; color: var(--muted);">from ${escapeHtml(te.sourceImage)}</span>`,Q.title=`${te.hostPath} (from ${te.sourceContainer})`,Q.onclick=()=>{const le=q.value.split(",").map(ce=>ce.trim()).filter(ce=>ce),pe=le.indexOf(te.hostPath);pe>=0?(le.splice(pe,1),Q.style.background="color-mix(in srgb, var(--success) 15%, var(--card-bg))"):(le.push(te.hostPath),Q.style.background="color-mix(in srgb, var(--success) 40%, var(--card-bg))"),q.value=le.join(", ")},G.appendChild(Q)})}else W.style.display="none"}catch{W.style.display="none"}document.getElementById("browse-media-btn").onclick=()=>{openFolderBrowser(q)}}else J.style.display="none",q.value="",document.getElementById("detected-mounts-container").style.display="none";const Z=document.getElementById("plex-claim-section");Z&&(i.id==="plex"||i.claimToken?(Z.style.display="block",document.getElementById("deploy-plex-claim").value=""):Z.style.display="none");const ee=document.getElementById("volume-mounts-section"),ne=document.getElementById("volume-mounts-list");if(ne.innerHTML="",i.docker?.volumes?.length){const W=i.mediaMount?.containerPath,G=i.docker.volumes.filter(Y=>!Y.includes("{{MEDIA_PATH}}")&&!(W&&Y.endsWith(":"+W)));G.length>0?(ee.style.display="block",G.forEach((Y,X)=>{const[ae,te]=Y.split(":"),Q=document.createElement("div");Q.style.cssText="display: flex; gap: 6px; align-items: center;",Q.innerHTML=`
|
|
|
|
|
<input type="text" class="vol-host-path" data-container-path="${te}" value="${ae}"
|
|
|
|
|
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 ${te}</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(ee),ee.querySelector(".vol-browse-btn").onclick=()=>{const de=ee.querySelector(".vol-host-path");openFolderBrowser(de)}})):ne.style.display="none"}else ne.style.display="none";const Z=i.defaultPort||8080;t.value="",t.placeholder=`Default: ${Z}`;let Q=document.getElementById("deploy-port-status");Q||(Q=document.createElement("div"),Q.id="deploy-port-status",Q.style.cssText="font-size: 0.8rem; margin-top: 4px;",t.parentNode.appendChild(Q));async function ie(){const W=t.value||Z;Q.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await R(W);if(G.available)Q.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const K=await g(Z);Q.innerHTML=`
|
|
|
|
|
`,ne.appendChild(Q),Q.querySelector(".vol-browse-btn").onclick=()=>{const de=Q.querySelector(".vol-host-path");openFolderBrowser(de)}})):ee.style.display="none"}else ee.style.display="none";const oe=i.defaultPort||8080;e.value="",e.placeholder=`Default: ${oe}`;let V=document.getElementById("deploy-port-status");V||(V=document.createElement("div"),V.id="deploy-port-status",V.style.cssText="font-size: 0.8rem; margin-top: 4px;",e.parentNode.appendChild(V));async function ie(){const W=e.value||oe;V.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const G=await R(W);if(G.available)V.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const Y=await f(oe);V.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(G.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;const Y=document.createElement("button");Y.type="button",Y.textContent=`Use ${K}`,Y.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",Y.onclick=()=>{document.getElementById("deploy-port").value=K,Q.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(K))}</span>`},Q.appendChild(Y)}}let re;t.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?e.innerHTML=`
|
|
|
|
|
`;const X=document.createElement("button");X.type="button",X.textContent=`Use ${Y}`,X.style.cssText="margin-left: 8px; padding: 2px 8px; font-size: 0.75rem; cursor: pointer;",X.onclick=()=>{document.getElementById("deploy-port").value=Y,V.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(Y))}</span>`},V.appendChild(X)}}let re;e.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const G=await(await fetch("/api/v1/tailscale/status")).json();G.success&&G.installed&&G.connected?s.innerHTML=`
|
|
|
|
|
<span style="color: #4caf50;">Connected</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
|
|
|
|
|
`:G.installed?e.innerHTML='<span style="color: #ff9800;">Not connected</span>':(e.innerHTML='<span style="color: var(--muted);">Not available</span>',r.disabled=!0)}catch{e.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=f.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,K=document.querySelector('input[name="ssl-type"]:checked').value;let Y="";if(SITE.routingMode==="subdirectory"&&SITE.domain)Y=`https://${SITE.domain}/${W}`;else if(G==="private")Y=`${K==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const ae=K==="none"?"http":"https",te=SITE.domain||W;Y=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=t.value||i.defaultPort||DC.DEFAULTS.SERVICE_PORT;Y=`http://${n.value}:${ae}`}c.textContent=Y}f.oninput=se,n.oninput=se,t.oninput=se,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=se}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=se}),se(),N.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(i)}async function I(i){const p=i.appTemplate,l=safeGetJSON(h,[]),f=p._useExisting&&p._existingContainer,c=l.find(n=>n.id===i.subdomain);if(!(c&&!f&&!confirm(`An app with subdomain "${i.subdomain}" already exists. Redeploy?`))){if(c){const n=l.indexOf(c);l.splice(n,1),safeSet(h,JSON.stringify(l))}if(f)i.port=p._existingContainer.primaryPort;else{const n=i.port||p.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const t=await R(n);if(!t.available){const r=await g(p.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${t.conflict?.usedBy||"another container"}.
|
|
|
|
|
`:G.installed?s.innerHTML='<span style="color: #ff9800;">Not connected</span>':(s.innerHTML='<span style="color: var(--muted);">Not available</span>',r.disabled=!0)}catch{s.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=y.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,Y=document.querySelector('input[name="ssl-type"]:checked').value;let X="";if(SITE.routingMode==="subdirectory"&&SITE.domain)X=`https://${SITE.domain}/${W}`;else if(G==="private")X=`${Y==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const ae=Y==="none"?"http":"https",te=SITE.domain||W;X=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=e.value||i.defaultPort||DC.DEFAULTS.SERVICE_PORT;X=`http://${n.value}:${ae}`}p.textContent=X}y.oninput=se,n.oninput=se,e.oninput=se,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=se}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=se}),se(),N.classList.remove("show"),c.classList.add("show"),c.dataset.appTemplate=JSON.stringify(i)}async function w(i){const c=i.appTemplate,d=safeGetJSON(b,[]),y=c._useExisting&&c._existingContainer,p=d.find(n=>n.id===i.subdomain);if(!(p&&!y&&!confirm(`An app with subdomain "${i.subdomain}" already exists. Redeploy?`))){if(p){const n=d.indexOf(p);d.splice(n,1),safeSet(b,JSON.stringify(d))}if(y)i.port=c._existingContainer.primaryPort;else{const n=i.port||c.defaultPort||8080;showNotification(`Checking port ${n} availability...`,"info",0);const e=await R(n);if(!e.available){const r=await f(c.defaultPort||8080);if(confirm(`Port ${n} is already in use by ${e.conflict?.usedBy||"another container"}.
|
|
|
|
|
|
|
|
|
|
Would you like to use port ${r} instead?`))i.port=r;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(f?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const n={appId:p.id,config:{subdomain:i.subdomain,ip:i.ip,createDns:i.dnsType==="private",port:i.port||p.defaultPort||null,sslType:i.sslType,dnsType:i.dnsType,tailscaleOnly:i.tailscaleOnly||!1,mediaPath:i.mediaPath||null,plexClaimToken:i.plexClaimToken||null,customVolumes:i.customVolumes||null}};f&&(n.config.useExisting=!0,n.config.existingContainerId=p._existingContainer.id,n.config.existingPort=p._existingContainer.primaryPort,!i.port&&p._existingContainer.primaryPort&&(n.config.port=p._existingContainer.primaryPort));const t={app:p.id,port:i.port||p.defaultPort||null,subdomain:i.subdomain||"",media_path:i.mediaPath||"",env:i.plexClaimToken?{PLEX_CLAIM:i.plexClaimToken}:{}},e=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(e.success){const o={id:i.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:null,url:e.url||`http://127.0.0.1:${t.port}`,ip:i.ip,appTemplate:p.id,tailscaleOnly:i.tailscaleOnly||!1};l.push(o),safeSet(h,JSON.stringify(l)),window.APPS&&!window.APPS.some(s=>s.id===p.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let a=e.usedExisting?`${p.name} configured with existing container!
|
|
|
|
|
URL: ${e.url}`:`${p.name} deployed successfully!
|
|
|
|
|
URL: ${e.url}`;e.warning&&(a+=`
|
|
|
|
|
Would you like to use port ${r} instead?`))i.port=r;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(y?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const n={appId:c.id,config:{subdomain:i.subdomain,ip:i.ip,createDns:i.dnsType==="private",port:i.port||c.defaultPort||null,sslType:i.sslType,dnsType:i.dnsType,tailscaleOnly:i.tailscaleOnly||!1,mediaPath:i.mediaPath||null,plexClaimToken:i.plexClaimToken||null,customVolumes:i.customVolumes||null}};y&&(n.config.useExisting=!0,n.config.existingContainerId=c._existingContainer.id,n.config.existingPort=c._existingContainer.primaryPort,!i.port&&c._existingContainer.primaryPort&&(n.config.port=c._existingContainer.primaryPort));const e={app:c.id,port:i.port||c.defaultPort||null,subdomain:i.subdomain||"",media_path:i.mediaPath||"",env:i.plexClaimToken?{PLEX_CLAIM:i.plexClaimToken}:{}},s=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)})).json();if(s.success){const a={id:i.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:null,url:s.url||`http://127.0.0.1:${e.port}`,ip:i.ip,appTemplate:c.id,tailscaleOnly:i.tailscaleOnly||!1};d.push(a),safeSet(b,JSON.stringify(d)),window.APPS&&!window.APPS.some(o=>o.id===c.id)&&(window.APPS.push(a),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let t=s.usedExisting?`${c.name} configured with existing container!
|
|
|
|
|
URL: ${s.url}`:`${c.name} deployed successfully!
|
|
|
|
|
URL: ${s.url}`;s.warning&&(t+=`
|
|
|
|
|
|
|
|
|
|
\u26A0 Warning: ${e.warning}`),showNotification(a,"success",8e3),delete p._useExisting,delete p._existingContainer,e.url&&e.url.startsWith("https://")&&$(e.url,p.name),e.setupInstructions&&e.setupInstructions.length>0&&setTimeout(()=>{const s=e.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${p.name}: ${s}`,"info",1e4)},1e3)}else throw new Error(e.error||"Deployment failed")}catch(n){b.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${n.message}`,"error",8e3)}}}async function $(i,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const f=12,c=async()=>{l++;try{const n=await fetch(i,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<f?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
|
|
|
|
|
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function E(){safeGetJSON(h,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),window.openDeployConfigFor=async function(i){if(!j&&!await D())return!1;const p=String(i||"").toLowerCase(),l=Object.entries(j).find(([c])=>c.toLowerCase()===p);if(!l)return!1;const f={id:l[0],...l[1]};return f.isDashboardWidget?(await L(),N.classList.add("show"),"selector"):(await M(f),!0)},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 i=JSON.parse(T.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const f={appTemplate:i,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:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!f.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(i.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),I(f)}),wireModal(T);const P=document.getElementById("folder-browser-modal"),v=document.getElementById("folder-browser-path"),B=document.getElementById("folder-browser-list"),z=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let u="",y=[],C=null;window.openFolderBrowser=function(i){C=i,y=i.value.split(",").map(p=>p.trim()).filter(p=>p),u="",S(),w(""),P.classList.add("show")};async function w(i){v.textContent=i||"Select a drive...",B.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(i)}`)).json();if(!l.success){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}u=l.path||"",v.textContent=u||"Select a drive...";let f="";l.parent&&l.parent!==l.path&&(f+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
\u26A0 Warning: ${s.warning}`),showNotification(t,"success",8e3),delete c._useExisting,delete c._existingContainer,s.url&&s.url.startsWith("https://")&&z(s.url,c.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const o=s.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${o}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(n){x.logError("[AppSelector] Deployment",n,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${n.message}`,"error",8e3)}}}async function z(i,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const y=12,p=async()=>{d++;try{const n=await fetch(i,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<y?setTimeout(p,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(p,3e3)}function S(){safeGetJSON(b,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{C(),N.classList.add("show")}),window.openDeployConfigFor=async function(i){if(!j&&!await D())return!1;const c=String(i||"").toLowerCase(),d=Object.entries(j).find(([p])=>p.toLowerCase()===c);if(!d)return!1;const y={id:d[0],...d[1]};return y.isDashboardWidget?(await C(),N.classList.add("show"),"selector"):(await M(y),!0)},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 i=JSON.parse(T.dataset.appTemplate),c=document.getElementById("deploy-media-path").value.trim(),d=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(p=>{d.push({hostPath:p.value.trim(),containerPath:p.dataset.containerPath})});const y={appTemplate:i,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:c||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:d.length>0?d:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!y.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(i.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),w(y)}),wireModal(T);const P=document.getElementById("folder-browser-modal"),v=document.getElementById("folder-browser-path"),B=document.getElementById("folder-browser-list"),H=document.getElementById("folder-browser-selected"),O=document.getElementById("folder-browser-selected-list");let u="",m=[],L=null;window.openFolderBrowser=function(i){L=i,m=i.value.split(",").map(c=>c.trim()).filter(c=>c),u="",I(),k(""),P.classList.add("show")};async function k(i){v.textContent=i||"Select a drive...",B.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const d=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(i)}`)).json();if(!d.success){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}u=d.path||"",v.textContent=u||"Select a drive...";let y="";d.parent&&d.parent!==d.path&&(y+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
|
|
|
|
<span style="color: var(--muted);">.. Parent Directory</span>
|
|
|
|
|
</div>`),l.items.length===0&&!l.parent?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?f+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(c=>{const n=c.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",t=y.includes(c.path),r=t?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";f+=`<div class="folder-item" data-path="${escapeHtml(c.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${r}">
|
|
|
|
|
</div>`),d.items.length===0&&!d.parent?y+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':d.items.length===0?y+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(p=>{const n=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=m.includes(p.path),r=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";y+=`<div class="folder-item" data-path="${escapeHtml(p.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${r}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${n}</span>
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(c.name)}</span>
|
|
|
|
|
${t?'<span style="color: var(--success);">\u2713</span>':""}
|
|
|
|
|
</div>`}),B.innerHTML=f,B.querySelectorAll(".folder-item").forEach(c=>{c.addEventListener("click",()=>{w(c.dataset.path)}),c.addEventListener("mouseenter",()=>{c.style.background="var(--card-bg)"}),c.addEventListener("mouseleave",()=>{const n=y.includes(c.dataset.path);c.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(p){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(p.message)}</div>`}}function S(){if(y.length===0){z.style.display="none";return}z.style.display="block",O.innerHTML=y.map(i=>`
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(p.name)}</span>
|
|
|
|
|
${e?'<span style="color: var(--success);">\u2713</span>':""}
|
|
|
|
|
</div>`}),B.innerHTML=y,B.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{k(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const n=m.includes(p.dataset.path);p.style.background=n?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function I(){if(m.length===0){H.style.display="none";return}H.style.display="block",O.innerHTML=m.map(i=>`
|
|
|
|
|
<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(i)}
|
|
|
|
|
<button type="button" onclick="removeSelectedFolder('${escapeHtml(i)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
|
|
|
</span>
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(i){y=y.filter(p=>p!==i),S(),w(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!y.includes(u)&&(y.push(u),S(),w(u))}),wireModal(P,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{C&&(C.value=y.join(", ")),P.classList.remove("show")}),E()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
|
|
|
|
`).join("")}window.removeSelectedFolder=function(i){m=m.filter(c=>c!==i),I(),k(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!m.includes(u)&&(m.push(u),I(),k(u))}),wireModal(P,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{L&&(L.value=m.join(", ")),P.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,h=null,j=null,m=1,N=!1;const A=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),g=document.getElementById("recipe-next");wireModal(A,D);async function L(){try{const u=await fetch("/api/v1/recipes/templates"),y=await u.json();if(y.success)return b=y.templates,h=y.categories,!0;if(u.status===403)return N=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function k(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(u){await k();let y;if(N&&b?y=b:y=M(),!y||y.length===0)return;const C=document.createElement("div");C.className="app-category-header",C.innerHTML="\u{1F9EA} Recipes",C.style.borderBottomColor="#8e44ad",u.appendChild(C);const w=Array.isArray(y)?y:Object.values(y);w.sort((S,i)=>(i.popularity||0)-(S.popularity||0));for(const S of w){const i=document.createElement("div");i.className="app-option",i.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${S.componentCount||S.components?.length||"?"} apps</div>`,l=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>';i.innerHTML=`
|
|
|
|
|
${l}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(S.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(S.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(S.description||"")}</div>
|
|
|
|
|
${p}
|
|
|
|
|
`,i.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}I(S)},u.appendChild(i)}};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 I(u){j=u,m=1;const y=document.getElementById("app-selector-modal");y&&y.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,$(),E(),A.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const y=parseInt(u.dataset.step);u.classList.toggle("active",y===m),u.classList.toggle("completed",y<m)});for(let u=1;u<=4;u++){const y=document.getElementById(`recipe-step-${u}`);y&&(y.style.display=u===m?"":"none")}R.style.display=m>1&&m<4?"":"none",m===4?(g.style.display="none",D.textContent="Close"):m===3?(g.textContent="\u{1F680} Deploy",g.style.display="",D.textContent="Cancel"):(g.textContent="Next",g.style.display="",D.textContent="Cancel")}function E(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const y=j.components||[];for(const C of y){const w=document.createElement("div");w.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const S=C.required,i=C.internal;w.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${S?"checked disabled":"checked"} data-component-id="${C.id}"
|
|
|
|
|
</div>`);let x=null,b=null,j=null,g=1,N=!1;const A=document.getElementById("recipe-deploy-modal"),D=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),f=document.getElementById("recipe-next");wireModal(A,D);async function C(){try{const u=await fetch("/api/v1/recipes/templates"),m=await u.json();if(m.success)return x=m.templates,b=m.categories,!0;if(u.status===403)return N=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function E(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(u){await E();let m;if(N&&x?m=x:m=M(),!m||m.length===0)return;const L=document.createElement("div");L.className="app-category-header",L.innerHTML="\u{1F9EA} Recipes",L.style.borderBottomColor="#8e44ad",u.appendChild(L);const k=Array.isArray(m)?m:Object.values(m);k.sort((I,i)=>(i.popularity||0)-(I.popularity||0));for(const I of k){const i=document.createElement("div");i.className="app-option",i.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;">${I.componentCount||I.components?.length||"?"} apps</div>`,d=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>';i.innerHTML=`
|
|
|
|
|
${d}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(I.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(I.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(I.description||"")}</div>
|
|
|
|
|
${c}
|
|
|
|
|
`,i.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}w(I)},u.appendChild(i)}};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 w(u){j=u,g=1;const m=document.getElementById("app-selector-modal");m&&m.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,z(),S(),A.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const m=parseInt(u.dataset.step);u.classList.toggle("active",m===g),u.classList.toggle("completed",m<g)});for(let u=1;u<=4;u++){const m=document.getElementById(`recipe-step-${u}`);m&&(m.style.display=u===g?"":"none")}R.style.display=g>1&&g<4?"":"none",g===4?(f.style.display="none",D.textContent="Close"):g===3?(f.textContent="\u{1F680} Deploy",f.style.display="",D.textContent="Cancel"):(f.textContent="Next",f.style.display="",D.textContent="Cancel")}function S(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const m=j.components||[];for(const L of m){const k=document.createElement("div");k.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const I=L.required,i=L.internal;k.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${I?"checked disabled":"checked"} data-component-id="${L.id}"
|
|
|
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(C.role||C.id)}</div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.role||L.id)}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">
|
|
|
|
|
${C.templateRef?escapeHtml(C.templateRef):"Built-in"}
|
|
|
|
|
${S?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
|
|
|
${L.templateRef?escapeHtml(L.templateRef):"Built-in"}
|
|
|
|
|
${I?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
|
|
|
${i?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
|
|
|
</div>
|
|
|
|
|
${C.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(C.note)}</div>`:""}
|
|
|
|
|
${L.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(L.note)}</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,u.appendChild(w)}}function T(){const u=document.getElementById("recipe-volumes-section"),y=document.getElementById("recipe-volume-list"),C=j.sharedVolumes;if(C&&Object.keys(C).length>0){u.style.display="",y.innerHTML="";for(const[w,S]of Object.entries(C)){const i=document.createElement("div");i.style.cssText="display: grid; gap: 4px;",i.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(S.label||w)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${w}" value="${escapeHtml(S.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(S.defaultPath||"/path")}"
|
|
|
|
|
`,u.appendChild(k)}}function T(){const u=document.getElementById("recipe-volumes-section"),m=document.getElementById("recipe-volume-list"),L=j.sharedVolumes;if(L&&Object.keys(L).length>0){u.style.display="",m.innerHTML="";for(const[k,I]of Object.entries(L)){const i=document.createElement("div");i.style.cssText="display: grid; gap: 4px;",i.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(I.label||k)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${k}" value="${escapeHtml(I.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(I.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(S.description||"")}</div>
|
|
|
|
|
`,y.appendChild(i)}}else u.style.display="none"}function P(){const u=document.getElementById("recipe-review-content"),y=v(),C=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),w={};C.forEach(l=>{w[l.dataset.volumeKey]=l.value});const S=document.getElementById("recipe-timezone").value||"UTC",i=document.getElementById("recipe-ip").value||"host.docker.internal",p=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
|
|
|
|
|
<div class="form-hint-sm">${escapeHtml(I.description||"")}</div>
|
|
|
|
|
`,m.appendChild(i)}}else u.style.display="none"}function P(){const u=document.getElementById("recipe-review-content"),m=v(),L=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),k={};L.forEach(d=>{k[d.dataset.volumeKey]=d.value});const I=document.getElementById("recipe-timezone").value||"UTC",i=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;u.innerHTML=`
|
|
|
|
|
<div style="font-weight: 600; font-size: 1rem; margin-bottom: 12px;">${escapeHtml(j.name)}</div>
|
|
|
|
|
<div style="color: var(--muted); margin-bottom: 16px;">${escapeHtml(j.description||"")}</div>
|
|
|
|
|
|
|
|
|
|
<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Components (${y.length}):</strong>
|
|
|
|
|
<strong>Components (${m.length}):</strong>
|
|
|
|
|
<div style="display: grid; gap: 4px; margin-top: 6px;">
|
|
|
|
|
${y.map(l=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
\u2022 <strong>${escapeHtml(l.role||l.id)}</strong> ${l.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
|
|
|
${m.map(d=>`<div style="padding: 4px 0; font-size: 0.85rem;">
|
|
|
|
|
\u2022 <strong>${escapeHtml(d.role||d.id)}</strong> ${d.internal?'<span style="color:var(--muted)">(internal)</span>':""}
|
|
|
|
|
</div>`).join("")}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${Object.keys(w).length>0?`<div style="margin-bottom: 12px;">
|
|
|
|
|
${Object.keys(k).length>0?`<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Volumes:</strong>
|
|
|
|
|
${Object.entries(w).map(([l,f])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${l}: ${escapeHtml(f)}</div>`).join("")}
|
|
|
|
|
${Object.entries(k).map(([d,y])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(y)}</div>`).join("")}
|
|
|
|
|
</div>`:""}
|
|
|
|
|
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
|
|
|
Timezone: ${escapeHtml(S)} • IP: ${escapeHtml(i)} ${p?"• Tailscale only":""}
|
|
|
|
|
Timezone: ${escapeHtml(I)} • IP: ${escapeHtml(i)} ${c?"• Tailscale only":""}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
${j.network?`<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">Docker network: <code>${escapeHtml(j.network.name)}</code></div>`:""}
|
|
|
|
|
`}function v(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),y=new Set;u.forEach(w=>{w.checked&&y.add(w.dataset.componentId)});const C=j.components||[];return C.filter(w=>w.required).forEach(w=>y.add(w.id)),C.filter(w=>y.has(w.id))}async function B(){const u=document.getElementById("recipe-progress-list"),y=document.getElementById("recipe-deploy-result");y.style.display="none",u.innerHTML="";const C=v();for(const p of C){const l=document.createElement("div");l.id=`recipe-progress-${p.id}`,l.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;",l.innerHTML=`
|
|
|
|
|
`}function v(){const u=document.querySelectorAll("#recipe-component-list input[data-component-id]"),m=new Set;u.forEach(k=>{k.checked&&m.add(k.dataset.componentId)});const L=j.components||[];return L.filter(k=>k.required).forEach(k=>m.add(k.id)),L.filter(k=>m.has(k.id))}async function B(){const u=document.getElementById("recipe-progress-list"),m=document.getElementById("recipe-deploy-result");m.style.display="none",u.innerHTML="";const L=v();for(const c of L){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
|
|
|
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(p.role||p.id)}</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
|
|
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
|
|
|
`,u.appendChild(l)}const w=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),S={};w.forEach(p=>{S[p.dataset.volumeKey]=p.value});const i={selectedComponents:C.map(p=>p.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:S},componentOverrides:{}};for(const p of C)z(p.id,"deploying","Deploying...");try{const l=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:j.id,config:i})})).json();if(l.success){for(const f of l.deployed||[])z(f.id,"success",f.url?`Running \u2192 ${f.url}`:"Running");for(const f of l.errors||[])z(f.componentId,"error",f.error);y.style.display="",y.innerHTML=`
|
|
|
|
|
`,u.appendChild(d)}const k=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),I={};k.forEach(c=>{I[c.dataset.volumeKey]=c.value});const i={selectedComponents:L.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:I},componentOverrides:{}};for(const c of L)H(c.id,"deploying","Deploying...");try{const d=await(await secureFetch("/api/v1/recipes/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({recipeId:j.id,config:i})})).json();if(d.success){for(const y of d.deployed||[])H(y.id,"success",y.url?`Running \u2192 ${y.url}`:"Running");for(const y of d.errors||[])H(y.componentId,"error",y.error);m.style.display="",m.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(l.message||"Deployed!")}</div>
|
|
|
|
|
${l.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(d.message||"Deployed!")}</div>
|
|
|
|
|
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
|
|
|
<strong>Setup tips:</strong>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${l.setupInstructions.map(f=>`<li>${escapeHtml(f)}</li>`).join("")}</ul>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(y=>`<li>${escapeHtml(y)}</li>`).join("")}</ul>
|
|
|
|
|
</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else y.style.display="",y.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(l.error||"Unknown error")}
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){y.style.display="",y.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(p.message)}
|
|
|
|
|
</div>`}}function z(u,y,C){const w=document.getElementById(`recipe-progress-${u}`);if(!w)return;const S=w.querySelector(".recipe-progress-icon"),i=w.querySelector(".recipe-progress-status");y==="deploying"?(S.textContent="\u23F3",i.style.color="var(--accent)"):y==="success"?(S.textContent="\u2705",i.style.color="var(--ok-fg)"):y==="error"&&(S.textContent="\u274C",i.style.color="var(--bad-fg)"),i.textContent=C}g.addEventListener("click",()=>{if(m===3){m=4,$(),B();return}m<3&&(m++,$(),m===2&&T(),m===3&&P())}),R.addEventListener("click",()=>{m>1&&m<4&&(m--,$())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const y={};u.forEach(C=>{const w=C.dataset.recipeId;y[w]||(y[w]=[]),y[w].push(C)});for(const[C,w]of Object.entries(y))w.length<2||w.forEach((S,i)=>{if(S.style.borderLeft="3px solid rgba(142,68,173,0.5)",i===0){let p=S.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.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;",p.textContent=C.replace(/-/g," "),S.style.position="relative",S.appendChild(p))}})},window.manageRecipe=async function(u,y){const C=`/api/v1/recipes/${u}/${y}`,w=y==="remove"?"DELETE":"POST",S=y==="remove"?`/api/v1/recipes/${u}`:C;if(!(y==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(S,{method:w})).json();p.success?(showNotification(`Recipe ${y}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${y} failed: ${p.error}`,"error",5e3)}catch(i){showNotification(`Network error: ${i.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
|
|
|
|
|
`,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
|
|
|
<strong>Deployment failed:</strong> ${escapeHtml(d.error||"Unknown error")}
|
|
|
|
|
</div>`,showNotification(`Recipe deployment failed: ${d.error}`,"error",5e3)}catch(c){m.style.display="",m.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
|
|
|
|
<strong>Network error:</strong> ${escapeHtml(c.message)}
|
|
|
|
|
</div>`}}function H(u,m,L){const k=document.getElementById(`recipe-progress-${u}`);if(!k)return;const I=k.querySelector(".recipe-progress-icon"),i=k.querySelector(".recipe-progress-status");m==="deploying"?(I.textContent="\u23F3",i.style.color="var(--accent)"):m==="success"?(I.textContent="\u2705",i.style.color="var(--ok-fg)"):m==="error"&&(I.textContent="\u274C",i.style.color="var(--bad-fg)"),i.textContent=L}f.addEventListener("click",()=>{if(g===3){g=4,z(),B();return}g<3&&(g++,z(),g===2&&T(),g===3&&P())}),R.addEventListener("click",()=>{g>1&&g<4&&(g--,z())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const m={};u.forEach(L=>{const k=L.dataset.recipeId;m[k]||(m[k]=[]),m[k].push(L)});for(const[L,k]of Object.entries(m))k.length<2||k.forEach((I,i)=>{if(I.style.borderLeft="3px solid rgba(142,68,173,0.5)",i===0){let c=I.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=L.replace(/-/g," "),I.style.position="relative",I.appendChild(c))}})},window.manageRecipe=async function(u,m){const L=`/api/v1/recipes/${u}/${m}`,k=m==="remove"?"DELETE":"POST",I=m==="remove"?`/api/v1/recipes/${u}`:L;if(!(m==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(I,{method:k})).json();c.success?(showNotification(`Recipe ${m}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${m} failed: ${c.error}`,"error",5e3)}catch(i){showNotification(`Network error: ${i.message}`,"error",5e3)}};const O=document.createElement("style");O.textContent=`
|
|
|
|
|
.recipe-step {
|
|
|
|
|
flex: 1;
|
|
|
|
|
text-align: center;
|
|
|
|
@@ -550,7 +550,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
.recipe-step-panel {
|
|
|
|
|
min-height: 180px;
|
|
|
|
|
}
|
|
|
|
|
`,document.head.appendChild(O),k()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const b=document.getElementById("reload-caddy-top"),h=b.textContent;try{b.textContent="\u23F3 Reloading...",b.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),m=await j.json();if(j.ok&&m.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=h,b.disabled=!1},2e3);else throw new Error(m.error||"Reload failed")}catch(j){b.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{b.textContent=h,b.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
|
|
|
|
|
`,document.head.appendChild(O),E()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const x=document.getElementById("reload-caddy-top"),b=x.textContent;try{x.textContent="\u23F3 Reloading...",x.disabled=!0;const j=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),g=await j.json();if(j.ok&&g.success)x.textContent="\u2705 Reloaded!",setTimeout(()=>{x.textContent=b,x.disabled=!1},2e3);else throw new Error(g.error||"Reload failed")}catch(j){x.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${j.message}`,"error"),setTimeout(()=>{x.textContent=b,x.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
|
|
|
|
<h3>\u{1F4CB} Error Logs</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -597,8 +597,8 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="error-log-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("error-log-modal"),h=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),m=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),A=document.getElementById("error-log-level"),D=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),g=document.getElementById("error-log-since"),L=document.getElementById("error-log-until"),k=document.getElementById("error-log-container"),M=document.getElementById("error-log-load-more"),I=document.getElementById("error-log-total"),$=50;let E=0,T=null,P=0,v=[];function B(S){if(!S)return null;const i=new Date(S);return isNaN(i.getTime())?null:i.toISOString()}async function z(){try{const S=await fetch("/api/v1/error-logs/contexts");if(!S.ok)return;const i=await S.json();if(!i.success||!Array.isArray(i.contexts))return;v=i.contexts;const p=D.value;D.innerHTML='<option value="">All</option>';for(const l of i.contexts){const f=document.createElement("option");f.value=l.name,f.textContent=`${l.name} (${l.count})`,D.appendChild(f)}p&&i.contexts.some(l=>l.name===p)&&(D.value=p)}catch{}}function O(){const S=new URLSearchParams;S.set("limit",String($)),S.set("offset",String(E)),A.value&&S.set("level",A.value),D.value&&S.set("context",D.value);const i=B(g.value),p=B(L.value);i&&S.set("since",i),p&&S.set("until",p);const l=(R.value||"").trim();return l&&S.set("search",l),S}async function u(S){try{S?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,E=0,P++,k.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const i=P,p=O(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:T.signal});if(!l.ok){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,M.style.display="none",I.textContent="";return}const f=await l.json();if(!f.success){k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.error||"unknown")}</div>`,M.style.display="none",I.textContent="";return}if(!S&&i!==P)return;const c=Array.isArray(f.logs)?f.logs:[];if(c.length===0&&!S){const t=f.filters&&(f.filters.level||f.filters.context||f.filters.search||f.filters.since||f.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";k.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(t)}</div>`,M.style.display="none",I.textContent=f.total?`${f.total} total`:"";return}let n="";S||(n='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',n+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',n+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',n+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',n+='<th style="padding: 6px; text-align: left;">Message</th>',n+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',n+="</tr>");for(const t of c){const r=(t.level||"?").toUpperCase(),e=r==="ERR"?"var(--bad-fg)":r==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",o=t.timestamp?new Date(t.timestamp).toLocaleString():"\u2014",a=t.context||"\u2014",s=(t.error||"").split(`
|
|
|
|
|
`)[0],d=t.request&&t.request.ip||"";n+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',n+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(t.timestamp||"")}">${escapeHtml(o)}</td>`,n+=`<td style="padding: 6px;"><span style="color: ${e}; font-weight: 600;">${escapeHtml(r)}</span></td>`,n+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(a)}</td>`,n+=`<td style="padding: 6px;">${escapeHtml(s)}</td>`,n+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,n+="</tr>",t.detail&&(n+=`<tr class="error-log-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; max-height: 320px; overflow: auto;">${escapeHtml(t.detail)}</pre></td></tr>`)}if(!S)n+="</table>",k.innerHTML=n;else{const t=k.querySelector("table");t&&t.insertAdjacentHTML("beforeend",n)}E+=c.length,M.style.display=f.hasMore?"":"none",I.textContent=`${f.total} total${f.hasMore?" (showing "+E+")":""}`,k.querySelectorAll(".error-log-row").forEach(t=>{t.dataset.wired||(t.dataset.wired="true",t.addEventListener("click",()=>{const r=t.nextElementSibling;r&&r.classList.contains("error-log-detail")&&(r.style.display=r.style.display==="none"?"":"none")}))})}catch(i){if(i&&i.name==="AbortError")return;k.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`,I.textContent=""}}async function y(){if(confirm("Clear the entire error log? This cannot be undone."))try{const i=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();i.success?(await z(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(i.error||"Clear failed"),"error",4e3)}catch(S){showNotification("\u274C "+S.message,"error",4e3)}}let C;function w(){A?.addEventListener("change",()=>u(!1)),D?.addEventListener("change",()=>u(!1)),R?.addEventListener("input",()=>{clearTimeout(C),C=setTimeout(()=>u(!1),250)});let S;[g,L].forEach(i=>{i?.addEventListener("change",()=>{clearTimeout(S),S=setTimeout(()=>u(!1),250)})}),j?.addEventListener("click",()=>u(!1)),M?.addEventListener("click",()=>u(!0)),m?.addEventListener("click",y),wireModal(b,N)}h?.addEventListener("click",async()=>{b?.classList.add("show"),await z(),u(!1)}),w()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
|
|
|
|
</div>`);const x=document.getElementById("error-log-modal"),b=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),g=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),A=document.getElementById("error-log-level"),D=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),f=document.getElementById("error-log-since"),C=document.getElementById("error-log-until"),E=document.getElementById("error-log-container"),M=document.getElementById("error-log-load-more"),w=document.getElementById("error-log-total"),z=50;let S=0,T=null,P=0,v=[];function B(I){if(!I)return null;const i=new Date(I);return isNaN(i.getTime())?null:i.toISOString()}async function H(){try{const I=await fetch("/api/v1/error-logs/contexts");if(!I.ok)return;const i=await I.json();if(!i.success||!Array.isArray(i.contexts))return;v=i.contexts;const c=D.value;D.innerHTML='<option value="">All</option>';for(const d of i.contexts){const y=document.createElement("option");y.value=d.name,y.textContent=`${d.name} (${d.count})`,D.appendChild(y)}c&&i.contexts.some(d=>d.name===c)&&(D.value=c)}catch{}}function O(){const I=new URLSearchParams;I.set("limit",String(z)),I.set("offset",String(S)),A.value&&I.set("level",A.value),D.value&&I.set("context",D.value);const i=B(f.value),c=B(C.value);i&&I.set("since",i),c&&I.set("until",c);const d=(R.value||"").trim();return d&&I.set("search",d),I}async function u(I){try{I?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,S=0,P++,E.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const i=P,c=O(),d=await fetch("/api/v1/error-logs?"+c.toString(),{signal:T.signal});if(!d.ok){E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${d.status}</div>`,M.style.display="none",w.textContent="";return}const y=await d.json();if(!y.success){E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.error||"unknown")}</div>`,M.style.display="none",w.textContent="";return}if(!I&&i!==P)return;const p=Array.isArray(y.logs)?y.logs:[];if(p.length===0&&!I){const e=y.filters&&(y.filters.level||y.filters.context||y.filters.search||y.filters.since||y.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";E.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,M.style.display="none",w.textContent=y.total?`${y.total} total`:"";return}let n="";I||(n='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',n+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',n+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',n+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',n+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',n+='<th style="padding: 6px; text-align: left;">Message</th>',n+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',n+="</tr>");for(const e of p){const r=(e.level||"?").toUpperCase(),s=r==="ERR"?"var(--bad-fg)":r==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",a=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",t=e.context||"\u2014",o=(e.error||"").split(`
|
|
|
|
|
`)[0],l=e.request&&e.request.ip||"";n+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',n+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(a)}</td>`,n+=`<td style="padding: 6px;"><span style="color: ${s}; font-weight: 600;">${escapeHtml(r)}</span></td>`,n+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(t)}</td>`,n+=`<td style="padding: 6px;">${escapeHtml(o)}</td>`,n+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(l)}</td>`,n+="</tr>",e.detail&&(n+=`<tr class="error-log-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; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!I)n+="</table>",E.innerHTML=n;else{const e=E.querySelector("table");e&&e.insertAdjacentHTML("beforeend",n)}S+=p.length,M.style.display=y.hasMore?"":"none",w.textContent=`${y.total} total${y.hasMore?" (showing "+S+")":""}`,E.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const r=e.nextElementSibling;r&&r.classList.contains("error-log-detail")&&(r.style.display=r.style.display==="none"?"":"none")}))})}catch(i){if(i&&i.name==="AbortError")return;E.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`,w.textContent=""}}async function m(){if(confirm("Clear the entire error log? This cannot be undone."))try{const i=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();i.success?(await H(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(i.error||"Clear failed"),"error",4e3)}catch(I){showNotification("\u274C "+I.message,"error",4e3)}}let L;function k(){A?.addEventListener("change",()=>u(!1)),D?.addEventListener("change",()=>u(!1)),R?.addEventListener("input",()=>{clearTimeout(L),L=setTimeout(()=>u(!1),250)});let I;[f,C].forEach(i=>{i?.addEventListener("change",()=>{clearTimeout(I),I=setTimeout(()=>u(!1),250)})}),j?.addEventListener("click",()=>u(!1)),M?.addEventListener("click",()=>u(!0)),g?.addEventListener("click",m),wireModal(x,N)}b?.addEventListener("click",async()=>{x?.classList.add("show"),await H(),u(!1)}),k()})(),(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>
|
|
|
|
@@ -648,14 +648,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"),h=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),m=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),A=document.getElementById("cl-refresh"),D=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),g=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),k=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),I=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),E=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),P=document.getElementById("cl-image"),v=document.getElementById("cl-status"),B=document.getElementById("cl-created");let z=null,O=[],u=[],y=null,C=!1,w=null;function S(s){if(!s)return"-";const d=new Date(s);return isNaN(d.getTime())?s:d.toLocaleString()}function i(s){if(!s)return"";const d=document.createElement("div");return d.textContent=s,d.innerHTML}function p(s,d){const x=s.stream==="stderr"?"log-stderr":"log-stdout",H=s.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
<div class="log-entry ${x}" data-index="${d}" 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;">${d+1}</span>
|
|
|
|
|
<span class="log-stream-icon" style="color: ${s.stream==="stderr"?"#f59e0b":"#22c55e"};">${H}</span>
|
|
|
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${i(s.text)}</span>
|
|
|
|
|
</div>`);const x=document.getElementById("container-logs-modal"),b=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),g=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),A=document.getElementById("cl-refresh"),D=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),f=document.getElementById("cl-clear-search"),C=document.getElementById("cl-close"),E=document.getElementById("cl-close-btn"),M=document.getElementById("cl-stream-status"),w=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),S=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),P=document.getElementById("cl-image"),v=document.getElementById("cl-status"),B=document.getElementById("cl-created");let H=null,O=[],u=[],m=null,L=!1,k=null;function I(o){if(!o)return"-";const l=new Date(o);return isNaN(l.getTime())?o:l.toLocaleString()}function i(o){if(!o)return"";const l=document.createElement("div");return l.textContent=o,l.innerHTML}function c(o,l){const h=o.stream==="stderr"?"log-stderr":"log-stdout",$=o.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
<div class="log-entry ${h}" 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: ${o.stream==="stderr"?"#f59e0b":"#22c55e"};">${$}</span>
|
|
|
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${i(o.text)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`}function l(s,d=""){if(!s||s.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',E.textContent="0 lines",T.textContent="0 filtered";return}if(O=s,u=d?s.filter(x=>x.text&&x.text.toLowerCase().includes(d.toLowerCase())):s,E.textContent=`${s.length} lines`,T.textContent=d?`${u.length} of ${s.length} shown`:`${s.length} shown`,u.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${i(d)}"</div>`;return}j.innerHTML=u.map((x,H)=>p(x,H)).join(""),j.scrollTop=j.scrollHeight}async function f(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],x=h.value;h.innerHTML='<option value="">Select a container...</option>',d.forEach(H=>{const U=document.createElement("option");U.value=H.id,U.textContent=`${H.name} (${H.image.split(":")[0]}) - ${H.status}`,U.dataset.name=H.name,U.dataset.image=H.image,U.dataset.status=H.status,U.dataset.created=H.created,h.appendChild(U)}),x&&h.querySelector(`option[value="${x}"]`)&&(h.value=x,c(x))}catch(s){console.error("Failed to load containers:",s)}}function c(s){const d=h.querySelector(`option[value="${s}"]`);d&&(P.textContent=d.dataset.image||"-",v.textContent=d.dataset.status||"-",v.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",B.textContent=S(d.dataset.created))}async function n(){const s=h.value;if(!s){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}r(),z=s,c(s);const d=N.value,x=m.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const H=`/api/v1/logs/container/${s}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(H);U.logs&&U.logs.length>0?l(U.logs,x):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',E.textContent="0 lines",T.textContent="0 filtered")}catch(H){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${i(H.message)}</div>`}}function t(){const s=h.value;if(!s)return;r(),C=!0,D.textContent="\u23F9 Stop",M.style.display="flex",I.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${s}`;y=new EventSource(d),y.onopen=()=>{I.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},y.onmessage=x=>{try{const H=JSON.parse(x.data);if(H.error){I.textContent="\u{1F534}",$.textContent=`Error: ${H.error}`;return}O.push(H),u.push(H),E.textContent=`${O.length} lines`,T.textContent=`${u.length} shown`;const U=m.value.trim();if(!U||H.text&&H.text.toLowerCase().includes(U.toLowerCase())){const _=document.createElement("div");_.innerHTML=p(H,u.length-1);const F=_.firstElementChild;F.style.background="#1a3a1a",j.appendChild(F),j.scrollTop=j.scrollHeight}}catch(H){console.error("Error parsing log:",H)}},y.onerror=()=>{I.textContent="\u{1F534}",$.textContent="Disconnected",C=!1,D.textContent="\u25B6 Stream"},b._eventSource=y}function r(){y&&(y.close(),y=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),C=!1,D.textContent="\u25B6 Stream",M.style.display="none"}function e(){if(!O||O.length===0){showNotification("No logs to download","error");return}const s=h.querySelector(`option[value="${z}"]`)?.dataset.name||z,d=new Date().toISOString().replace(/[:.]/g,"-"),x=`${s}-logs-${d}.txt`,H=O.map(q=>{const J=q.timestamp||"",V=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${V} ${q.text}`}).join(`
|
|
|
|
|
`),U=new Blob([H],{type:"text/plain"}),_=URL.createObjectURL(U),F=document.createElement("a");F.href=_,F.download=x,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(_),showNotification(`Downloaded ${O.length} log lines`,"success")}h?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),A?.addEventListener("click",()=>{n()}),D?.addEventListener("click",()=>{C?r():t()}),R?.addEventListener("click",()=>{e()}),g?.addEventListener("click",()=>{m.value="",l(O,"")}),m?.addEventListener("input",()=>{clearTimeout(w),w=setTimeout(()=>{l(O,m.value.trim())},300)}),m?.addEventListener("keydown",s=>{s.key==="Escape"&&(m.value="",l(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),f()});function a(){r(),b.classList.remove("show")}L?.addEventListener("click",a),k?.addEventListener("click",a),document.addEventListener("keydown",s=>{s.key==="Escape"&&b.classList.contains("show")&&a()}),b.addEventListener("click",s=>{s.target===b&&a()}),window.openContainerLogsModal=function(s,d){b.classList.add("show"),f().then(()=>{const x=Array.from(h.options).find(H=>H.value===s||H.dataset.name===d);x?(h.value=x.value,c(x.value),n()):s?(z=s,P.textContent=d||s,v.textContent="-",B.textContent="-",n()):j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const b=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
|
|
|
|
|
`}function d(o,l=""){if(!o||o.length===0){j.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(O=o,u=l?o.filter(h=>h.text&&h.text.toLowerCase().includes(l.toLowerCase())):o,S.textContent=`${o.length} lines`,T.textContent=l?`${u.length} of ${o.length} shown`:`${o.length} shown`,u.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${i(l)}"</div>`;return}j.innerHTML=u.map((h,$)=>c(h,$)).join(""),j.scrollTop=j.scrollHeight}async function y(){try{const l=(await getJSON("/api/v1/logs/containers")).containers||[],h=b.value;b.innerHTML='<option value="">Select a container...</option>',l.forEach($=>{const U=document.createElement("option");U.value=$.id,U.textContent=`${$.name} (${$.image.split(":")[0]}) - ${$.status}`,U.dataset.name=$.name,U.dataset.image=$.image,U.dataset.status=$.status,U.dataset.created=$.created,b.appendChild(U)}),h&&b.querySelector(`option[value="${h}"]`)&&(b.value=h,p(h))}catch(o){console.error("Failed to load containers:",o)}}function p(o){const l=b.querySelector(`option[value="${o}"]`);l&&(P.textContent=l.dataset.image||"-",v.textContent=l.dataset.status||"-",v.style.color=l.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",B.textContent=I(l.dataset.created))}async function n(){const o=b.value;if(!o){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}r(),H=o,p(o);const l=N.value,h=g.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const $=`/api/v1/logs/container/${o}${l!=="all"?`?tail=${l}`:""}`,U=await getJSON($);U.logs&&U.logs.length>0?d(U.logs,h):(j.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($){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${i($.message)}</div>`}}function e(){const o=b.value;if(!o)return;r(),L=!0,D.textContent="\u23F9 Stop",M.style.display="flex",w.textContent="\u{1F7E2}",z.textContent="Connecting...";const l=`/api/v1/logs/stream/${o}`;m=new EventSource(l),m.onopen=()=>{w.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},m.onmessage=h=>{try{const $=JSON.parse(h.data);if($.error){w.textContent="\u{1F534}",z.textContent=`Error: ${$.error}`;return}O.push($),u.push($),S.textContent=`${O.length} lines`,T.textContent=`${u.length} shown`;const U=g.value.trim();if(!U||$.text&&$.text.toLowerCase().includes(U.toLowerCase())){const F=document.createElement("div");F.innerHTML=c($,u.length-1);const _=F.firstElementChild;_.style.background="#1a3a1a",j.appendChild(_),j.scrollTop=j.scrollHeight}}catch($){console.error("Error parsing log:",$)}},m.onerror=()=>{w.textContent="\u{1F534}",z.textContent="Disconnected",L=!1,D.textContent="\u25B6 Stream"},x._eventSource=m}function r(){m&&(m.close(),m=null),x._eventSource&&(x._eventSource.close(),x._eventSource=null),L=!1,D.textContent="\u25B6 Stream",M.style.display="none"}function s(){if(!O||O.length===0){showNotification("No logs to download","error");return}const o=b.querySelector(`option[value="${H}"]`)?.dataset.name||H,l=new Date().toISOString().replace(/[:.]/g,"-"),h=`${o}-logs-${l}.txt`,$=O.map(J=>{const q=J.timestamp||"",K=J.stream==="stderr"?"[ERR]":"[OUT]";return`${q?q+" ":""}${K} ${J.text}`}).join(`
|
|
|
|
|
`),U=new Blob([$],{type:"text/plain"}),F=URL.createObjectURL(U),_=document.createElement("a");_.href=F,_.download=h,document.body.appendChild(_),_.click(),document.body.removeChild(_),URL.revokeObjectURL(F),showNotification(`Downloaded ${O.length} log lines`,"success")}b?.addEventListener("change",()=>{n()}),N?.addEventListener("change",()=>{n()}),A?.addEventListener("click",()=>{n()}),D?.addEventListener("click",()=>{L?r():e()}),R?.addEventListener("click",()=>{s()}),f?.addEventListener("click",()=>{g.value="",d(O,"")}),g?.addEventListener("input",()=>{clearTimeout(k),k=setTimeout(()=>{d(O,g.value.trim())},300)}),g?.addEventListener("keydown",o=>{o.key==="Escape"&&(g.value="",d(O,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{x.classList.add("show"),y()});function t(){r(),x.classList.remove("show")}C?.addEventListener("click",t),E?.addEventListener("click",t),document.addEventListener("keydown",o=>{o.key==="Escape"&&x.classList.contains("show")&&t()}),x.addEventListener("click",o=>{o.target===x&&t()}),window.openContainerLogsModal=function(o,l){x.classList.add("show"),y().then(()=>{const h=Array.from(b.options).find($=>$.value===o||$.dataset.name===l);h?(b.value=h.value,p(h.value),n()):o?(H=o,P.textContent=l||o,v.textContent="-",B.textContent="-",n()):j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const x=[{unit:"caddy",label:"Caddy (reverse proxy)"},{unit:"dashcaddy-api",label:"DashCaddy API (host systemd unit, not this container)"},{unit:"docker",label:"Docker daemon"},{unit:"ssh",label:"SSH server"},{unit:"systemd-journald",label:"systemd-journald"},{unit:"tailscaled",label:"Tailscale"},{unit:"networkd-dispatcher",label:"Networkd dispatcher"}];injectModal("journald-modal",`
|
|
|
|
|
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
|
|
|
|
|
<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;">
|
|
|
|
@@ -698,7 +698,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`);const h=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),m=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),A=document.getElementById("jd-refresh"),D=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),g=document.getElementById("jd-close"),L=document.getElementById("jd-close-btn"),k=document.getElementById("jd-content"),M=document.getElementById("jd-line-count"),I=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),E=document.getElementById("jd-unit-display"),T=document.getElementById("jd-stream-state");let P=!1,v=[],B=!1,z=null,O=null;function u(c){const n=document.createElement("div");return n.textContent=String(c),n.innerHTML}function y(c){P=c,j.innerHTML="",b.forEach(n=>{const t=document.createElement("option");t.value=n.unit,t.textContent=n.label+" ("+n.unit+")",j.appendChild(t)}),j.disabled=!c,c?(A.disabled=!1,D.disabled=!1):(k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',A.disabled=!0,D.disabled=!0)}async function C(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){y(!1);return}const n=await c.json();y(!!n.available)}catch{y(!1)}}function w(){const c=(m.value||"").trim().toLowerCase(),n=c?v.filter(t=>(t.textContent||"").toLowerCase().includes(c)):v;if(n.length===0)k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching "${u(c)}"`:"")+"</div>";else{const t=n.map(e=>{const o=e.timestamp?u(e.timestamp):"\u2014",a=u(e.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${o}</span>${a}</div>`}).join("");k.innerHTML=t,k.scrollHeight-k.scrollTop-k.clientHeight<80&&(k.scrollTop=k.scrollHeight)}M.textContent=`${v.length} entries`,I.textContent=c?`${n.length} of ${v.length} shown`:`${v.length} shown`}async function S(){if(!P)return;p();const c=j.value;if(!c)return;const n=Math.max(1,Math.min(5e3,Number(N.value)||200)),t=(m.value||"").trim();k.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const r=new URL("/api/v1/logs/journal",window.location.origin);r.searchParams.set("unit",c),r.searchParams.set("tail",String(n)),t&&r.searchParams.set("search",t);const e=await fetch(r.toString()),o=await e.json();if(!e.ok||!o.success){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(o&&o.error||"HTTP "+e.status)+"</div>";return}E.textContent=c,v=(o.entries||[]).map(a=>({timestamp:a.timestamp,unit:a.unit,textContent:a.text||""})),$.style.display="none",w()}catch(r){k.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(r.message)+"</div>"}}function i(){if(!P)return;p();const c=j.value;if(!c)return;const n=(m.value||"").trim();E.textContent=c,D.textContent="\u23F8 Stop",D.classList.add("streaming"),T.textContent="streaming",T.style.color="var(--ok-fg, #4ade80)",v=[],w(),$.style.display="none";const t=new URL("/api/v1/logs/journal/stream",window.location.origin);t.searchParams.set("unit",c),n&&t.searchParams.set("search",n),z=new EventSource(t.toString()),z.onmessage=r=>{try{const e=JSON.parse(r.data);if(e.error){/stream (exceeded|line cap)/.test(e.error)&&($.style.display="",p()),k.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(e.error)+"</div>",k.scrollTop=k.scrollHeight;return}v.push({timestamp:e.timestamp,unit:e.unit||c,textContent:e.text||""}),v.length>5e3&&(v=v.slice(v.length-5e3),$.style.display=""),w()}catch{}},z.onerror=()=>{},B=!0}function p(){if(B=!1,z){try{z.close()}catch{}z=null}D.textContent="\u25B6 Stream",D.classList.remove("streaming"),T.textContent="disconnected",T.style.color="var(--muted)"}function l(){p(),h.classList.remove("show")}A.addEventListener("click",S),D.addEventListener("click",()=>B?p():i()),R.addEventListener("click",()=>{m.value="",w()}),m.addEventListener("input",()=>{clearTimeout(O),O=setTimeout(w,200)}),m.addEventListener("keydown",c=>{c.key==="Escape"&&(m.value="",w())}),g.addEventListener("click",l),L.addEventListener("click",l),h.addEventListener("click",c=>{c.target===h&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&h.classList.contains("show")&&l()}),j.addEventListener("change",()=>{v.length>0&&S()});function f(){h.classList.add("show"),C()}window.openJournaldModal=f,document.getElementById("view-journald-logs")?.addEventListener("click",f)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
|
|
|
|
`);const b=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),g=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),A=document.getElementById("jd-refresh"),D=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),f=document.getElementById("jd-close"),C=document.getElementById("jd-close-btn"),E=document.getElementById("jd-content"),M=document.getElementById("jd-line-count"),w=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),S=document.getElementById("jd-unit-display"),T=document.getElementById("jd-stream-state");let P=!1,v=[],B=!1,H=null,O=null;function u(p){const n=document.createElement("div");return n.textContent=String(p),n.innerHTML}function m(p){P=p,j.innerHTML="",x.forEach(n=>{const e=document.createElement("option");e.value=n.unit,e.textContent=n.label+" ("+n.unit+")",j.appendChild(e)}),j.disabled=!p,p?(A.disabled=!1,D.disabled=!1):(E.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>',A.disabled=!0,D.disabled=!0)}async function L(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){m(!1);return}const n=await p.json();m(!!n.available)}catch{m(!1)}}function k(){const p=(g.value||"").trim().toLowerCase(),n=p?v.filter(e=>(e.textContent||"").toLowerCase().includes(p)):v;if(n.length===0)E.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching "${u(p)}"`:"")+"</div>";else{const e=n.map(s=>{const a=s.timestamp?u(s.timestamp):"\u2014",t=u(s.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${a}</span>${t}</div>`}).join("");E.innerHTML=e,E.scrollHeight-E.scrollTop-E.clientHeight<80&&(E.scrollTop=E.scrollHeight)}M.textContent=`${v.length} entries`,w.textContent=p?`${n.length} of ${v.length} shown`:`${v.length} shown`}async function I(){if(!P)return;c();const p=j.value;if(!p)return;const n=Math.max(1,Math.min(5e3,Number(N.value)||200)),e=(g.value||"").trim();E.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const r=new URL("/api/v1/logs/journal",window.location.origin);r.searchParams.set("unit",p),r.searchParams.set("tail",String(n)),e&&r.searchParams.set("search",e);const s=await fetch(r.toString()),a=await s.json();if(!s.ok||!a.success){E.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(a&&a.error||"HTTP "+s.status)+"</div>";return}S.textContent=p,v=(a.entries||[]).map(t=>({timestamp:t.timestamp,unit:t.unit,textContent:t.text||""})),z.style.display="none",k()}catch(r){E.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(r.message)+"</div>"}}function i(){if(!P)return;c();const p=j.value;if(!p)return;const n=(g.value||"").trim();S.textContent=p,D.textContent="\u23F8 Stop",D.classList.add("streaming"),T.textContent="streaming",T.style.color="var(--ok-fg, #4ade80)",v=[],k(),z.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",p),n&&e.searchParams.set("search",n),H=new EventSource(e.toString()),H.onmessage=r=>{try{const s=JSON.parse(r.data);if(s.error){/stream (exceeded|line cap)/.test(s.error)&&(z.style.display="",c()),E.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(s.error)+"</div>",E.scrollTop=E.scrollHeight;return}v.push({timestamp:s.timestamp,unit:s.unit||p,textContent:s.text||""}),v.length>5e3&&(v=v.slice(v.length-5e3),z.style.display=""),k()}catch{}},H.onerror=()=>{},B=!0}function c(){if(B=!1,H){try{H.close()}catch{}H=null}D.textContent="\u25B6 Stream",D.classList.remove("streaming"),T.textContent="disconnected",T.style.color="var(--muted)"}function d(){c(),b.classList.remove("show")}A.addEventListener("click",I),D.addEventListener("click",()=>B?c():i()),R.addEventListener("click",()=>{g.value="",k()}),g.addEventListener("input",()=>{clearTimeout(O),O=setTimeout(k,200)}),g.addEventListener("keydown",p=>{p.key==="Escape"&&(g.value="",k())}),f.addEventListener("click",d),C.addEventListener("click",d),b.addEventListener("click",p=>{p.target===b&&d()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&b.classList.contains("show")&&d()}),j.addEventListener("change",()=>{v.length>0&&I()});function y(){b.classList.add("show"),L()}window.openJournaldModal=y,document.getElementById("view-journald-logs")?.addEventListener("click",y)})(),(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">
|
|
|
|
@@ -752,7 +752,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"),h=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),m=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),A=document.getElementById("snapshot-create-btn"),D=document.getElementById("snapshot-create-status");let R=null;async function g(){try{const E=await(await fetch("/api/v1/containers")).json();if(!E.success||!E.containers)return;m.innerHTML='<option value="">-- Select a container --</option>';for(const T of E.containers){const P=document.createElement("option");P.value=T.id,P.textContent=`${T.name||T.id} (${T.image||"unknown"})`,P.dataset.name=T.name,P.dataset.image=T.image,P.dataset.status=T.status,P.dataset.created=T.created,m.appendChild(P)}}catch($){console.error("Failed to load containers:",$)}}function L($){if(!$||!$.value){N.style.display="none",R=null;return}R=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),N.style.display=""}async function k(){if(!R){D.textContent="Please select a container first",D.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){D.textContent="Please enter a snapshot name",D.style.color="var(--bad-fg)";return}const E=document.getElementById("snapshot-leave-running").checked;A.disabled=!0,A.textContent="Creating...",D.textContent="";try{const P=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:E})})).json();P.success?(D.textContent=`\u2713 Snapshot "${$}" created successfully`,D.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(D.textContent=`\u2717 Failed: ${P.error||"Unknown error"}`,D.style.color="var(--bad-fg)")}catch(T){D.textContent=`\u2717 Error: ${T.message}`,D.style.color="var(--bad-fg)"}finally{A.disabled=!1,A.textContent="\u{1F4BE} Create Snapshot"}}function M(){b.classList.add("show"),g()}function I(){b.classList.remove("show"),N.style.display="none",R=null,m.selectedIndex=0}h?.addEventListener("click",M),j?.addEventListener("click",I),wireModal(b,j),m?.addEventListener("change",$=>{const E=m.options[m.selectedIndex];L(E)}),A?.addEventListener("click",k),b?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(E=>E.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(E=>E.classList.remove("active")),$.classList.add("active"),b.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
</div>`);const x=document.getElementById("snapshot-modal"),b=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),g=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),A=document.getElementById("snapshot-create-btn"),D=document.getElementById("snapshot-create-status");let R=null;async function f(){try{const S=await(await fetch("/api/v1/containers")).json();if(!S.success||!S.containers)return;g.innerHTML='<option value="">-- Select a container --</option>';for(const T of S.containers){const P=document.createElement("option");P.value=T.id,P.textContent=`${T.name||T.id} (${T.image||"unknown"})`,P.dataset.name=T.name,P.dataset.image=T.image,P.dataset.status=T.status,P.dataset.created=T.created,g.appendChild(P)}}catch(z){console.error("Failed to load containers:",z)}}function C(z){if(!z||!z.value){N.style.display="none",R=null;return}R=z.value,document.getElementById("snapshot-image").textContent=z.dataset.image||"-",document.getElementById("snapshot-status").textContent=z.dataset.status||"-",document.getElementById("snapshot-created").textContent=z.dataset.created?new Date(z.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=z.value.substring(0,12),N.style.display=""}async function E(){if(!R){D.textContent="Please select a container first",D.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){D.textContent="Please enter a snapshot name",D.style.color="var(--bad-fg)";return}const S=document.getElementById("snapshot-leave-running").checked;A.disabled=!0,A.textContent="Creating...",D.textContent="";try{const P=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:S})})).json();P.success?(D.textContent=`\u2713 Snapshot "${z}" created successfully`,D.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(D.textContent=`\u2717 Failed: ${P.error||"Unknown error"}`,D.style.color="var(--bad-fg)")}catch(T){D.textContent=`\u2717 Error: ${T.message}`,D.style.color="var(--bad-fg)"}finally{A.disabled=!1,A.textContent="\u{1F4BE} Create Snapshot"}}function M(){x.classList.add("show"),f()}function w(){x.classList.remove("show"),N.style.display="none",R=null,g.selectedIndex=0}b?.addEventListener("click",M),j?.addEventListener("click",w),wireModal(x,j),g?.addEventListener("change",z=>{const S=g.options[g.selectedIndex];C(S)}),A?.addEventListener("click",E),x?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{x.querySelectorAll(".panel-tab").forEach(S=>S.classList.remove("active")),x.querySelectorAll(".panel-section").forEach(S=>S.classList.remove("active")),z.classList.add("active"),x.querySelector(`#${z.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;">
|
|
|
|
@@ -831,73 +831,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"),h=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),m=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),A=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),g=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),k=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),I=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let E=null;const T={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},P={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function v(w){N.style.display=w==="detect"?"block":"none",A.style.display=w==="credentials"?"block":"none",D.style.display=w==="progress"?"block":"none",R.style.display=w==="results"?"block":"none"}function B(w){const S={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"}},i=S[w]||S.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, ${i.bg} 20%, transparent); color: ${i.bg};">${i.icon} ${i.text}</span>`}async function z(){v("detect"),g.style.display="none";try{if(E=await(await fetch("/api/v1/arr/smart-detect")).json(),!E.success){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(E.error)}</div>`,g.style.display="block";return}let S='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(E.services)){const f=T[p]||"\u{1F4E6}",c=P[p]||p,n=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",t=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",r=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";S+=`<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;">${f}</span>
|
|
|
|
|
</div>`);const x=document.getElementById("arr-setup-modal"),b=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),g=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),A=document.getElementById("smart-phase-credentials"),D=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),f=document.getElementById("smart-detect-results"),C=document.getElementById("smart-credential-inputs"),E=document.getElementById("smart-progress-steps"),M=document.getElementById("smart-results-content"),w=document.getElementById("smart-plex-libraries"),z=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}"},P={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function v(k){N.style.display=k==="detect"?"block":"none",A.style.display=k==="credentials"?"block":"none",D.style.display=k==="progress"?"block":"none",R.style.display=k==="results"?"block":"none"}function B(k){const I={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"}},i=I[k]||I.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, ${i.bg} 20%, transparent); color: ${i.bg};">${i.icon} ${i.text}</span>`}async function H(){v("detect"),f.style.display="none";try{if(S=await(await fetch("/api/v1/arr/smart-detect")).json(),!S.success){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(S.error)}</div>`,f.style.display="block";return}let I='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(S.services)){const y=T[c]||"\u{1F4E6}",p=P[c]||c,n=d.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(d.source)}</span>`:"",e=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",r=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";I+=`<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;">${y}</span>
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${c}</div>
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${p}</div>
|
|
|
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
|
|
|
${n} ${t} ${r}
|
|
|
|
|
${n} ${e} ${r}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
${B(l.status)}
|
|
|
|
|
</div>`}S+="</div>";const i=E.summary;S+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
|
|
|
|
${B(d.status)}
|
|
|
|
|
</div>`}I+="</div>";const i=S.summary;I+=`<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(i.fullyConnected))}/${escapeHtml(String(i.totalDetected+(5-i.totalDetected)))} services detected ·
|
|
|
|
|
${escapeHtml(String(i.fullyConnected))} connected${i.needsApiKey>0?` · <strong>${escapeHtml(String(i.needsApiKey))} needs API key</strong>`:""}
|
|
|
|
|
</div>`,g.innerHTML=S,g.style.display="block",O(E),setTimeout(()=>{v("credentials")},800)}catch(w){g.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(w.message)}</div>`,g.style.display="block"}}function O(w){let S="";const i=w.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const n=i[c];if(!n||n.status==="not_found"&&!n.url)continue;const t=T[c],r=P[c],e=n.status==="connected";S+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${e?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`,f.innerHTML=I,f.style.display="block",O(S),setTimeout(()=>{v("credentials")},800)}catch(k){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(k.message)}</div>`,f.style.display="block"}}function O(k){let I="";const i=k.services,c=["radarr","sonarr","prowlarr"];for(const p of c){const n=i[p];if(!n||n.status==="not_found"&&!n.url)continue;const e=T[p],r=P[p],s=n.status==="connected";I+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${s?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">${t}</span>
|
|
|
|
|
<span style="font-size: 1.1rem;">${e}</span>
|
|
|
|
|
<span style="font-weight: 500;">${r}</span>
|
|
|
|
|
<span id="smart-${c}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${e?'<span style="color: var(--ok-fg);">✓ Connected</span>':""}
|
|
|
|
|
<span id="smart-${p}-status" style="margin-left: auto; font-size: 0.75rem;">
|
|
|
|
|
${s?'<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-${c}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${c}/"
|
|
|
|
|
<input type="text" id="smart-${p}-url" value="${escapeHtml(n.url||"")}" placeholder="https://seedbox.com/${p}/"
|
|
|
|
|
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-${c}-key" placeholder="${e?"(saved)":"Paste API key"}"
|
|
|
|
|
<input type="password" id="smart-${p}-key" placeholder="${s?"(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('${c}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const l=i.plex;if(l){const c=l.status==="connected";S+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
|
|
|
<button onclick="smartTestConnection('${p}')" style="margin-top: 8px; padding: 4px 12px; font-size: 0.75rem; cursor: pointer;">Test</button>
|
|
|
|
|
</div>`}const d=i.plex;if(d){const p=d.status==="connected";I+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"}; margin-bottom: 10px;">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">\u{1F3AC}</span>
|
|
|
|
|
<span style="font-weight: 500;">Plex</span>
|
|
|
|
|
${B(l.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(l.source||"")}</span>
|
|
|
|
|
${B(d.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}const f=i.seerr;if(f){const c=f.status==="connected";let n="";if(f.configuredServices){const t=f.configuredServices;n=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
Configured: ${t.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
|
|
|
${t.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
|
|
|
${t.plex?"✓ Plex":"✗ Plex"}
|
|
|
|
|
</div>`}S+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${c?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`}const y=i.seerr;if(y){const p=y.status==="connected";let n="";if(y.configuredServices){const e=y.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>`}I+=`<div style="padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${p?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<span style="font-size: 1.1rem;">\u{1F4CB}</span>
|
|
|
|
|
<span style="font-weight: 500;">Seerr</span>
|
|
|
|
|
${B(f.status)}
|
|
|
|
|
${B(y.status)}
|
|
|
|
|
</div>
|
|
|
|
|
${n}
|
|
|
|
|
</div>`}L.innerHTML=S}window.smartTestConnection=async function(w){const S=document.getElementById(`smart-${w}-url`),i=document.getElementById(`smart-${w}-key`),p=document.getElementById(`smart-${w}-status`),l=S?.value.trim(),f=i?.value.trim();if(!l||!f){p.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}p.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:w,url:l,apiKey:f})})).json();n.success?p.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(n.appName||"Connected")} v${escapeHtml(n.version||"")}</span>`:p.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(n.error)}</span>`}catch(c){p.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(c.message)}</span>`}};async function u(){v("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 w={};for(const i of["radarr","sonarr","prowlarr"]){const p=document.getElementById(`smart-${i}-url`)?.value.trim(),l=document.getElementById(`smart-${i}-key`)?.value.trim();l&&p?w[i]={apiKey:l,url:p}:l&&(w[i]={apiKey:l})}const S={services:Object.keys(w).length>0?w: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 p=await(await secureFetch("/api/v1/arr/smart-connect",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();let l="";for(const f of p.steps||[]){const c=f.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',n=f.status==="success"?"var(--muted)":"var(--bad-fg)";l+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${c}
|
|
|
|
|
<span>${escapeHtml(f.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml(f.details||"")}</span>
|
|
|
|
|
</div>`}k.innerHTML=l,setTimeout(()=>y(p),500)}catch(i){k.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(i.message)}</div>`}}function y(w){v("results");const S=w.summary||{},i=S.failed===0&&S.succeeded>0,p=i?"var(--ok-fg)":"#f39c12",l=i?"✓":"⚠",f=i?"All Connected!":`${escapeHtml(String(S.succeeded))}/${escapeHtml(String(S.totalSteps))} Steps Succeeded`;let c=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${p} 12%, transparent); border-radius: 10px; border: 1px solid ${p}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${p};">${l}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${p};">${f}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(S.succeeded))} succeeded, ${escapeHtml(String(S.failed))} failed</div>
|
|
|
|
|
</div>`;c+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of w.steps||[]){const t=n.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';c+=`<div style="display: flex; align-items: center; gap: 8px; padding: 4px 8px; font-size: 0.8rem;">
|
|
|
|
|
${t} ${escapeHtml(n.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(n.details||"")}</span>
|
|
|
|
|
</div>`}c+="</div>",M.innerHTML=c,$.style.display=S.failed>0?"block":"none",w.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&C()}async function C(){try{const S=await(await fetch("/api/v1/plex/libraries")).json();if(S.success&&S.libraries?.length>0){let i=`<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(S.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of S.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";i+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${l} <strong>${escapeHtml(p.title)}</strong>
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span>
|
|
|
|
|
</div>`}i+="</div></div>",I.innerHTML=i,I.style.display="block"}}catch{}}h?.addEventListener("click",()=>{b.classList.add("show"),I.style.display="none",z()}),wireModal(b,j),m?.addEventListener("click",u),$?.addEventListener("click",u)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
|
|
|
</div>`}C.innerHTML=I}window.smartTestConnection=async function(k){const I=document.getElementById(`smart-${k}-url`),i=document.getElementById(`smart-${k}-key`),c=document.getElementById(`smart-${k}-status`),d=I?.value.trim(),y=i?.value.trim();if(!d||!y){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:k,url:d,apiKey:y})})).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(p){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(p.message)}</span>`}};async function u(){v("progress"),E.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 k={};for(const i of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${i}-url`)?.value.trim(),d=document.getElementById(`smart-${i}-key`)?.value.trim();d&&c?k[i]={apiKey:d,url:c}:d&&(k[i]={apiKey:d})}const I={services:Object.keys(k).length>0?k: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(I)})).json();let d="";for(const y of c.steps||[]){const p=y.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',n=y.status==="success"?"var(--muted)":"var(--bad-fg)";d+=`<div style="display: flex; align-items: center; gap: 8px; padding: 6px 10px; background: var(--card-base); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${p}
|
|
|
|
|
<span>${escapeHtml(y.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${n};">${escapeHtml(y.details||"")}</span>
|
|
|
|
|
</div>`}E.innerHTML=d,setTimeout(()=>m(c),500)}catch(i){E.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(i.message)}</div>`}}function m(k){v("results");const I=k.summary||{},i=I.failed===0&&I.succeeded>0,c=i?"var(--ok-fg)":"#f39c12",d=i?"✓":"⚠",y=i?"All Connected!":`${escapeHtml(String(I.succeeded))}/${escapeHtml(String(I.totalSteps))} Steps Succeeded`;let p=`<div style="text-align: center; padding: 16px; background: color-mix(in srgb, ${c} 12%, transparent); border-radius: 10px; border: 1px solid ${c}; margin-bottom: 12px;">
|
|
|
|
|
<div style="font-size: 1.5rem; color: ${c};">${d}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${y}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(I.succeeded))} succeeded, ${escapeHtml(String(I.failed))} failed</div>
|
|
|
|
|
</div>`;p+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const n of k.steps||[]){const e=n.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>';p+=`<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>`}p+="</div>",M.innerHTML=p,z.style.display=I.failed>0?"block":"none",k.steps?.some(n=>n.step.includes("Plex")&&n.status==="success")&&L()}async function L(){try{const I=await(await fetch("/api/v1/plex/libraries")).json();if(I.success&&I.libraries?.length>0){let i=`<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(I.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of I.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";i+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
|
|
|
|
${d} <strong>${escapeHtml(c.title)}</strong>
|
|
|
|
|
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(c.count))} items</span>
|
|
|
|
|
</div>`}i+="</div></div>",w.innerHTML=i,w.style.display="block"}}catch{}}b?.addEventListener("click",()=>{x.classList.add("show"),w.style.display="none",H()}),wireModal(x,j),g?.addEventListener("click",u),z?.addEventListener("click",u)})(),(function(){const x=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>
|
|
|
|
|
|
|
|
|
@@ -1109,7 +1109,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const h=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),m=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(I=>{const $=document.getElementById(`${I}-enabled`),E=document.getElementById(`${I}-config`);$?.addEventListener("change",()=>{E.style.display=$.checked?"block":"none"})});const A=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");A?.addEventListener("change",()=>{D.style.opacity=A.checked?"1":"0.5"});async function R(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const E=$.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),E.providers?.email?.to&&(document.getElementById("email-to").value=E.providers.email.to),E.providers?.email?.port&&(document.getElementById("email-port").value=E.providers.email.port),E.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=E.providers.email.secure===!0),E.providers?.email?.username&&(document.getElementById("email-user").value=E.providers.email.username);const T=document.getElementById("email-pass");E.providers?.email?.hasPassword?(T.value="",T.placeholder="saved \u2014 leave blank to keep"):T.placeholder="app password",document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=E.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=E.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=E.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=E.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=E.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=E.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=E.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=E.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=E.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=E.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=E.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=E.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=E.events?.alert!==!1}}catch(I){b.logError("[Notifications] Load Config",I,{function:"loadConfig"})}}async function g(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");$.success&&$.history?.length>0?E.innerHTML=$.history.map(T=>{const P=new Date(T.timestamp).toLocaleString();return`
|
|
|
|
|
</div>`);const b=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),g=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(w=>{const z=document.getElementById(`${w}-enabled`),S=document.getElementById(`${w}-config`);z?.addEventListener("change",()=>{S.style.display=z.checked?"block":"none"})});const A=document.getElementById("health-check-enabled"),D=document.getElementById("health-check-config");A?.addEventListener("change",()=>{D.style.opacity=A.checked?"1":"0.5"});async function R(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const S=z.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),S.providers?.email?.to&&(document.getElementById("email-to").value=S.providers.email.to),S.providers?.email?.port&&(document.getElementById("email-port").value=S.providers.email.port),S.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=S.providers.email.secure===!0),S.providers?.email?.username&&(document.getElementById("email-user").value=S.providers.email.username);const T=document.getElementById("email-pass");S.providers?.email?.hasPassword?(T.value="",T.placeholder="saved \u2014 leave blank to keep"):T.placeholder="app password",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?.["container-down"]!==!1,document.getElementById("event-container-up").checked=S.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=S.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=S.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=S.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=S.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=S.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=S.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=S.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=S.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=S.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=S.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=S.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=S.events?.alert!==!1}}catch(w){x.logError("[Notifications] Load Config",w,{function:"loadConfig"})}}async function f(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),S=document.getElementById("notification-history");z.success&&z.history?.length>0?S.innerHTML=z.history.map(T=>{const P=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)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span>
|
|
|
|
|
<div style="flex: 1; min-width: 0;">
|
|
|
|
@@ -1117,7 +1117,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${P}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""):E.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(I){b.logError("[Notifications] Load History",I,{function:"loadHistory"})}}async function L(){try{const I={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,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(I)})).json();E.success?(showNotification("Notification settings saved","success",3e3),h.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(I){showNotification(`Error: ${I.message}`,"error",3e3)}}async function k(I){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:I})})).json();E.success?showNotification(`Test ${I} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.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(I){showNotification(`Error: ${I.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{h.classList.add("show"),R(),g()}),m?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const I=document.getElementById("notifications-send-test"),$=I.textContent;I.textContent="Sending...",I.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(P=>`${P.provider}: ${P.error||"ok"}`).join(", ")}`,"error",5e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}finally{I.textContent=$,I.disabled=!1}});async function M(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){
Line truncated
|
|
|
|
|
`}).join(""):S.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(w){x.logError("[Notifications] Load History",w,{function:"loadHistory"})}}async function C(){try{const w={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,username:document.getElementById("email-user").value.trim(),password:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{"container-down":document.getElementById("event-container-down").checked,"container-up":document.getElementById("event-container-up").checked,"deploy-success":document.getElementById("event-deploy-success").checked,"deploy-failed":document.getElementById("event-deploy-failed").checked,"ssl-cert-expiry":document.getElementById("event-ssl-cert-expiry").checked,"dns-propagation":document.getElementById("event-dns-propagation").checked,"drift-detected":document.getElementById("event-drift-detected").checked,"dependency-restart":document.getElementById("event-dependency-restart").checked,"recipe-removed":document.getElementById("event-recipe-removed").checked,workflow:document.getElementById("event-workflow").checked,"backup-complete":document.getElementById("event-backup-complete").checked,"backup-failed":document.getElementById("event-backup-failed").checked,"update-available":document.getElementById("event-update-available").checked,alert: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(w)})).json();S.success?(showNotification("Notification settings saved","success",3e3),b.classList.remove("show")):showNotification(`Failed to save: ${S.error}`,"error",3e3)}catch(w){showNotification(`Error: ${w.message}`,"error",3e3)}}async function E(w){try{const S=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:w})})).json();S.success?showNotification(`Test ${w} notification sent!`,"success",3e3):showNotification(`Test failed: ${S.error}`,"error",3e3)}catch(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>E("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>E("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>E("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>E("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const z=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();z.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(z.lastCheck).toLocaleString()} (${z.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(w){showNotification(`Error: ${w.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{b.classList.add("show"),R(),f()}),g?.addEventListener("click",C),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const w=document.getElementById("notifications-send-test"),z=w.textContent;w.textContent="Sending...",w.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(P=>`${P.provider}: ${P.error||"ok"}`).join(", ")}`,"error",5e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}finally{w.textContent=z,w.disabled=!1}});async function M(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){
Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
|
|
|
|
<h3>\u{1F4BE} Backup & Restore</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1223,9 +1223,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 A=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),g=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),k=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),I=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),E=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),P=document.getElementById("backup-schedules-container"),v=document.getElementById("backup-history-container"),B=document.getElementById("backup-disk-container"),z=document.getElementById("pointintime-container"),O=null;D?.addEventListener("click",function(){A.classList.add("show"),T&&(T.style.display="none"),I&&(I.style.display="none"),M&&(M.style.display="none"),O=null}),wireModal(A,R),g?.addEventListener("click",async function(){g.disabled=!0,g.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var t=await fetch("/api/v1/backup/export"),r=await t.json();r.browserState=h();var e=new Blob([JSON.stringify(r,null,2)],{type:"application/json"}),o=URL.createObjectURL(e),a=document.createElement("a");a.href=o,a.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(o);var s=Object.keys(r.browserState).length,d=r.themes?Object.keys(r.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+s+" browser settings"+(d?" + "+d+" 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(x){T.innerHTML="\u274C Export failed: "+escapeHtml(x.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)"}g.disabled=!1,g.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){k.click()}),k?.addEventListener("change",async function(t){var r=t.target.files[0];if(r){M.textContent="\u{1F4C4} "+r.name,M.style.display="block",T.style.display="none";try{var e=await r.text(),o=JSON.parse(e);if(m(o)){O=o;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(o.version)+")</div>";a+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',o.services?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+o.services.length+" services</span>"),o.customApps?.length&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+o.customApps.length+" custom apps</span>"),o.theme&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(o.theme)+"</span>"),o.userThemes&&(a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(o.userThemes).length+" custom themes</span>"),a+="</div>",$.innerHTML=a,I.style.display="block";return}var s=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)}),d=await s.json();if(d.success){O=o;var a='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(o.exportedAt).toLocaleString()+" (v"+escapeHtml(o.version)+")</div>";a+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',a+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var x in d.preview.files){var H=d.preview.files[x],U=H.action==="create"?"\u{1F195}":"\u{1F4DD}";a+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(H.description)+"</span>"}a+="</div>",d.preview.serviceCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(a+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.preview.browserStateCount&&(a+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',a+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=a,I.style.display="block"}else T.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),T.style.display="block",T.style.background="color-mix(in srgb, #f39c12 15%, transparent)",T.style.border="1px solid #f39c12",I.style.display="none"}catch(_){T.innerHTML="\u274CLine truncated
|
|
|
|
|
</div>`);var A=document.getElementById("backup-modal"),D=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),f=document.getElementById("backup-export-btn"),C=document.getElementById("backup-select-file"),E=document.getElementById("backup-file-input"),M=document.getElementById("backup-file-name"),w=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),S=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),P=document.getElementById("backup-schedules-container"),v=document.getElementById("backup-history-container"),B=document.getElementById("backup-disk-container"),H=document.getElementById("pointintime-container"),O=null;D?.addEventListener("click",function(){A.classList.add("show"),T&&(T.style.display="none"),w&&(w.style.display="none"),M&&(M.style.display="none"),O=null}),wireModal(A,R),f?.addEventListener("click",async function(){f.disabled=!0,f.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),r=await e.json();r.browserState=b();var s=new Blob([JSON.stringify(r,null,2)],{type:"application/json"}),a=URL.createObjectURL(s),t=document.createElement("a");t.href=a,t.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(t),t.click(),document.body.removeChild(t),URL.revokeObjectURL(a);var o=Object.keys(r.browserState).length,l=r.themes?Object.keys(r.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+o+" 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(h){T.innerHTML="\u274C Export failed: "+escapeHtml(h.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)"}f.disabled=!1,f.innerHTML="\u2B07\uFE0F Download Full Backup"}),C?.addEventListener("click",function(){E.click()}),E?.addEventListener("change",async function(e){var r=e.target.files[0];if(r){M.textContent="\u{1F4C4} "+r.name,M.style.display="block",T.style.display="none";try{var s=await r.text(),a=JSON.parse(s);if(g(a)){O=a;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(a.version)+")</div>";t+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',a.services?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+a.services.length+" services</span>"),a.customApps?.length&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+a.customApps.length+" custom apps</span>"),a.theme&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(a.theme)+"</span>"),a.userThemes&&(t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(a.userThemes).length+" custom themes</span>"),t+="</div>",z.innerHTML=t,w.style.display="block";return}var o=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(a)}),l=await o.json();if(l.success){O=a;var t='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(a.exportedAt).toLocaleString()+" (v"+escapeHtml(a.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 h in l.preview.files){var $=l.preview.files[h],U=$.action==="create"?"\u{1F195}":"\u{1F4DD}";t+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml($.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>"),z.innerHTML=t,w.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",w.style.display="none"}catch(F){T.innerHTML="\u274CLine truncated
|
|
|
|
|
|
|
|
|
|
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var e=await secureFetch("/api/v1/apps/"+encodeURIComponent(t)+"/revert/"+encodeURIComponent(r),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),o=await e.json();o.success?(showNotification(t+" restored to "+r,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(o.error||"Unknown"),"error")}catch(a){showNotification("Restore error: "+a.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
|
|
|
|
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var s=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(r),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),a=await s.json();a.success?(showNotification(e+" restored to "+r,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(a.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">
|
|
|
|
@@ -1305,72 +1305,72 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<button id="stats-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),m=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),A=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),g=document.getElementById("stats-last-update");let L=null,k=null;function M(l){if(l===0||!l)return"0 B";const f=1024,c=["B","KB","MB","GB"],n=Math.floor(Math.log(l)/Math.log(f));return parseFloat((l/Math.pow(f,n)).toFixed(1))+" "+c[n]}function I(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function E(){try{let l=null,f=!1;try{const t=await(await fetch("/api/v1/monitoring/stats")).json();t.success&&t.stats&&(l=t.stats,f=!0,k=t.stats)}catch{}if(!f){const t=await(await fetch("/api/v1/stats/containers")).json();if(t.success&&t.stats){l={};for(const r of t.stats)l[r.name]={name:r.name,current:{cpu:r.cpu,memory:{percent:r.memory.percent,usage:r.memory.used,limit:r.memory.limit,usageMB:Math.round(r.memory.used/1048576),limitMB:Math.round(r.memory.limit/1048576)},network:{rxBytes:r.network.rx,txBytes:r.network.tx,rxMB:(r.network.rx/1048576).toFixed(1),txMB:(r.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:r.status};k=l}}if(!l||Object.keys(l).length===0){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,t]of Object.entries(l)){const r=t.current||t,e=r.cpu?.percent||0,o=r.memory?.percent||0,a=I(e),s=$(o),d=r.memory?.usage||r.memory?.used||0,x=r.memory?.limit||0,H=r.network?.rxBytes||r.network?.rx||0,U=r.network?.txBytes||r.network?.tx||0,_=t.aggregated;c+=`
|
|
|
|
|
</div>`);const x=document.getElementById("stats-modal"),b=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),g=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),A=document.getElementById("stats-container"),D=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),f=document.getElementById("stats-last-update");let C=null,E=null;function M(d){if(d===0||!d)return"0 B";const y=1024,p=["B","KB","MB","GB"],n=Math.floor(Math.log(d)/Math.log(y));return parseFloat((d/Math.pow(y,n)).toFixed(1))+" "+p[n]}function w(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function z(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function S(){try{let d=null,y=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(d=e.stats,y=!0,E=e.stats)}catch{}if(!y){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){d={};for(const r of e.stats)d[r.name]={name:r.name,current:{cpu:r.cpu,memory:{percent:r.memory.percent,usage:r.memory.used,limit:r.memory.limit,usageMB:Math.round(r.memory.used/1048576),limitMB:Math.round(r.memory.limit/1048576)},network:{rxBytes:r.network.rx,txBytes:r.network.tx,rxMB:(r.network.rx/1048576).toFixed(1),txMB:(r.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:r.status};E=d}}if(!d||Object.keys(d).length===0){A.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[n,e]of Object.entries(d)){const r=e.current||e,s=r.cpu?.percent||0,a=r.memory?.percent||0,t=w(s),o=z(a),l=r.memory?.usage||r.memory?.used||0,h=r.memory?.limit||0,$=r.network?.rxBytes||r.network?.rx||0,U=r.network?.txBytes||r.network?.tx||0,F=e.aggregated;p+=`
|
|
|
|
|
<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;">${t.name||n}</span>
|
|
|
|
|
${_?`<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 ${_.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;">${t.status||"running"}</span>
|
|
|
|
|
<span style="font-weight: 600; flex: 1;">${e.name||n}</span>
|
|
|
|
|
${F?`<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 ${F.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>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px;">
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">CPU</div>
|
|
|
|
|
<div style="display: flex; align-items: center; gap: 8px;">
|
|
|
|
|
<div style="flex: 1; height: 6px; background: var(--base); border-radius: 3px; overflow: hidden;">
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(e,100)}%; background: ${a}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(s,100)}%; background: ${t}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${a}; min-width: 45px; text-align: right;">${e.toFixed(1)}%</span>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${t}; min-width: 45px; text-align: right;">${s.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(o,100)}%; background: ${s}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
<div style="height: 100%; width: ${Math.min(a,100)}%; background: ${o}; border-radius: 3px; transition: width 0.3s;"></div>
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${s}; min-width: 45px; text-align: right;">${o.toFixed(1)}%</span>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${o}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(d)} / ${M(x)}</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${M(l)} / ${M(h)}</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 ${M(H)}</span>
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${M($)}</span>
|
|
|
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${M(U)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}c+="</div>",A.innerHTML=c,g.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){A.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function T(){if(!D)return;const l=k;if(!l||Object.keys(l).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let f='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,n]of Object.entries(l)){const t=n.aggregated;t&&(f+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${n.name||c}</div>
|
|
|
|
|
</div>`}p+="</div>",A.innerHTML=p,f.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){A.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function T(){if(!D)return;const d=E;if(!d||Object.keys(d).length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4C8}</span>No monitoring data available. Open the Live Stats tab first.</div>';return}let y='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,n]of Object.entries(d)){const e=n.aggregated;e&&(y+=`<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||p}</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${t.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${t.cpu?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max CPU</span></div>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${t.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${t.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
|
|
|
<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>
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${e.memory?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg Mem</span></div>
|
|
|
|
|
<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>
|
|
|
|
|
${t.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${t.dataPoints} data points over ${t.timeRange||24}h</div>`:""}
|
|
|
|
|
</div>`)}f+="</div>",D.innerHTML=f}async function P(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const l=k;if(!l||Object.keys(l).length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let f=!1;try{f=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{f=!1}let c=[];try{const s=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();s.success&&(c=s.history||[])}catch{}let n={};try{const s=await(await fetch("/api/v1/monitoring/alerts/config")).json();s.success&&(n=s.configs||{})}catch{}const r=Object.entries(l).map(([a,s])=>{const d=n[a]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
|
|
|
|
<tr data-container="${a}">
|
|
|
|
|
<td style="font-weight: 600; padding: 8px;">${s.name||a}</td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${d.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${d.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${d.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${f?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${d.autoRestart?"checked":""} ${f?"":"disabled"} /></td>
|
|
|
|
|
${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>`)}y+="</div>",D.innerHTML=y}async function P(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=E;if(!d||Object.keys(d).length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F514}</span>No containers found. Open the Live Stats tab first.</div>';return}let y=!1;try{y=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{y=!1}let p=[];try{const o=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();o.success&&(p=o.history||[])}catch{}let n={};try{const o=await(await fetch("/api/v1/monitoring/alerts/config")).json();o.success&&(n=o.configs||{})}catch{}const r=Object.entries(d).map(([t,o])=>{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;">${o.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;" ${y?"":"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;" ${y?"":"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;" ${y?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${l.autoRestart?"checked":""} ${y?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;">
|
|
|
|
|
<button class="alert-test-btn btn-xs" data-container="${a}" data-name="${s.name||a}" style="padding: 2px 8px; font-size: 0.75rem; background: var(--card-base); border: 1px solid var(--border); border-radius: 4px; cursor: pointer;">Test</button>
|
|
|
|
|
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${o.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(""),e=c.map(a=>{const s=new Date(a.timestamp).toLocaleString(),d=a.notified?"\u2713":"\u2014";return`
|
|
|
|
|
`}).join(""),s=p.map(t=>{const o=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;">${a.containerName||a.containerId}</td>
|
|
|
|
|
<td style="padding: 6px 8px; text-transform: capitalize;">${a.metric||a.type}</td>
|
|
|
|
|
<td style="padding: 6px 8px;">${typeof a.value=="number"?a.value.toFixed(1):a.value}${a.metric==="disk"?" MB/s":"%"}</td>
|
|
|
|
|
<td style="padding: 6px 8px; text-align: center;">${d}</td>
|
|
|
|
|
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${a.autoRestartTriggered?"#f39c12":"var(--muted)"};">${a.autoRestartTriggered?"\u21BB":""}</td>
|
|
|
|
|
<td style="padding: 6px 8px; font-size: 0.8rem; color: var(--muted);">${o}</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(""),o=f?`
|
|
|
|
|
`}).join(""),a=y?`
|
|
|
|
|
<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>
|
|
|
|
@@ -1402,10 +1402,10 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<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>
|
|
|
|
|
`;R.innerHTML=`
|
|
|
|
|
${o}
|
|
|
|
|
${a}
|
|
|
|
|
<div>
|
|
|
|
|
<h4 style="margin: 0 0 10px; font-size: 0.9rem;">\u{1F4CB} Recent Alerts</h4>
|
|
|
|
|
${e?`
|
|
|
|
|
${s?`
|
|
|
|
|
<div style="overflow-x: auto;">
|
|
|
|
|
<table style="width: 100%; border-collapse: collapse; font-size: 0.8rem;">
|
|
|
|
|
<thead>
|
|
|
|
@@ -1418,26 +1418,26 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<th style="padding: 6px 8px;"></th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody>${e}</tbody>
|
|
|
|
|
<tbody>${s}</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 a={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(s=>{const d=s.dataset.container;a[d]={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 d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:a})})).json(),x=document.getElementById("save-all-alerts");x.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{x.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",a=>{a.preventDefault(),b.classList.remove("show"),B(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.textContent;a.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${a.dataset.container}/test`,{method:"POST"}),a.textContent="\u2705",showNotification("Test alert sent for "+a.dataset.name,"success",3e3)}catch{a.textContent="\u274C"}setTimeout(()=>{a.textContent=s},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),B(),typeof openLicenseModal=="function"&&openLicenseModal()})}function v(){L&&clearInterval(L),N?.checked&&(L=setInterval(E,DC.POLL.STATS))}function B(){L&&(clearInterval(L),L=null)}h?.addEventListener("click",()=>{b.classList.add("show"),E(),v()}),j?.addEventListener("click",()=>{b.classList.remove("show"),B()}),b?.addEventListener("click",l=>{l.target===b&&(b.classList.remove("show"),B())}),m?.addEventListener("click",E),N?.addEventListener("change",()=>{N.checked?v():B()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",P);const z=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let y="1h";function C(l){switch(l){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 w(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function S(l,f,c,n,t){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const r=l.map(f).filter(q=>q!=null);if(r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const e=Math.max(...r,1),o=Math.min(...r,0),a=e-o||1,s=600,d=80,x=4,H=(s-x*2)/Math.max(r.length-1,1),U=r.map((q,J)=>{const V=x+J*H,X=d-x-(q-o)/a*(d-x*2);return`${V.toFixed(1)},${X.toFixed(1)}`}).join(" "),_=r[r.length-1],F=r.reduce((q,J)=>q+J,0)/r.length;return`
|
|
|
|
|
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(o=>{const l=o.dataset.container;t[l]={cpuThreshold:parseInt(o.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(o.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(o.querySelector(".alert-disk")?.value)||50,autoRestart:!!o.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(),h=document.getElementById("save-all-alerts");h.textContent=l.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{h.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(),x.classList.remove("show"),B(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const o=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=o},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{x.classList.remove("show"),B(),typeof openLicenseModal=="function"&&openLicenseModal()})}function v(){C&&clearInterval(C),N?.checked&&(C=setInterval(S,DC.POLL.STATS))}function B(){C&&(clearInterval(C),C=null)}b?.addEventListener("click",()=>{x.classList.add("show"),S(),v()}),j?.addEventListener("click",()=>{x.classList.remove("show"),B()}),x?.addEventListener("click",d=>{d.target===x&&(x.classList.remove("show"),B())}),g?.addEventListener("click",S),N?.addEventListener("change",()=>{N.checked?v():B()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",P);const H=document.getElementById("stats-history-container"),O=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let m="1h";function L(d){switch(d){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function k(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function I(d,y,p,n,e){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const r=d.map(y).filter(J=>J!=null);if(r.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(n)}</div>`;const s=Math.max(...r,1),a=Math.min(...r,0),t=s-a||1,o=600,l=80,h=4,$=(o-h*2)/Math.max(r.length-1,1),U=r.map((J,q)=>{const K=h+q*$,Z=l-h-(J-a)/t*(l-h*2);return`${K.toFixed(1)},${Z.toFixed(1)}`}).join(" "),F=r[r.length-1],_=r.reduce((J,q)=>J+q,0)/r.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(n)}</span>
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${_.toFixed(1)}${t} \xB7 avg ${F.toFixed(1)}${t} \xB7 max ${e.toFixed(1)}${t}</span>
|
|
|
|
|
<span style="font-size: 0.75rem; color: var(--muted);">last ${F.toFixed(1)}${e} \xB7 avg ${_.toFixed(1)}${e} \xB7 max ${s.toFixed(1)}${e}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<svg viewBox="0 0 ${s} ${d}" preserveAspectRatio="none" style="width: 100%; height: ${d}px; background: var(--base); border-radius: 4px;">
|
|
|
|
|
<polyline fill="none" stroke="${c}" stroke-width="1.5" points="${U}" />
|
|
|
|
|
<svg viewBox="0 0 ${o} ${l}" preserveAspectRatio="none" style="width: 100%; height: ${l}px; background: var(--base); border-radius: 4px;">
|
|
|
|
|
<polyline fill="none" stroke="${p}" stroke-width="1.5" points="${U}" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
`}function i(){if(!z)return;const l=k||{},f=z.value,c=Object.entries(l);if(c.length===0){z.innerHTML='<option value="">No containers</option>';return}z.innerHTML=c.map(([n,t])=>`<option value="${escapeHtml(n)}">${escapeHtml(t.name||n)}</option>`).join(""),f&&l[f]&&(z.value=f)}async function p(){if(!O||!z)return;const l=z.value;if(!l){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const f=Date.now(),c=f-C(y);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const t=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(l)}?startTime=${c}&endTime=${f}`)).json();if(!t.success)throw new Error(t.error||"Failed to load history");const r=t.samples||[],e=t.tier||"raw";if(r.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${y}. Tier: ${w(e)}.</div>`;return}const o=e==="raw",a=o?U=>U.cpu?.percent:U=>U.cpu?.avg,s=o?U=>U.memory?.percent:U=>U.memory?.avgPercent,d=o?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,x=o?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let H=`
|
|
|
|
|
`}function i(){if(!H)return;const d=E||{},y=H.value,p=Object.entries(d);if(p.length===0){H.innerHTML='<option value="">No containers</option>';return}H.innerHTML=p.map(([n,e])=>`<option value="${escapeHtml(n)}">${escapeHtml(e.name||n)}</option>`).join(""),y&&d[y]&&(H.value=y)}async function c(){if(!O||!H)return;const d=H.value;if(!d){O.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const y=Date.now(),p=y-L(m);O.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const e=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${p}&endTime=${y}`)).json();if(!e.success)throw new Error(e.error||"Failed to load history");const r=e.samples||[],s=e.tier||"raw";if(r.length===0){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${m}. Tier: ${k(s)}.</div>`;return}const a=s==="raw",t=a?U=>U.cpu?.percent:U=>U.cpu?.avg,o=a?U=>U.memory?.percent:U=>U.memory?.avgPercent,l=a?U=>U.network?.rxMB||0:U=>U.network?.rxMB||0,h=a?U=>U.network?.txMB||0:U=>U.network?.txMB||0;let $=`
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
|
|
|
${r.length} samples \xB7 ${escapeHtml(w(e))} \xB7 ${new Date(c).toLocaleString()} \u2192 ${new Date(f).toLocaleString()}
|
|
|
|
|
${r.length} samples \xB7 ${escapeHtml(k(s))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(y).toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
`;H+=S(r,a,"#2ecc71","CPU","%"),H+=S(r,s,"#3498db","Memory","%"),H+=S(r,d,"#9b59b6","Network RX"," MB"),H+=S(r,x,"#e67e22","Network TX"," MB"),O.innerHTML=H}catch(n){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}u.forEach(l=>{l.addEventListener("click",()=>{u.forEach(f=>f.classList.remove("active")),l.classList.add("active"),y=l.dataset.range,p()})}),z?.addEventListener("change",p),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{i(),p()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
|
|
|
`;$+=I(r,t,"#2ecc71","CPU","%"),$+=I(r,o,"#3498db","Memory","%"),$+=I(r,l,"#9b59b6","Network RX"," MB"),$+=I(r,h,"#e67e22","Network TX"," MB"),O.innerHTML=$}catch(n){O.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(n.message)}</div>`}}u.forEach(d=>{d.addEventListener("click",()=>{u.forEach(y=>y.classList.remove("active")),d.classList.add("active"),m=d.dataset.range,c()})}),H?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{i(),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">
|
|
|
|
@@ -1561,24 +1561,24 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<button id="health-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),m=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),A=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),g=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),k=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),I=document.getElementById("health-form-save"),$="dashcaddy-health-settings",E={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},T=document.getElementById("health-global-save"),P=document.getElementById("health-global-reset"),v=document.getElementById("health-global-status"),B=document.getElementById("health-setting-retention"),z=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),y=document.getElementById("health-setting-disk-threshold");function C(){try{const o=safeGet($),a=o?JSON.parse(o):{};return Object.assign({},E,a)}catch{return Object.assign({},E)}}function w(){const o=C();B&&(B.value=o.retentionDays),z&&(z.value=o.pollingInterval),O&&(O.value=o.statsPollingInterval),u&&(u.value=o.maxEntriesPerService),y&&(y.value=o.diskUsageThreshold)}function S(){const o={retentionDays:Math.max(1,Math.min(3650,parseInt(B?.value)||E.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(z?.value)||E.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||E.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||E.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(y?.value)||E.diskUsageThreshold))};try{safeSet($,JSON.stringify(o)),w(),v&&(v.textContent="Saved \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(a){v&&(v.textContent="Save failed",v.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+a.message,"error")}}function i(){try{safeSet($,JSON.stringify(E))}catch{}w(),v&&(v.textContent="Reset to defaults \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500))}w(),T?.addEventListener("click",S),P?.addEventListener("click",i);let p=null;function l(o){return o>=99.9?"var(--ok-fg)":o>=95?"#f39c12":"var(--bad-fg)"}function f(o){const a={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: ${a[o]||"var(--muted)"}20; color: ${a[o]||"var(--muted)"};">${o}</span>`}async function c(){try{const a=await(await fetch("/api/v1/health-checks/status")).json();if(!a.success||!a.status||Object.keys(a.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 s=Object.values(a.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const x of s){const H=x.status==="up",U=H?"var(--dot-ok)":"var(--dot-bad)",_=x.uptime?.["24h"]??"-",F=x.uptime?.["7d"]??"-",q=x.avgResponseTime!=null?Math.round(x.avgResponseTime)+"ms":"-",J=x.timestamp?timeAgo(x.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(x.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(x.name||x.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${H?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof _=="number"?l(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${q}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(x.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
|
|
|
|
</div>`);const x=document.getElementById("health-modal"),b=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),g=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),A=document.getElementById("health-incidents-container"),D=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),f=document.getElementById("health-add-btn"),C=document.getElementById("health-config-form"),E=document.getElementById("health-form-title"),M=document.getElementById("health-form-cancel"),w=document.getElementById("health-form-save"),z="dashcaddy-health-settings",S={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},T=document.getElementById("health-global-save"),P=document.getElementById("health-global-reset"),v=document.getElementById("health-global-status"),B=document.getElementById("health-setting-retention"),H=document.getElementById("health-setting-interval"),O=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),m=document.getElementById("health-setting-disk-threshold");function L(){try{const a=safeGet(z),t=a?JSON.parse(a):{};return Object.assign({},S,t)}catch{return Object.assign({},S)}}function k(){const a=L();B&&(B.value=a.retentionDays),H&&(H.value=a.pollingInterval),O&&(O.value=a.statsPollingInterval),u&&(u.value=a.maxEntriesPerService),m&&(m.value=a.diskUsageThreshold)}function I(){const a={retentionDays:Math.max(1,Math.min(3650,parseInt(B?.value)||S.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(H?.value)||S.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(O?.value)||S.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||S.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(m?.value)||S.diskUsageThreshold))};try{safeSet(z,JSON.stringify(a)),k(),v&&(v.textContent="Saved \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(t){v&&(v.textContent="Save failed",v.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+t.message,"error")}}function i(){try{safeSet(z,JSON.stringify(S))}catch{}k(),v&&(v.textContent="Reset to defaults \u2713",v.style.color="var(--ok-fg)",setTimeout(()=>{v&&(v.textContent="")},2500))}k(),T?.addEventListener("click",I),P?.addEventListener("click",i);let c=null;function d(a){return a>=99.9?"var(--ok-fg)":a>=95?"#f39c12":"var(--bad-fg)"}function y(a){const t={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: ${t[a]||"var(--muted)"}20; color: ${t[a]||"var(--muted)"};">${a}</span>`}async function p(){try{const t=await(await fetch("/api/v1/health-checks/status")).json();if(!t.success||!t.status||Object.keys(t.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 o=Object.values(t.status);let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',l+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',l+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',l+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const h of o){const $=h.status==="up",U=$?"var(--dot-ok)":"var(--dot-bad)",F=h.uptime?.["24h"]??"-",_=h.uptime?.["7d"]??"-",J=h.avgResponseTime!=null?Math.round(h.avgResponseTime)+"ms":"-",q=h.timestamp?timeAgo(h.timestamp):"-";l+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(h.serviceId)}">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(h.name||h.serviceId)}</td>`,l+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${$?"Up":"Down"}</td>`,l+=`<td style="padding: 8px; color: ${typeof F=="number"?d(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,l+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,l+=`<td style="padding: 8px;">${J}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${q}</td>`,l+="</tr>",l+=`<tr id="health-detail-${escapeHtml(h.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
|
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(J.avg||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(J.p95||0)}ms / ${Math.round(J.p99||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(J.min||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(J.max||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${q.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${q.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.message)}</div>`}}})})}catch(o){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(o.message)}</div>`}}async function n(){try{const[o,a]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),s=await o.json(),d=await a.json();let x="";const H=s.success&&s.incidents?s.incidents:[];if(H.length>0){x+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+H.length+")</h4>";for(const _ of H)x+=`<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>${J.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(J.uptime||0)};">${(J.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Avg Response</span><br><strong>${Math.round(q.avg||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">P95 / P99</span><br><strong>${Math.round(q.p95||0)}ms / ${Math.round(q.p99||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Min Response</span><br><strong>${Math.round(q.min||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Max Response</span><br><strong>${Math.round(q.max||0)}ms</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Up Checks</span><br><strong style="color: var(--ok-fg);">${J.upChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Down Checks</span><br><strong style="color: var(--bad-fg);">${J.downChecks||0}</strong></div>
|
|
|
|
|
</div>`}else U.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(F){U.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(F.message)}</div>`}}})})}catch(a){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed to load health status: ${escapeHtml(a.message)}</div>`}}async function n(){try{const[a,t]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),o=await a.json(),l=await t.json();let h="";const $=o.success&&o.incidents?o.incidents:[];if($.length>0){h+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+$.length+")</h4>";for(const F of $)h+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
|
|
|
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
|
|
|
|
|
<span>${f(_.severity)}</span>
|
|
|
|
|
<span style="font-weight: 500;">${escapeHtml(F.serviceId)}</span>
|
|
|
|
|
<span>${y(F.severity)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(_.message)}</div>
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(_.createdAt)} \xB7 ${_.occurrences||1} occurrence(s)</div>
|
|
|
|
|
</div>`;x+="</div>"}else x+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){x+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',x+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',x+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of U){const F=_.status==="resolved",q=F&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";x+='<tr style="border-bottom: 1px solid var(--border);">',x+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,x+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,x+=`<td style="padding: 6px;">${f(_.severity)}</td>`,x+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,x+=`<td style="padding: 6px;">${q}</td>`,x+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,x+="</tr>"}x+="</table>"}A.innerHTML=x||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(o){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`}}async function t(){try{const a=await(await fetch("/api/v1/health-checks/status")).json(),s=a.success&&a.status?Object.values(a.status):[];if(s.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<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 x of s){const H=x.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(x.name||x.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${H?"var(--ok-fg)":"var(--bad-fg)"};">${H?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${x.sla?.target?x.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(x.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(x.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",D.innerHTML=d}catch(o){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`}}function r(o,a,s,d,x,H,U){p=o||null,k.textContent=o?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=o||"",document.getElementById("health-form-id").disabled=!!o,document.getElementById("health-form-name").value=a||"",document.getElementById("health-form-url").value=s||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=x||"200",document.getElementById("health-form-sla").value=H||99.9,document.getElementById("health-form-slow").value=U||5e3,L.style.display="",g.style.display="none"}function e(){L.style.display="none",g.style.display="",p=null}g?.addEventListener("click",()=>r("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",e),I?.addEventListener("click",async()=>{const o=p||document.getElementById("health-form-id").value.trim();if(!o)return showNotification("Service ID is required","warning");const a=document.getElementById("health-form-url").value.trim();if(!a)return showNotification("URL is required","warning");const s=document.getElementById("health-form-codes").value.split(",").map(x=>parseInt(x.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||o,url:a,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:s.length?s:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
|
|
|
|
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(F.message)}</div>
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(F.createdAt)} \xB7 ${F.occurrences||1} occurrence(s)</div>
|
|
|
|
|
</div>`;h+="</div>"}else h+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=l.success&&l.history?l.history:[];if(U.length>0){h+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',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;">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 F of U){const _=F.status==="resolved",J=_&&F.duration?F.duration<6e4?Math.round(F.duration/1e3)+"s":Math.round(F.duration/6e4)+"m":"-";h+='<tr style="border-bottom: 1px solid var(--border);">',h+=`<td style="padding: 6px;">${escapeHtml(F.serviceId)}</td>`,h+=`<td style="padding: 6px;">${escapeHtml(F.type)}</td>`,h+=`<td style="padding: 6px;">${y(F.severity)}</td>`,h+=`<td style="padding: 6px;"><span style="color: ${_?"var(--ok-fg)":"var(--bad-fg)"};">${F.status}</span></td>`,h+=`<td style="padding: 6px;">${J}</td>`,h+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(F.createdAt)}</td>`,h+="</tr>"}h+="</table>"}A.innerHTML=h||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(a){A.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}async function e(){try{const t=await(await fetch("/api/v1/health-checks/status")).json(),o=t.success&&t.status?Object.values(t.status):[];if(o.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2699\uFE0F</span>No health checks configured yet. Click "Add Health Check" below.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const h of o){const $=h.status==="up";l+='<tr style="border-bottom: 1px solid var(--border);">',l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(h.name||h.serviceId)}</td>`,l+=`<td style="padding: 8px; color: ${$?"var(--ok-fg)":"var(--bad-fg)"};">${$?"Up":"Down"}</td>`,l+=`<td style="padding: 8px;">${h.sla?.target?h.sla.target+"%":"-"}</td>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(h.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,l+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(h.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,l+="</td></tr>"}l+="</table>",D.innerHTML=l}catch(a){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}function r(a,t,o,l,h,$,U){c=a||null,E.textContent=a?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=a||"",document.getElementById("health-form-id").disabled=!!a,document.getElementById("health-form-name").value=t||"",document.getElementById("health-form-url").value=o||"",document.getElementById("health-form-timeout").value=l||1e4,document.getElementById("health-form-codes").value=h||"200",document.getElementById("health-form-sla").value=$||99.9,document.getElementById("health-form-slow").value=U||5e3,C.style.display="",f.style.display="none"}function s(){C.style.display="none",f.style.display="",c=null}f?.addEventListener("click",()=>r("","","",1e4,"200",99.9,5e3)),M?.addEventListener("click",s),w?.addEventListener("click",async()=>{const a=c||document.getElementById("health-form-id").value.trim();if(!a)return showNotification("Service ID is required","warning");const t=document.getElementById("health-form-url").value.trim();if(!t)return showNotification("URL is required","warning");const o=document.getElementById("health-form-codes").value.split(",").map(h=>parseInt(h.trim())).filter(Boolean),l={name:document.getElementById("health-form-name").value.trim()||a,url:t,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:o.length?o:[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">
|
|
|
|
@@ -1652,17 +1652,17 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<button id="updates-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),m=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),A=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function g(){try{const n=await(await fetch("/api/v1/updates/available")).json();if(!n.success)throw new Error(n.error);const t=n.updates||[];if(t.length===0){N.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',R.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';r+='<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 a of t){const s=(()=>{const d=window.APPS||[];for(const x of d)if(x.containerId===a.containerId||x.name===a.containerName||x.id===a.containerName)return x.id;return a.containerName})();r+=`<tr data-app-id="${escapeHtml(s)}" style="border-bottom: 1px solid var(--border);">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(a.containerName)}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(a.imageName)}</td>`,r+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(a.currentDigest)}</code></td>`,r+=`<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(a.latestDigest)}</code></td>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<button class="update-now-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,r+=`<button class="rollback-btn" data-id="${escapeHtml(a.containerId)}" data-name="${escapeHtml(a.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,r+="</td></tr>"}r+="</table>",N.innerHTML=r,R.textContent=t.length+" update(s) available";const e=document.getElementById("updates-count-badge"),o=document.getElementById("updates-update-all-btn");e&&(e.textContent=t.length+" pending",e.style.display=""),o&&t.length>0&&(o.style.display=""),window._pendingUpdates=t,N.querySelectorAll(".update-now-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.dataset.id,d=a.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){a.textContent="Updating...",a.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(H.success)a.textContent="Done!",a.style.background="var(--ok-fg)",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Update failed")}catch(x){a.textContent="Failed",a.style.color="var(--bad-fg)",showNotification("Update error: "+x.message,"error"),setTimeout(()=>{a.textContent="Update",a.disabled=!1,a.style.color="",a.style.background=""},3e3)}}})}),N.querySelectorAll(".rollback-btn").forEach(a=>{a.addEventListener("click",async()=>{const s=a.dataset.id,d=a.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){a.textContent="Rolling back...",a.disabled=!0;try{const H=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(s)}`,{method:"POST"})).json();if(H.success)a.textContent="Rolled back!",setTimeout(()=>g(),2e3);else throw new Error(H.error||"Rollback failed")}catch(x){a.textContent="Failed",showNotification("Rollback error: "+x.message,"error"),setTimeout(()=>{a.textContent="Rollback",a.disabled=!1},3e3)}}})})}catch(c){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function L(){const c=window._pendingUpdates||[];if(!c.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let t=0,r=0;for(const e of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(e.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?t++:r++}catch{r++}n.textCLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(x)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
</div>`);const x=document.getElementById("updates-modal"),b=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),g=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),A=document.getElementById("updates-history-container"),D=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function f(){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>',R.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';r+='<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 o=(()=>{const l=window.APPS||[];for(const h of l)if(h.containerId===t.containerId||h.name===t.containerName||h.id===t.containerName)return h.id;return t.containerName})();r+=`<tr data-app-id="${escapeHtml(o)}" style="border-bottom: 1px solid var(--border);">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,r+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,r+=`<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>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<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>`,r+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,r+="</td></tr>"}r+="</table>",N.innerHTML=r,R.textContent=e.length+" update(s) available";const s=document.getElementById("updates-count-badge"),a=document.getElementById("updates-update-all-btn");s&&(s.textContent=e.length+" pending",s.style.display=""),a&&e.length>0&&(a.style.display=""),window._pendingUpdates=e,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const o=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 $=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if($.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>f(),2e3);else throw new Error($.error||"Update failed")}catch(h){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+h.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 o=t.dataset.id,l=t.dataset.name;if(confirm(`Rollback "${l}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const $=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(o)}`,{method:"POST"})).json();if($.success)t.textContent="Rolled back!",setTimeout(()=>f(),2e3);else throw new Error($.error||"Rollback failed")}catch(h){t.textContent="Failed",showNotification("Rollback error: "+h.message,"error"),setTimeout(()=>{t.textContent="Rollback",t.disabled=!1},3e3)}}})})}catch(p){N.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}async function C(){const p=window._pendingUpdates||[];if(!p.length)return;const n=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;n.textContent="\u23F3 Updating...",n.disabled=!0;let e=0,r=0;for(const s of p)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(s.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:r++}catch{r++}n.textCLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(h)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
<option value=""${U?"":" selected"}>Disabled</option>
|
|
|
|
|
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
|
|
|
|
|
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
|
|
|
|
|
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
|
|
|
|
|
</select></td>`,a+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(x)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,a+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(x)}"${_?" checked":""} /></td>`,a+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,a+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(x)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,a+="</tr>"}a+="</table>",D.innerHTML=a,D.querySelectorAll(".save-auto-btn").forEach(s=>{s.addEventListener("click",async()=>{const d=s.dataset.id,x=s.closest("tr"),H=x.querySelector(".auto-schedule").value,U=x.querySelector(".auto-rollback").checked,_=x.querySelector(".auto-window").value.trim();s.textContent="Saving...",s.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!H,schedule:H||"weekly",autoRollback:U,maintenanceWindow:_||void 0})})).json();if(q.success)s.textContent="\u2713 Saved";else throw new Error(q.error)}catch(F){s.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{s.textContent="Save",s.disabled=!1},2e3)})})}catch(c){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),E=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),P=document.getElementById("dashcaddy-new-version"),v=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),z=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),y=document.getElementById("dashcaddy-history-container");let C=null;function w(c,n){u&&(u.style.display="block",u.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function S(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const t=n.commit&&n.commit!=="unknown"?n.commit:null;$.textContent="v"+n.version+(t?" ("+t.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function i(c){c||(z.textContent="Checking...",z.disabled=!0);try{const t=await(await fetch("/api/v1/system/update-check")).json();if(C=t,t.success&&t.available&&t.remote){E.style.display="",T.style.display="",P.textContent="v"+t.remote.version,v.textContent=t.remote.changelog||"No changelog available.";const r=document.getElementById("updates-btn");if(r&&!r.querySelector(".update-dot")){const o=document.createElement("span");o.className="update-dot",o.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",r.style.position="relative",r.appendChild(o)}const e=document.getElementById("updates-dashcaddy-tab");if(e&&!e.querySelector(".update-dot")){const o=document.createElement("span");o.className="update-dot",o.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",e.appendChild(o)}}else E.style.display="none",T.style.display="none",await S(),c||w("You are running the latest version.","success");c||(z.textContent="Check for Updates",z.disabled=!1)}catch(n){c||(w("Failed to check: "+n.message,"error"),z.textContent="Check for Updates",z.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,w("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return w("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(t=>t.remove()),!0;throw new Error(n.error||"Update failed")}catch(c){throw w("Update failed: "+c.message,"error"),B.textContent="Update Now",B.disabled=!1,c}}async function l(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),t=n.success&&n.history?n.history:[];if(t.length===0){y.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';r+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
`+t.join(`
|
|
|
|
|
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(h)}" value="${escapeHtml(_)}" 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(h)}"${F?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${J}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(h)}" data-name="${escapeHtml(l)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",D.innerHTML=t,D.querySelectorAll(".save-auto-btn").forEach(o=>{o.addEventListener("click",async()=>{const l=o.dataset.id,h=o.closest("tr"),$=h.querySelector(".auto-schedule").value,U=h.querySelector(".auto-rollback").checked,F=h.querySelector(".auto-window").value.trim();o.textContent="Saving...",o.disabled=!0;try{const J=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(l)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!$,schedule:$||"weekly",autoRollback:U,maintenanceWindow:F||void 0})})).json();if(J.success)o.textContent="\u2713 Saved";else throw new Error(J.error)}catch(_){o.textContent="\u2717 Error",showNotification("Save error: "+_.message,"error")}setTimeout(()=>{o.textContent="Save",o.disabled=!1},2e3)})})}catch(p){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),S=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),P=document.getElementById("dashcaddy-new-version"),v=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),H=document.getElementById("dashcaddy-check-btn"),O=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),m=document.getElementById("dashcaddy-history-container");let L=null;function k(p,n){u&&(u.style.display="block",u.style.background=n==="error"?"var(--bad-bg)":n==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=n==="error"?"var(--bad-fg)":n==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=p)}async function I(){try{const n=await(await fetch("/api/v1/system/version")).json();if(n.success){const e=n.commit&&n.commit!=="unknown"?n.commit:null;z.textContent="v"+n.version+(e?" ("+e.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function i(p){p||(H.textContent="Checking...",H.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(L=e,e.success&&e.available&&e.remote){S.style.display="",T.style.display="",P.textContent="v"+e.remote.version,v.textContent=e.remote.changelog||"No changelog available.";const r=document.getElementById("updates-btn");if(r&&!r.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",r.style.position="relative",r.appendChild(a)}const s=document.getElementById("updates-dashcaddy-tab");if(s&&!s.querySelector(".update-dot")){const a=document.createElement("span");a.className="update-dot",a.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",s.appendChild(a)}}else S.style.display="none",T.style.display="none",await I(),p||k("You are running the latest version.","success");p||(H.textContent="Check for Updates",H.disabled=!1)}catch(n){p||(k("Failed to check: "+n.message,"error"),H.textContent="Check for Updates",H.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,k("Downloading and applying update...","info");try{const n=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(n.success)return k("Update initiated: v"+(n.fromVersion||"?")+" \u2192 v"+(n.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(n.error||"Update failed")}catch(p){throw k("Update failed: "+p.message,"error"),B.textContent="Update Now",B.disabled=!1,p}}async function d(){try{const n=await(await fetch("/api/v1/system/update-history")).json(),e=n.success&&n.history?n.history:[];if(e.length===0){m.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let r='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';r+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
`+e.join(`
|
|
|
|
|
`)+`
|
|
|
|
|
|
|
|
|
|
Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification("Invalid version: "+r,"error");return}if(!confirm("Rollback DashCaddy to v"+r+"? The container will restart."))return;w("Rolling back to v"+r+"...","info");const o=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:r})})).json();if(o.success)w("Rollback to v"+r+" initiated. Container will restart.","success");else throw new Error(o.error||"Rollback failed")}catch(c){w("Rollback failed: "+c.message,"error")}}z?.addEventListener("click",()=>i(!1)),B?.addEventListener("click",()=>p().catch(()=>{})),O?.addEventListener("click",f),m?.addEventListener("click",k),h?.addEventListener("click",()=>{b?.classList.add("show"),g()}),wireModal(b,j),window.openUpdateModal=function(c){b?.classList.add("show"),g().then(()=>{if(!c)return;const n=N.querySelector(`[data-app-id="${c}"]`);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",I),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{S(),l(),C||i(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=i,setTimeout(()=>i(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
|
|
|
|
Enter version to rollback to:`);if(!r)return;if(!e.includes(r)){showNotification("Invalid version: "+r,"error");return}if(!confirm("Rollback DashCaddy to v"+r+"? The container will restart."))return;k("Rolling back to v"+r+"...","info");const a=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:r})})).json();if(a.success)k("Rollback to v"+r+" initiated. Container will restart.","success");else throw new Error(a.error||"Rollback failed")}catch(p){k("Rollback failed: "+p.message,"error")}}H?.addEventListener("click",()=>i(!1)),B?.addEventListener("click",()=>c().catch(()=>{})),O?.addEventListener("click",y),g?.addEventListener("click",E),b?.addEventListener("click",()=>{x?.classList.add("show"),f()}),wireModal(x,j),window.openUpdateModal=function(p){x?.classList.add("show"),f().then(()=>{if(!p)return;const n=N.querySelector(`[data-app-id="${p}"]`);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",w),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{I(),d(),L||i(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=i,setTimeout(()=>i(!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>
|
|
|
|
@@ -1711,7 +1711,7 @@ Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification
|
|
|
|
|
<button id="dr-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("docker-resources-modal"),h=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function m(R){if(!R||R===0)return"0 B";const g=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,L)).toFixed(1)+" "+g[L]}async function N(){const R=document.getElementById("dr-vol-list");try{const L=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(L.length===0){R.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 M of L){const I=M.name==="buildkit"||M.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(M.name)}">${escapeHtml(M.name.length>40?M.name.substring(0,37)+"...":M.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,k+='<td style="padding: 6px; text-align: right;">',I||(k+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),k+="</td></tr>"}k+="</table>",R.innerHTML=k,R.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(I){showNotification("Delete failed: "+I.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(g){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),g=R.value.trim();if(!g){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:g}),R.value="",showNotification(`Volume "${g}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function A(){const R=document.getElementById("dr-net-list");try{const L=(await getJSON("/api/v1/docker/networks")).networks||[];if(L.length===0){R.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 M of L){const I=["bridge","host","none"].includes(M.name);k+='<tr style="border-bottom: 1px solid var(--border);">',k+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,k+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,k+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,k+='<td style="padding: 6px; text-align: right;">',I||(k+=`<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>`),k+="</td></tr>"}k+="</table>",R.innerHTML=k,R.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)}`),A()}catch(I){showNotification("Delete failed: "+I.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(g){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),g=document.getElementById("dr-net-driver"),L=R.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:g.value}),R.value="",showNotification(`Network "${L}" created`,"success"),A()}catch(k){showNotification("Create failed: "+k.message,"error")}});async fuLine truncated
|
|
|
|
|
</div>`);const x=document.getElementById("docker-resources-modal"),b=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function g(R){if(!R||R===0)return"0 B";const f=["B","KB","MB","GB","TB"],C=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,C)).toFixed(1)+" "+f[C]}async function N(){const R=document.getElementById("dr-vol-list");try{const C=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(C.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</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;">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 C){const w=M.name==="buildkit"||M.name.length===64;E+='<tr style="border-bottom: 1px solid var(--border);">',E+=`<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>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,E+='<td style="padding: 6px; text-align: right;">',w||(E+=`<button class="dr-vol-del" data-name="${escapeHtml(M.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),E+="</td></tr>"}E+="</table>",R.innerHTML=E,R.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(w){showNotification("Delete failed: "+w.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(f){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),f=R.value.trim();if(!f){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:f}),R.value="",showNotification(`Volume "${f}" created`,"success"),N()}catch(C){showNotification("Create failed: "+C.message,"error")}});async function A(){const R=document.getElementById("dr-net-list");try{const C=(await getJSON("/api/v1/docker/networks")).networks||[];if(C.length===0){R.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</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;">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 C){const w=["bridge","host","none"].includes(M.name);E+='<tr style="border-bottom: 1px solid var(--border);">',E+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(M.name)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.driver)}</td>`,E+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(M.scope)}</td>`,E+=`<td style="padding: 6px; text-align: center;">${M.containers}</td>`,E+='<td style="padding: 6px; text-align: right;">',w||(E+=`<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>`),E+="</td></tr>"}E+="</table>",R.innerHTML=E,R.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)}`),A()}catch(w){showNotification("Delete failed: "+w.message,"error"),M.textContent="Delete",M.disabled=!1}}})})}catch(f){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(f.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),f=document.getElementById("dr-net-driver"),C=R.value.trim();if(!C){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:C,driver:f.value}),R.value="",showNotification(`Network "${C}" created`,"success"),A()}catch(E){showNotification("Create failed: "+E.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>
|
|
|
|
@@ -1752,7 +1752,7 @@ Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification
|
|
|
|
|
<div id="compose-progress-content"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("compose-import-modal"),h=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(b,j);let m=null;function N(D){document.getElementById("compose-step-paste").style.display=D==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=D==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=D==="progress"?"":"none"}h?.addEventListener("click",()=>{N("paste"),m=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",D=>{const R=D.target.files[0];if(!R)return;const g=new FileReader;g.onload=()=>{document.getElementById("compose-yaml").value=g.result},g.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const D=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!D){showNotification("Paste a docker-compose.yml","warning");return}const g=document.getElementById("compose-parse-btn"),L=g.textContent;g.textContent="Parsing...",g.disabled=!0;try{const k=await postJSON("/api/v1/apps/import-compose",{yaml:D,stackName:R});m=k,m.stackName=R,A(k),N("preview")}catch(k){showNotification("Parse failed: "+k.message,"error")}finally{g.textContent=L,g.disabled=!1}});function A(D){const R=document.getElementById("compose-preview-content");let g="";D.networks&&D.networks.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${D.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),D.volumes&&D.volumes.length>0&&(g+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${D.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),g+=`<div style="font-weight: 600; margin-bottom: 8px;">${D.services.length} service(s)</div>`,g+='<div class="scroll-container" style="max-height: 350px;">';for(const L of D.services){const k=L.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(L.name)}`,L.skip&&(g+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),g+="</div>",!L.skip&&(g+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),L.volumes?.length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(g+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(g+=`<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`),g+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}g+="</div>"}g+="</div>",R.innerHTML=g}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!m)return;const D=document.getElementById("compose-deploy-btn");D.textContent="Deploying...",D.disabled=!0,N("progress");const R=document.getElementById("compose-progress-content");R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const g=await postJSON("/api/v1/apps/deploy-compose",{services:m.services,networks:m.networks,stackName:m.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(g.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const k of g.results){const M=k.status==="deployed"||k.status==="created"?"\u2705":k.status==="exists"?"\u26A1":k.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${M} <strong>${escapeHtml(k.name)}</strong> (${k.type}) \u2014 ${escapeHtml(k.status)}`,k.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(k.error)}</span>`),k.subdomain&&(L+=` \u2192 <code>${escapeHtml(k.subdomain)}</code>`),k.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(k.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>`);const x=document.getElementById("compose-import-modal"),b=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(x,j);let g=null;function N(D){document.getElementById("compose-step-paste").style.display=D==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=D==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=D==="progress"?"":"none"}b?.addEventListener("click",()=>{N("paste"),g=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",x?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",D=>{const R=D.target.files[0];if(!R)return;const f=new FileReader;f.onload=()=>{document.getElementById("compose-yaml").value=f.result},f.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const D=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!D){showNotification("Paste a docker-compose.yml","warning");return}const f=document.getElementById("compose-parse-btn"),C=f.textContent;f.textContent="Parsing...",f.disabled=!0;try{const E=await postJSON("/api/v1/apps/import-compose",{yaml:D,stackName:R});g=E,g.stackName=R,A(E),N("preview")}catch(E){showNotification("Parse failed: "+E.message,"error")}finally{f.textContent=C,f.disabled=!1}});function A(D){const R=document.getElementById("compose-preview-content");let f="";D.networks&&D.networks.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${D.networks.map(C=>`<code>${escapeHtml(C)}</code>`).join(", ")}</div>`),D.volumes&&D.volumes.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${D.volumes.map(C=>`<code>${escapeHtml(C)}</code>`).join(", ")}</div>`),f+=`<div style="font-weight: 600; margin-bottom: 8px;">${D.services.length} service(s)</div>`,f+='<div class="scroll-container" style="max-height: 350px;">';for(const C of D.services){const E=C.skip?"var(--bad-fg)":"var(--border)";if(f+=`<div style="padding: 10px 14px; border: 1px solid ${E}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,f+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(C.name)}`,C.skip&&(f+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(C.reason)}</span>`),f+="</div>",!C.skip&&(f+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(C.image)}</code></div>`,C.ports?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${C.ports.map(M=>`${M.host}:${M.container}`).join(", ")}</div>`),C.volumes?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${C.volumes.length}</div>`),Object.keys(C.environment||{}).length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(C.environment).length}</div>`),C.envFileWarning&&(f+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(C.envFileWarning)}</div>`),C.resources?.cpus||C.resources?.memory)){const M=[];C.resources.cpus&&M.push(`CPU: ${C.resources.cpus}`),C.resources.memory&&M.push(`Mem: ${C.resources.memory}MB`),f+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${M.join(", ")}</div>`}f+="</div>"}f+="</div>",R.innerHTML=f}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!g)return;const D=document.getElementById("compose-deploy-btn");D.textContent="Deploying...",D.disabled=!0,N("progress");const R=document.getElementById("compose-progress-content");R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const f=await postJSON("/api/v1/apps/deploy-compose",{services:g.services,networks:g.networks,stackName:g.stackName});let C=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(f.stackName)}" \u2014 Deployment Complete</div>`;C+='<div class="scroll-container" style="max-height: 350px;">';for(const E of f.results){const M=E.status==="deployed"||E.status==="created"?"\u2705":E.status==="exists"?"\u26A1":E.status==="skipped"?"\u23ED":"\u274C";C+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',C+=`${M} <strong>${escapeHtml(E.name)}</strong> (${E.type}) \u2014 ${escapeHtml(E.status)}`,E.error&&(C+=` <span style="color: var(--bad-fg);">${escapeHtml(E.error)}</span>`),E.subdomain&&(C+=` \u2192 <code>${escapeHtml(E.subdomain)}</code>`),E.reason&&(C+=` <span style="color: var(--muted);">(${escapeHtml(E.reason)})</span>`),C+="</div>"}C+="</div>",C+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{D.textContent="Deploy All",D.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>
|
|
|
|
@@ -1761,11 +1761,11 @@ Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification
|
|
|
|
|
<button id="exec-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("exec-modal"),h=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let m=null,N=null,A=null;function D(){if(N){try{N.close()}catch{}N=null}if(m){try{m.dispose()}catch{}m=null}A=null,h.innerHTML=""}function R(g,L){if(D(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||g}`,b?.classList.add("show"),typeof Terminal>"u"){h.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}m=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"&&(A=new FitAddon.FitAddon,m.loadAddon(A)),m.open(h),A&&setTimeout(()=>A.fit(),50);const k=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${k}//${location.host}/ws/exec/${encodeURIComponent(g)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(m.writeln("\x1B[32mConnecting...\x1B[0m"),A){const I=A.proposeDimensions();I&&N.send(JSON.stringify({type:"resize",cols:I.cols,rows:I.rows}))}},N.onmessage=I=>{if(typeof I.data=="string"){try{const $=JSON.parse(I.data);if($.type==="connected"){m.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
|
|
|
|
|
`);return}if($.type==="error"){m.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){m.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}m.write(I.data)}else m.write(new Uint8Array(I.data))},N.onclose=()=>{m&&m.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{m&&m.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},m.onData(I=>{N&&N.readyState===WebSocket.OPEN&&N.send(I)}),m.onResize(({cols:I,rows:$})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:I,rows:$}))});const M=()=>{A&&A.fit()};window.addEventListener("resize",M),b._resizeHandler=M}j?.addEventListener("click",()=>{D(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",g=>{g.target===b&&(D(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show"))}),window.openExecModal=R})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
|
|
|
</div>`);const x=document.getElementById("exec-modal"),b=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let g=null,N=null,A=null;function D(){if(N){try{N.close()}catch{}N=null}if(g){try{g.dispose()}catch{}g=null}A=null,b.innerHTML=""}function R(f,C){if(D(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${C||f}`,x?.classList.add("show"),typeof Terminal>"u"){b.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}g=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"&&(A=new FitAddon.FitAddon,g.loadAddon(A)),g.open(b),A&&setTimeout(()=>A.fit(),50);const E=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${E}//${location.host}/ws/exec/${encodeURIComponent(f)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(g.writeln("\x1B[32mConnecting...\x1B[0m"),A){const w=A.proposeDimensions();w&&N.send(JSON.stringify({type:"resize",cols:w.cols,rows:w.rows}))}},N.onmessage=w=>{if(typeof w.data=="string"){try{const z=JSON.parse(w.data);if(z.type==="connected"){g.writeln(`\x1B[32mConnected (${z.shell})\x1B[0m\r
|
|
|
|
|
`);return}if(z.type==="error"){g.writeln(`\x1B[31mError: ${z.message}\x1B[0m`);return}if(z.type==="exit"){g.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}g.write(w.data)}else g.write(new Uint8Array(w.data))},N.onclose=()=>{g&&g.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{g&&g.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},g.onData(w=>{N&&N.readyState===WebSocket.OPEN&&N.send(w)}),g.onResize(({cols:w,rows:z})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:w,rows:z}))});const M=()=>{A&&A.fit()};window.addEventListener("resize",M),x._resizeHandler=M}j?.addEventListener("click",()=>{D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show")}),x?.addEventListener("click",f=>{f.target===x&&(D(),x._resizeHandler&&window.removeEventListener("resize",x._resizeHandler),x?.classList.remove("show"))}),window.openExecModal=R})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 950px; max-width: 1150px;">
|
|
|
|
|
<h3>\u{1F4DC} Audit Log</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1811,7 +1811,7 @@ Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification
|
|
|
|
|
<button id="audit-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),m=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),A=document.getElementById("audit-filter"),D=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),g=document.getElementById("audit-until"),L=document.getElementById("audit-log-container"),k=document.getElementById("audit-load-more");let M=0,I=null,$=0;const E=50;function T(z){if(!z)return null;const O=new Date(z);return isNaN(O.getTime())?null:O.toISOString()}async function P(z){try{z?(I&&I.abort(),I=new AbortController):(I&&I.abort(),I=new AbortController,M=0,$++,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const O=$,u=new URLSearchParams;u.set("limit",String(E)),u.set("offset",String(M));const y=A.value,C=D.value,w=T(R.value),S=T(g.value);y&&u.set("action",y),C&&u.set("outcome",C),w&&u.set("since",w),S&&u.set("until",S);const i=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:I.signal});if(!i.ok){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${i.status}</div>`,k.style.display="none";return}const p=await i.json();if(!p.success){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,k.style.display="none";return}if(!z&&O!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!z){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";L.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,k.style.display="none";return}let f="";z||(f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',f+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',f+='<th style="padding: 6px; text-align: left;">When</th>',f+='<th style="padding: 6px; text-align: left;">Actor</th>',f+='<th style="padding: 6px; text-align: left;">IP</th>',f+='<th style="padding: 6px; text-align: left;">Action</th>',f+='<th style="padding: 6px; text-align: left;">Resource</th>',f+='<th style="padding: 6px; text-align: left;">Result</th>',f+="</tr>");for(const c of l){const n=c.outcome==="success",t=v(c);f+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',f+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,f+=`<td style="padding: 6px; font-size: 0.78rem;">${t}</td>`,f+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,f+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,f+=`<td style="padding: 6px;"><span style="color: ${n?"var(--ok-fg)":"var(--bad-fg)"};">${n?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,f+="</tr>",c.details&&Object.keys(c.details).length>0&&(f+=`<tr class="audit-detail" style="display: none;"><td colspan="6" 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(c.details,null,2))}</pre></td></tr>`)}if(!z)f+="</table>",L.innerHTML=f;else{const c=L.querySelector("table");c&&c.insertAdjacentHTML("beforeend",f)}M+=l.length,k.style.display=p.hasMore?"":"none",L.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const n=c.nextElementSibling;n&&n.classList.contains("audit-detail")&&(n.style.display=n.style.display==="none"?"":"none")}))})}catch(O){if(O&&O.name==="AbortError")return;L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(O.message)}</div>`}}function v(z){const O=z.details||{},u=O.userEmail,y=O.userId,C=O.userRole,w=O.viaProvider;if(u){const S=C?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(C)}${w?"/"+escapeHtml(w):""}]</span>`:"";return`${escapeHtml(u)}${S}`}return y?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(y)}</span>`:z.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}h?.addEventListener("click",()=>{b?.classList.add("show"),P(!1)}),wireModal(b,j),m?.addEventListener("click",()=>P(!1)),A?.addEventListener("change",()=>P(!1)),D?.addEventListener("change",()=>P(!1));let B;[R,g].forEach(z=>{z?.addEventListener("change",()=>{clearTimeout(B),B=setTimeout(()=>P(!1),250)})}),k?.addEventListener("click",()=>P(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const O=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
|
|
|
|
|
</div>`);const x=document.getElementById("audit-modal"),b=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),g=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),A=document.getElementById("audit-filter"),D=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),f=document.getElementById("audit-until"),C=document.getElementById("audit-log-container"),E=document.getElementById("audit-load-more");let M=0,w=null,z=0;const S=50;function T(H){if(!H)return null;const O=new Date(H);return isNaN(O.getTime())?null:O.toISOString()}async function P(H){try{H?(w&&w.abort(),w=new AbortController):(w&&w.abort(),w=new AbortController,M=0,z++,C.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const O=z,u=new URLSearchParams;u.set("limit",String(S)),u.set("offset",String(M));const m=A.value,L=D.value,k=T(R.value),I=T(f.value);m&&u.set("action",m),L&&u.set("outcome",L),k&&u.set("since",k),I&&u.set("until",I);const i=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:w.signal});if(!i.ok){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${i.status}</div>`,E.style.display="none";return}const c=await i.json();if(!c.success){C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.error||"unknown")}</div>`,E.style.display="none";return}if(!H&&O!==z)return;const d=Array.isArray(c.entries)?c.entries:[];if(d.length===0&&!H){const p=c.filters&&(c.filters.action||c.filters.outcome||c.filters.since||c.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";C.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,E.style.display="none";return}let y="";H||(y='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',y+='<th style="padding: 6px; text-align: left;">When</th>',y+='<th style="padding: 6px; text-align: left;">Actor</th>',y+='<th style="padding: 6px; text-align: left;">IP</th>',y+='<th style="padding: 6px; text-align: left;">Action</th>',y+='<th style="padding: 6px; text-align: left;">Resource</th>',y+='<th style="padding: 6px; text-align: left;">Result</th>',y+="</tr>");for(const p of d){const n=p.outcome==="success",e=v(p);y+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',y+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,y+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,y+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,y+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${n?"var(--ok-fg)":"var(--bad-fg)"};">${n?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,y+="</tr>",p.details&&Object.keys(p.details).length>0&&(y+=`<tr class="audit-detail" style="display: none;"><td colspan="6" 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(p.details,null,2))}</pre></td></tr>`)}if(!H)y+="</table>",C.innerHTML=y;else{const p=C.querySelector("table");p&&p.insertAdjacentHTML("beforeend",y)}M+=d.length,E.style.display=c.hasMore?"":"none",C.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const n=p.nextElementSibling;n&&n.classList.contains("audit-detail")&&(n.style.display=n.style.display==="none"?"":"none")}))})}catch(O){if(O&&O.name==="AbortError")return;C.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(O.message)}</div>`}}function v(H){const O=H.details||{},u=O.userEmail,m=O.userId,L=O.userRole,k=O.viaProvider;if(u){const I=L?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(L)}${k?"/"+escapeHtml(k):""}]</span>`:"";return`${escapeHtml(u)}${I}`}return m?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(m)}</span>`:H.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}b?.addEventListener("click",()=>{x?.classList.add("show"),P(!1)}),wireModal(x,j),g?.addEventListener("click",()=>P(!1)),A?.addEventListener("change",()=>P(!1)),D?.addEventListener("change",()=>P(!1));let B;[R,f].forEach(H=>{H?.addEventListener("change",()=>{clearTimeout(B),B=setTimeout(()=>P(!1),250)})}),E?.addEventListener("click",()=>P(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const O=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 980px; max-width: 1280px;">
|
|
|
|
|
<h3>\u{1F6E1}\uFE0F Security Center</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -1890,39 +1890,39 @@ Enter version to rollback to:`);if(!r)return;if(!t.includes(r)){showNotification
|
|
|
|
|
<button id="sec-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("security-modal"),h=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),m=b.querySelectorAll(".sec-tab"),N=b.querySelectorAll(".sec-panel");let A=[],D=[],R=null;m.forEach(i=>{i.addEventListener("click",()=>{m.forEach(p=>p.classList.toggle("active",p===i)),N.forEach(p=>p.style.display=p.dataset.panel===i.dataset.tab?"":"none"),i.dataset.tab==="overview"&&M(),i.dataset.tab==="events"&&B(),i.dataset.tab==="hosts"&&y()})}),h&&h.addEventListener("click",()=>{b.classList.add("show"),M(),L()}),j.addEventListener("click",g),b.addEventListener("click",i=>{i.target===b&&g()});function g(){b.classList.remove("show"),k()}function L(){if(k(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",i=>{try{A=JSON.parse(i.data).events||[],z()}catch{}}),R.addEventListener("security",i=>{try{const p=JSON.parse(i.data);A.unshift(p),A.length>500&&(A.length=500);const l=b.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?z():l==="overview"&&M()}catch{}}),R.onerror=()=>{}}catch(i){console.warn("[security] SSE failed:",i.message)}}function k(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&L()});async function M(){try{const i=new Date(Date.now()-864e5).toISOString(),[p,l,f]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(i)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(i)}`)]),c=(await p.json()).data||{},n=(await l.json()).data?.hosts||[],t=(await f.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${t} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${n.length} hosts`,I("sec-top-actors",c.top_actors||[]),I("sec-top-targets",c.top_targets||[])}catch(i){console.warn("[security] refreshOverview failed:",i.message)}}function I(i,p){const l=document.getElementById(i);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(f=>`<tr><td style="padding:3px 0;word-break:break-all;">${w(String(f.key))}</td><td style="text-align:right;color:var(--muted);">${f.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),E=document.getElementById("sec-filter-severity"),T=document.getElementById("sec-filter-host"),P=document.getElementById("sec-filter-actor"),v=document.getElementById("sec-refresh-btn");[$,E,T].forEach(i=>i.addEventListener("change",B)),P.addEventListener("input",S(B,250)),v.addEventListener("click",B);async function B(){try{const i=new URLSearchParams;i.set("limit","200"),$.value&&i.set("source_type",$.value),E.value&&i.set("severity",E.value),T.value&&i.set("source_host",T.value),P.value&&i.set("actor_prefix",P.value),A=(await(await fetch(`/api/v1/security/events?${i}`)).json()).data.events||[],z(),(!T.options.length||T.options.length===1)&&await u()}catch(i){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+w(i.message)+"</div>"}}function z(){const i=document.getElementById("sec-events-container");if(!A.length){i.innerHTML='<div class="panel-empty">No events</div>';return}i.innerHTML=A.slice(0,200).map(O).join("")}function O(i){const p=i.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",f=i.ts?new Date(i.ts).toLocaleTimeString():"",c=i.source_type||"",n=i.actor||"\u2014",t=i.target||"",r=i.action||"",e=i.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
|
|
|
|
<span style="color:${l};font-weight:600;">${w(p)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${w(c)}</span>
|
|
|
|
|
<span title="${w(n)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${w(n)}</span>
|
|
|
|
|
<span title="${w(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${w(r)} ${w(t)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${w(e)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${w(f)}</span>
|
|
|
|
|
</div>`}async function u(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=T.value;T.innerHTML='<option value="">All hosts</option>'+p.map(f=>`<option value="${w(f.id)}">${w(f.label||f.id)}</option>`).join(""),l&&(T.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",C),document.getElementById("sec-hosts-refresh").addEventListener("click",y);async function y(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];D=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(f=>{const c=f.enabled?f.last_seen_at?Date.now()-Date.parse(f.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
|
|
|
|
</div>`);const x=document.getElementById("security-modal"),b=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),g=x.querySelectorAll(".sec-tab"),N=x.querySelectorAll(".sec-panel");let A=[],D=[],R=null;g.forEach(i=>{i.addEventListener("click",()=>{g.forEach(c=>c.classList.toggle("active",c===i)),N.forEach(c=>c.style.display=c.dataset.panel===i.dataset.tab?"":"none"),i.dataset.tab==="overview"&&M(),i.dataset.tab==="events"&&B(),i.dataset.tab==="hosts"&&m()})}),b&&b.addEventListener("click",()=>{x.classList.add("show"),M(),C()}),j.addEventListener("click",f),x.addEventListener("click",i=>{i.target===x&&f()});function f(){x.classList.remove("show"),E()}function C(){if(E(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",i=>{try{A=JSON.parse(i.data).events||[],H()}catch{}}),R.addEventListener("security",i=>{try{const c=JSON.parse(i.data);A.unshift(c),A.length>500&&(A.length=500);const d=x.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?H():d==="overview"&&M()}catch{}}),R.onerror=()=>{}}catch(i){console.warn("[security] SSE failed:",i.message)}}function E(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{x.classList.contains("show")&&C()});async function M(){try{const i=new Date(Date.now()-864e5).toISOString(),[c,d,y]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(i)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(i)}`)]),p=(await c.json()).data||{},n=(await d.json()).data?.hosts||[],e=(await y.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${p.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${p.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${p.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${n.length} hosts`,w("sec-top-actors",p.top_actors||[]),w("sec-top-targets",p.top_targets||[])}catch(i){console.warn("[security] refreshOverview failed:",i.message)}}function w(i,c){const d=document.getElementById(i);if(!c.length){d.innerHTML='<div class="panel-empty">No data</div>';return}d.innerHTML='<table style="width:100%;font-size:0.85rem;">'+c.map(y=>`<tr><td style="padding:3px 0;word-break:break-all;">${k(String(y.key))}</td><td style="text-align:right;color:var(--muted);">${y.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),S=document.getElementById("sec-filter-severity"),T=document.getElementById("sec-filter-host"),P=document.getElementById("sec-filter-actor"),v=document.getElementById("sec-refresh-btn");[z,S,T].forEach(i=>i.addEventListener("change",B)),P.addEventListener("input",I(B,250)),v.addEventListener("click",B);async function B(){try{const i=new URLSearchParams;i.set("limit","200"),z.value&&i.set("source_type",z.value),S.value&&i.set("severity",S.value),T.value&&i.set("source_host",T.value),P.value&&i.set("actor_prefix",P.value),A=(await(await fetch(`/api/v1/security/events?${i}`)).json()).data.events||[],H(),(!T.options.length||T.options.length===1)&&await u()}catch(i){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+k(i.message)+"</div>"}}function H(){const i=document.getElementById("sec-events-container");if(!A.length){i.innerHTML='<div class="panel-empty">No events</div>';return}i.innerHTML=A.slice(0,200).map(O).join("")}function O(i){const c=i.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",y=i.ts?new Date(i.ts).toLocaleTimeString():"",p=i.source_type||"",n=i.actor||"\u2014",e=i.target||"",r=i.action||"",s=i.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
|
|
|
|
<span style="color:${d};font-weight:600;">${k(c)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${k(p)}</span>
|
|
|
|
|
<span title="${k(n)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${k(n)}</span>
|
|
|
|
|
<span title="${k(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${k(r)} ${k(e)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${k(s)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${k(y)}</span>
|
|
|
|
|
</div>`}async function u(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=T.value;T.innerHTML='<option value="">All hosts</option>'+c.map(y=>`<option value="${k(y.id)}">${k(y.label||y.id)}</option>`).join(""),d&&(T.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",L),document.getElementById("sec-hosts-refresh").addEventListener("click",m);async function m(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];D=c;const d=document.getElementById("sec-hosts-container");if(!c.length){d.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}d.innerHTML=c.map(y=>{const p=y.enabled?y.last_seen_at?Date.now()-Date.parse(y.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
|
|
|
|
<div>
|
|
|
|
|
<strong>${w(f.label||f.id)}</strong>
|
|
|
|
|
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${w(f.type)}</span>
|
|
|
|
|
<strong>${k(y.label||y.id)}</strong>
|
|
|
|
|
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${k(y.type)}</span>
|
|
|
|
|
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
|
|
|
|
id: ${w(f.id)} \xB7
|
|
|
|
|
registered ${new Date(f.registered_at).toLocaleDateString()} \xB7
|
|
|
|
|
last seen ${f.last_seen_at?new Date(f.last_seen_at).toLocaleString():"never"}
|
|
|
|
|
id: ${k(y.id)} \xB7
|
|
|
|
|
registered ${new Date(y.registered_at).toLocaleDateString()} \xB7
|
|
|
|
|
last seen ${y.last_seen_at?new Date(y.last_seen_at).toLocaleString():"never"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span style="font-size:0.85rem;margin-right:12px;">${c}</span>
|
|
|
|
|
${f.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${w(f.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
|
|
|
|
<span style="font-size:0.85rem;margin-right:12px;">${p}</span>
|
|
|
|
|
${y.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${k(y.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}).join(""),l.querySelectorAll(".sec-host-del").forEach(f=>{f.addEventListener("click",async()=>{confirm(`Remove host ${f.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(f.dataset.id)}`,{method:"DELETE"}),y())})})}catch(i){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+w(i.message)+"</div>"}}async function C(){const i=prompt("Host id (lowercase, no spaces):");if(!i)return;const p=prompt("Display label:",i)||i,l=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const f=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:i,label:p,type:l})}),c=await f.json();if(!f.ok){alert("Failed: "+(c?.error?.message||f.statusText));return}alert(`\u2705 Host registered!
|
|
|
|
|
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(y=>{y.addEventListener("click",async()=>{confirm(`Remove host ${y.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(y.dataset.id)}`,{method:"DELETE"}),m())})})}catch(i){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+k(i.message)+"</div>"}}async function L(){const i=prompt("Host id (lowercase, no spaces):");if(!i)return;const c=prompt("Display label:",i)||i,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const y=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:i,label:c,type:d})}),p=await y.json();if(!y.ok){alert("Failed: "+(p?.error?.message||y.statusText));return}alert(`\u2705 Host registered!
|
|
|
|
|
|
|
|
|
|
id: ${c.data.host.id}
|
|
|
|
|
label: ${c.data.host.label}
|
|
|
|
|
type: ${c.data.host.type}
|
|
|
|
|
id: ${p.data.host.id}
|
|
|
|
|
label: ${p.data.host.label}
|
|
|
|
|
type: ${p.data.host.type}
|
|
|
|
|
|
|
|
|
|
\u{1F511} API KEY (save this NOW \u2014 won't be shown again):
|
|
|
|
|
|
|
|
|
|
${c.data.api_key}
|
|
|
|
|
${p.data.api_key}
|
|
|
|
|
|
|
|
|
|
Send this key as: Authorization: Bearer <api_key>
|
|
|
|
|
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),y()}catch(f){alert("Failed: "+f.message)}}function w(i){return String(i).replace(/[&<>"']/g,p=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[p])}function S(i,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>i.apply(this,arguments),p)}}})(),window.__dc133_buildRepoOptions=function(b,h){var j=h||function(m){return String(m).replace(/[&<>"']/g,function(N){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(b||[]).map(function(m){return'<option value="'+j(m.url)+'" data-id="'+j(m.id)+'">'+j(m.full_name)+(m.description?" \u2014 "+j(m.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(b,h){if(b)return"";var j=String(h||"").trim();return j||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
|
|
|
|
|
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),m()}catch(y){alert("Failed: "+y.message)}}function k(i){return String(i).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])}function I(i,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>i.apply(this,arguments),c)}}})(),window.__dc133_buildRepoOptions=function(x,b){var j=b||function(g){return String(g).replace(/[&<>"']/g,function(N){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(x||[]).map(function(g){return'<option value="'+j(g.url)+'" data-id="'+j(g.id)+'">'+j(g.full_name)+(g.description?" \u2014 "+j(g.description):"")+"</option>"}).join("")},window.__dc133_requestToken=function(x,b){if(x)return"";var j=String(b||"").trim();return j||void 0},(function(){injectModal("deploys-modal",`<div id="deploys-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 860px; max-width: 1200px;">
|
|
|
|
|
<h3>\u{1F69A} Deploys</h3>
|
|
|
|
|
<p class="modal-subtitle">
|
|
|
|
@@ -2006,16 +2006,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),y()}catch(f)
|
|
|
|
|
<button class="btn" id="dep-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=P=>document.getElementById(P);let h=null,j=!1;function m(P){return window.escapeHtml?window.escapeHtml(String(P)):String(P).replace(/[&<>"']/g,v=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[v])}async function N(P,v){const B=await fetch("/dashcaddy-api/api/v1/deploys"+P,Object.assign({credentials:"include"},v||{}));let z=null;try{z=await B.json()}catch{z={success:!1,error:"non-JSON response"}}return{status:B.status,body:z}}function A(P){const v=b("dep-output");v.style.display="block",v.textContent=P||"(no output)",v.scrollTop=v.scrollHeight}async function D(){const P=b("dep-services"),{body:v}=await N("/services");if(!v.success){P.innerHTML="<em>"+m(v.error||"unavailable")+"</em>";return}const B=v.services||[];if(!B.length){P.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}P.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+B.map(z=>"<tr><td><strong>"+m(z.name)+"</strong></td><td>"+m(z.last_action)+"</td><td>"+m(z.last_time)+"</td><td>"+m(z.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+m(z.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+m(z.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const P=b("dep-repo-select"),{body:v}=await N("/repos");if(!v.success){P.innerHTML="<option>"+m(v.error||"unavailable")+"</option>";return}const B=v.repos||[];P.innerHTML=B.length?B.map(z=>'<option value="'+m(z.dir)+'">'+m(z.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function g(P){const v=b("dep-journal"),B=P?"?service="+encodeURIComponent(P):"",{body:z}=await N("/journal"+B),O=z.rows||[];if(!O.length){v.innerHTML="<em>No journal rows.</em>";return}v.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+O.map(u=>"<tr><td>"+m(u.time)+"</td><td>"+m(u.service)+"</td><td>"+m(u.action)+"</td><td>"+m(u.epoch)+"</td><td>"+m(u.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function L(){const P=document.getElementById("dep-gh-gitea"),v=document.getElementById("dep-gh-gitea-host"),B=document.getElementById("dep-gh-gitea-token"),z=document.getElementById("dep-gh-anonymous"),O={};if(v&&v.value.trim()&&(O.gitea_url="https://"+v.value.trim().replace(/^https?:\/*/,"")),z&&z.checked)O.token="";else{const u=window.__dc133_requestToken(!1,B&&B.value);u!==void 0&&(O.token=u)}try{const{status:u,body:y}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(O)});if(!y.success||!(y.repos||[]).length){P.innerHTML='<option value="">'+m(y.error||"no repos found")+"</option>";return}P.innerHTML=window.__dc133_buildRepoOptions(y.repos,m)}catch{P.innerHTML='<option value="">unavailable</option>'}}async function k(){const P=document.getElementById("dep-gh-btn"),v=document.getElementById("dep-gh-url").value.trim(),B=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),z=document.getElementById("dep-gh-args").value.trim();if(!v||!B){A("Repo URL and name are required.");return}const O=z?z.split(/\s+/):[];M(!0,P,"Install"),A("Installing "+v+`
|
|
|
|
|
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const u=!!(document.getElementById("dep-gh-anonymous")||{}).checked,y=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,C=window.__dc133_requestToken(u,y),{status:w,body:S}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:v,service:B,args:O,token:C})});if(!S.success){A((S.output?S.output+`
|
|
|
|
|
`:"")+"FAIL "+(S.error||"HTTP "+w)),M(!1,P,"Install");return}const i=S.service||{};if(!(window.APPS||[]).some(l=>l.id===i.id)){const l={id:i.id,name:i.name||i.id,url:i.url,logo:i.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(l)}),window.APPS.push(l),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}A("INSTALLED "+(i.name||i.id)+" in "+(i.deploy_seconds||"?")+`s
|
|
|
|
|
</div>`);const x=P=>document.getElementById(P);let b=null,j=!1;function g(P){return window.escapeHtml?window.escapeHtml(String(P)):String(P).replace(/[&<>"']/g,v=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[v])}async function N(P,v){const B=await fetch("/dashcaddy-api/api/v1/deploys"+P,Object.assign({credentials:"include"},v||{}));let H=null;try{H=await B.json()}catch{H={success:!1,error:"non-JSON response"}}return{status:B.status,body:H}}function A(P){const v=x("dep-output");v.style.display="block",v.textContent=P||"(no output)",v.scrollTop=v.scrollHeight}async function D(){const P=x("dep-services"),{body:v}=await N("/services");if(!v.success){P.innerHTML="<em>"+g(v.error||"unavailable")+"</em>";return}const B=v.services||[];if(!B.length){P.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}P.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Service</th><th>Last action</th><th>When</th><th>Release</th><th></th></tr></thead><tbody>'+B.map(H=>"<tr><td><strong>"+g(H.name)+"</strong></td><td>"+g(H.last_action)+"</td><td>"+g(H.last_time)+"</td><td>"+g(H.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+g(H.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+g(H.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const P=x("dep-repo-select"),{body:v}=await N("/repos");if(!v.success){P.innerHTML="<option>"+g(v.error||"unavailable")+"</option>";return}const B=v.repos||[];P.innerHTML=B.length?B.map(H=>'<option value="'+g(H.dir)+'">'+g(H.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function f(P){const v=x("dep-journal"),B=P?"?service="+encodeURIComponent(P):"",{body:H}=await N("/journal"+B),O=H.rows||[];if(!O.length){v.innerHTML="<em>No journal rows.</em>";return}v.innerHTML='<table class="table" style="width:100%;"><thead><tr><th>Time</th><th>Service</th><th>Action</th><th>Release</th><th>Duration</th></tr></thead><tbody>'+O.map(u=>"<tr><td>"+g(u.time)+"</td><td>"+g(u.service)+"</td><td>"+g(u.action)+"</td><td>"+g(u.epoch)+"</td><td>"+g(u.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function C(){const P=document.getElementById("dep-gh-gitea"),v=document.getElementById("dep-gh-gitea-host"),B=document.getElementById("dep-gh-gitea-token"),H=document.getElementById("dep-gh-anonymous"),O={};if(v&&v.value.trim()&&(O.gitea_url="https://"+v.value.trim().replace(/^https?:\/*/,"")),H&&H.checked)O.token="";else{const u=window.__dc133_requestToken(!1,B&&B.value);u!==void 0&&(O.token=u)}try{const{status:u,body:m}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(O)});if(!m.success||!(m.repos||[]).length){P.innerHTML='<option value="">'+g(m.error||"no repos found")+"</option>";return}P.innerHTML=window.__dc133_buildRepoOptions(m.repos,g)}catch{P.innerHTML='<option value="">unavailable</option>'}}async function E(){const P=document.getElementById("dep-gh-btn"),v=document.getElementById("dep-gh-url").value.trim(),B=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),H=document.getElementById("dep-gh-args").value.trim();if(!v||!B){A("Repo URL and name are required.");return}const O=H?H.split(/\s+/):[];M(!0,P,"Install"),A("Installing "+v+`
|
|
|
|
|
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const u=!!(document.getElementById("dep-gh-anonymous")||{}).checked,m=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,L=window.__dc133_requestToken(u,m),{status:k,body:I}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:v,service:B,args:O,token:L})});if(!I.success){A((I.output?I.output+`
|
|
|
|
|
`:"")+"FAIL "+(I.error||"HTTP "+k)),M(!1,P,"Install");return}const i=I.service||{};if(!(window.APPS||[]).some(d=>d.id===i.id)){const d={id:i.id,name:i.name||i.id,url:i.url,logo:i.logo,tailscaleOnly:!0,isCustom:!0};try{await fetch("/dashcaddy-api/api/v1/services",{method:"POST",credentials:"include",headers:{"Content-Type":"application/json"},body:JSON.stringify(d)}),window.APPS.push(d),typeof window.renderApps=="function"&&window.renderApps(),typeof window.renderGrid=="function"&&window.renderGrid()}catch{}}A("INSTALLED "+(i.name||i.id)+" in "+(i.deploy_seconds||"?")+`s
|
|
|
|
|
Card: `+(i.url||"")+`
|
|
|
|
|
`+(S.output||"").slice(-800)),D()}catch(u){A("install error: "+u.message)}M(!1,P,"Install")}function M(P,v,B){v&&(v.disabled=P,B&&(v.textContent=P?"Working\u2026":B))}async function I(){const P=b("dep-deploy-btn"),v=b("dep-repo-select").value;if(v){M(!0,P,"Deploy"),A("Deploying "+v+`
|
|
|
|
|
`+(I.output||"").slice(-800)),D()}catch(u){A("install error: "+u.message)}M(!1,P,"Install")}function M(P,v,B){v&&(v.disabled=P,B&&(v.textContent=P?"Working\u2026":B))}async function w(){const P=x("dep-deploy-btn"),v=x("dep-repo-select").value;if(v){M(!0,P,"Deploy"),A("Deploying "+v+`
|
|
|
|
|
This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:v})});A((B.output||B.error||"")+(B.success?`
|
|
|
|
|
\u2705 SUCCESS`:`
|
|
|
|
|
\u274C FAILED`)),D()}catch(B){A("deploy error: "+B.message)}M(!1,P,"Deploy")}}async function $(P,v){if(confirm("Roll back "+P+" to the previous release?")){M(!0,v,"Rollback"),A("Rolling back "+P+"\u2026");try{const{body:B}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:P})});A((B.output||B.error||"")+(B.success?`
|
|
|
|
|
\u274C FAILED`)),D()}catch(B){A("deploy error: "+B.message)}M(!1,P,"Deploy")}}async function z(P,v){if(confirm("Roll back "+P+" to the previous release?")){M(!0,v,"Rollback"),A("Rolling back "+P+"\u2026");try{const{body:B}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:P})});A((B.output||B.error||"")+(B.success?`
|
|
|
|
|
\u2705 ROLLED BACK`:`
|
|
|
|
|
\u274C FAILED`)),D()}catch(B){A("rollback error: "+B.message)}M(!1,v,"Rollback")}}async function E(P,v){M(!0,v,"Status");try{const{body:B}=await N("/status?service="+encodeURIComponent(P));A(B.output||B.error||"(no output)")}catch(B){A("status error: "+B.message)}M(!1,v,"Status")}function T(){h=document.getElementById("deploys-btn"),h&&(h.addEventListener("click",async()=>{b("deploys-modal").classList.add("open"),j||(j=!0,D(),R(),g(""),L())}),b("dep-close").addEventListener("click",()=>{b("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(P=>{P.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(v=>v.classList.remove("active")),P.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(v=>{v.style.display=v.dataset.panel===P.dataset.tab?"block":"none"})})}),b("dep-deploy-btn").addEventListener("click",I),b("dep-gh-btn").addEventListener("click",k),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(P=>{const v=document.getElementById(P);v&&v.addEventListener("change",()=>L())}),document.getElementById("dep-gh-gitea").addEventListener("change",P=>{const v=P.target.value;if(v){document.getElementById("dep-gh-url").value=v;const B=P.target.selectedOptions[0],z=B&&B.dataset?B.dataset.id:"",O=document.getElementById("dep-gh-service");z&&!O.value&&(O.value=z)}}),b("dep-journal-btn").addEventListener("click",()=>g(b("dep-journal-name").value.trim())),b("dep-status-btn").addEventListener("click",()=>{const P=b("dep-status-name").value.trim();P&&E(P)}),b("dep-services").addEventListener("click",P=>{const v=P.target.closest(".dep-rollback");if(v)return $(v.dataset.service,v);const B=P.target.closest(".dep-status");if(B)return E(B.dataset.service,B)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",T):T()})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
\u274C FAILED`)),D()}catch(B){A("rollback error: "+B.message)}M(!1,v,"Rollback")}}async function S(P,v){M(!0,v,"Status");try{const{body:B}=await N("/status?service="+encodeURIComponent(P));A(B.output||B.error||"(no output)")}catch(B){A("status error: "+B.message)}M(!1,v,"Status")}function T(){b=document.getElementById("deploys-btn"),b&&(b.addEventListener("click",async()=>{x("deploys-modal").classList.add("open"),j||(j=!0,D(),R(),f(""),C())}),x("dep-close").addEventListener("click",()=>{x("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(P=>{P.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(v=>v.classList.remove("active")),P.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(v=>{v.style.display=v.dataset.panel===P.dataset.tab?"block":"none"})})}),x("dep-deploy-btn").addEventListener("click",w),x("dep-gh-btn").addEventListener("click",E),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(P=>{const v=document.getElementById(P);v&&v.addEventListener("change",()=>C())}),document.getElementById("dep-gh-gitea").addEventListener("change",P=>{const v=P.target.value;if(v){document.getElementById("dep-gh-url").value=v;const B=P.target.selectedOptions[0],H=B&&B.dataset?B.dataset.id:"",O=document.getElementById("dep-gh-service");H&&!O.value&&(O.value=H)}}),x("dep-journal-btn").addEventListener("click",()=>f(x("dep-journal-name").value.trim())),x("dep-status-btn").addEventListener("click",()=>{const P=x("dep-status-name").value.trim();P&&S(P)}),x("dep-services").addEventListener("click",P=>{const v=P.target.closest(".dep-rollback");if(v)return z(v.dataset.service,v);const B=P.target.closest(".dep-status");if(B)return S(B.dataset.service,B)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",T):T()})(),(function(){const x=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>
|
|
|
|
@@ -2026,23 +2026,23 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
<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 h="weather-location",j="weather-zip",m="weather-geo",N="weather-unit";!safeGet(h)&&safeGet(j)&&safeSet(h,safeGet(j));function A(){return safeGet(N)||"imperial"}function D(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const R={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"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function k(v){return L[Math.round(v/22.5)%16]}async function M(v){const B=safeGet(m);if(B)try{const C=JSON.parse(B);if(C.query===v)return C}catch{}const z=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(v)}&count=1&language=en&format=json`);if(!z.ok)throw new Error("Geocoding failed");const O=await z.json();if(!O.results||!O.results.length)throw new Error("Location not found");const u=O.results[0],y={query:v,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(m,JSON.stringify(y)),y}function I(v){return v.countryCode==="US"&&v.state?`${v.city}, ${v.state}`:v.country?`${v.city}, ${v.country}`:v.city}async function $(v){try{const B=await M(v),z=A(),O=z==="metric"?"celsius":"fahrenheit",u=z==="metric"?"kmh":"mph",y=`https://api.open-meteo.com/v1/forecast?latitude=${B.lat}&longitude=${B.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${u}`,C=await fetch(y);if(!C.ok)throw new Error("Weather fetch failed");const S=(await C.json()).current,i=S.weather_code;return{temp:Math.round(S.temperature_2m),condition:R[i]||"Unknown",icon:g[i]||"\u{1F324}\uFE0F",locationStr:I(B),windSpeed:Math.round(S.wind_speed_10m),windDir:k(S.wind_direction_10m),unit:z}}catch(B){return console.warn("Weather fetch failed:",B),null}}async function E(){const v=D();if(!v.icon||!v.temp||!v.condition||!v.location||!v.wind){console.warn("Weather widget elements not found");return}const B=safeGet(h);if(!B){v.location.textContent="Set Location",v.temp.textContent="--\xB0",v.condition.textContent="Click \u2699\uFE0F to configure",v.wind.textContent="--",v.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const z=await $(B);if(z){const O=z.unit==="metric"?"\xB0C":"\xB0F",u=z.unit==="metric"?"km/h":"mph";v.location.textContent=z.locationStr,v.temp.textContent=`${z.temp}${O}`,v.condition.textContent=z.condition,v.wind.textContent=`Wind: ${z.windSpeed} ${u} ${z.windDir}`,v.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(z.icon)}</span>`}}catch(z){b.logError("[Weather] Update Error",z,{function:"updateWeather"}),v.location.textContent="Weather Error",v.temp.textContent="Error",v.condition.textContent="Failed to load",v.wind.textContent="--"}}const T=document.getElementById("weather-modal"),P=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{P.value=safeGet(h)||"";const v=A(),B=T.querySelector(`input[name="weather-unit-radio"][value="${v}"]`);B&&(B.checked=!0),T.classList.add("show"),P.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const v=P.value.trim();if(v){safeGet(h)!==v&&safeSet(m,""),safeSet(h,v);const z=T.querySelector('input[name="weather-unit-radio"]:checked'),O=z?z.value:"imperial",u=A();safeSet(N,O),u!==O&&safeSet(m,""),T.classList.remove("show"),E()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",v=>{v.keyLine truncated
|
|
|
|
|
<circle cx="${s}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${F}
|
|
|
|
|
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const b="weather-location",j="weather-zip",g="weather-geo",N="weather-unit";!safeGet(b)&&safeGet(j)&&safeSet(b,safeGet(j));function A(){return safeGet(N)||"imperial"}function D(){return{icon:document.querySelector(".weather-icon"),temp:document.querySelector(".weather-temp"),condition:document.querySelector(".weather-condition"),location:document.querySelector(".weather-location"),wind:document.querySelector(".weather-wind")}}const R={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"},f={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},C=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function E(v){return C[Math.round(v/22.5)%16]}async function M(v){const B=safeGet(g);if(B)try{const L=JSON.parse(B);if(L.query===v)return L}catch{}const H=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(v)}&count=1&language=en&format=json`);if(!H.ok)throw new Error("Geocoding failed");const O=await H.json();if(!O.results||!O.results.length)throw new Error("Location not found");const u=O.results[0],m={query:v,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(g,JSON.stringify(m)),m}function w(v){return v.countryCode==="US"&&v.state?`${v.city}, ${v.state}`:v.country?`${v.city}, ${v.country}`:v.city}async function z(v){try{const B=await M(v),H=A(),O=H==="metric"?"celsius":"fahrenheit",u=H==="metric"?"kmh":"mph",m=`https://api.open-meteo.com/v1/forecast?latitude=${B.lat}&longitude=${B.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${O}&wind_speed_unit=${u}`,L=await fetch(m);if(!L.ok)throw new Error("Weather fetch failed");const I=(await L.json()).current,i=I.weather_code;return{temp:Math.round(I.temperature_2m),condition:R[i]||"Unknown",icon:f[i]||"\u{1F324}\uFE0F",locationStr:w(B),windSpeed:Math.round(I.wind_speed_10m),windDir:E(I.wind_direction_10m),unit:H}}catch(B){return console.warn("Weather fetch failed:",B),null}}async function S(){const v=D();if(!v.icon||!v.temp||!v.condition||!v.location||!v.wind){console.warn("Weather widget elements not found");return}const B=safeGet(b);if(!B){v.location.textContent="Set Location",v.temp.textContent="--\xB0",v.condition.textContent="Click \u2699\uFE0F to configure",v.wind.textContent="--",v.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const H=await z(B);if(H){const O=H.unit==="metric"?"\xB0C":"\xB0F",u=H.unit==="metric"?"km/h":"mph";v.location.textContent=H.locationStr,v.temp.textContent=`${H.temp}${O}`,v.condition.textContent=H.condition,v.wind.textContent=`Wind: ${H.windSpeed} ${u} ${H.windDir}`,v.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(H.icon)}</span>`}}catch(H){x.logError("[Weather] Update Error",H,{function:"updateWeather"}),v.location.textContent="Weather Error",v.temp.textContent="Error",v.condition.textContent="Failed to load",v.wind.textContent="--"}}const T=document.getElementById("weather-modal"),P=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{P.value=safeGet(b)||"";const v=A(),B=T.querySelector(`input[name="weather-unit-radio"][value="${v}"]`);B&&(B.checked=!0),T.classList.add("show"),P.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{T.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const v=P.value.trim();if(v){safeGet(b)!==v&&safeSet(g,""),safeSet(b,v);const H=T.querySelector('input[name="weather-unit-radio"]:checked'),O=H?H.value:"imperial",u=A();safeSet(N,O),u!==O&&safeSet(g,""),T.classList.remove("show"),S()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",v=>{v.keyLine truncated
|
|
|
|
|
<circle cx="${o}" cy="${l}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${_}
|
|
|
|
|
<line x1="${s}" y1="${d}" x2="${s+28*Math.cos(U*Math.PI/180)}" y2="${d+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${s}" y1="${d}" x2="${s+38*Math.cos(H*Math.PI/180)}" y2="${d+38*Math.sin(H*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${s}" y1="${d}" x2="${s+42*Math.cos(x*Math.PI/180)}" y2="${d+42*Math.sin(x*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${s}" cy="${d}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,J=n.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(e).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(n)}</span></div></div>`,L="analog"}function y(){const n=new Date,t=n.getHours()%12||12,r=n.getMinutes(),e=n.getSeconds(),o="clock-widget"+(A!=="default"?" "+A:"");switch(b.className!==o&&(b.className=o),A){case"lcd":v(n);break;case"lcd-blue":v(n);break;case"lcd-amber":v(n);break;case"lcd-retro":v(n);break;case"lcd-taxi":v(n);break;case"flip":B(n);break;case"binary":O(n);break;case"analog":u(n,!1);break;case"roman":u(n,!0);break;default:P(n)}r===0&&e===0&&t!==D&&(D=t,I(t)),r!==0&&(D=-1)}function C(){clearTimeout(M);const n=document.hidden?6e4:1e3,t=n-Date.now()%n+25;M=setTimeout(()=>{y(),C()},t)}document.addEventListener("visibilitychange",()=>{g="",E(),y(),C()}),y(),C();const w=[{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 S='<div class="clock-style-grid">';w.forEach(n=>{S+=`<label class="clock-style-option">
|
|
|
|
|
${F}
|
|
|
|
|
<line x1="${o}" y1="${l}" x2="${o+28*Math.cos(U*Math.PI/180)}" y2="${l+28*Math.sin(U*Math.PI/180)}" stroke="var(--fg)" stroke-width="3" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${o}" y1="${l}" x2="${o+38*Math.cos($*Math.PI/180)}" y2="${l+38*Math.sin($*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${o}" y1="${l}" x2="${o+42*Math.cos(h*Math.PI/180)}" y2="${l+42*Math.sin(h*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${o}" cy="${l}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,q=n.getHours()>=12?"PM":"AM";b.innerHTML=`<div class="analog-clock-wrap">${J}<div class="analog-info"><span class="analog-digital">${n.getHours()%12||12}:${String(s).padStart(2,"0")} ${q}</span><span class="analog-date-sm">${z(n)}</span></div></div>`,C="analog"}function m(){const n=new Date,e=n.getHours()%12||12,r=n.getMinutes(),s=n.getSeconds(),a="clock-widget"+(A!=="default"?" "+A:"");switch(x.className!==a&&(x.className=a),A){case"lcd":v(n);break;case"lcd-blue":v(n);break;case"lcd-amber":v(n);break;case"lcd-retro":v(n);break;case"lcd-taxi":v(n);break;case"flip":B(n);break;case"binary":O(n);break;case"analog":u(n,!1);break;case"roman":u(n,!0);break;default:P(n)}r===0&&s===0&&e!==D&&(D=e,w(e)),r!==0&&(D=-1)}function L(){clearTimeout(M);const n=document.hidden?6e4:1e3,e=n-Date.now()%n+25;M=setTimeout(()=>{m(),L()},e)}document.addEventListener("visibilitychange",()=>{f="",S(),m(),L()}),m(),L();const k=[{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 I='<div class="clock-style-grid">';k.forEach(n=>{I+=`<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>`}),S+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
|
|
|
</label>`}),I+="</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>
|
|
|
|
|
${S}
|
|
|
|
|
${I}
|
|
|
|
|
</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);">
|
|
|
|
@@ -2066,9 +2066,9 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
<button id="clock-settings-save">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const i=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),f=document.getElementById("clock-volume-section");function c(){const n=safeGet("clock-style")||"default",t=i.querySelector(`input[value="${n}"]`);t&&(t.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",f.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{f.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),i.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(l.value,10)/100,t=new Audio("/assets/sounds/church-bell.mp3");t.volume=n,t.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const n=i.querySelector('input[name="clock-style-radio"]:checked'),t=n?n.value:"default";safeSet("clock-style",t),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),A=t,g="",E(),y(),C(),i.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{i.classList.remove("show")}),wireModal(i),i?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{A=n.value,g="",E(),y()})})})(),(function(){function b(m){const N=new Date(m.start).toISOString();return m.uid?`${m.uid}@${N}`:`${N}|${m.title||""}`}function h(){const m=new Map;return{size:()=>m.size,merge(N,A){if(A)for(const[D,R]of m){const g=new Date(R.start).getTime();g>=A.start.getTime()&&g<=A.end.getTime()&&m.delete(D)}for(const D of N&&Array.isArray(N.events)?N.events:[])m.set(b(D),D);return{events:[...m.values()].sort((D,R)=>new Date(D.start)-new Date(R.start))}}}}const j={eventKey:b,createCache:h};typeof module<"u"&&module.exports&&(module.exports=j),typeof window<"u"&&(window.CalendarCache=j)})(),(function(){const b=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
|
|
|
|
|
<h3>\u{1F4C5} Calendar</h3>
|
|
|
|
|
</div>`);const i=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),y=document.getElementById("clock-volume-section");function p(){const n=safeGet("clock-style")||"default",e=i.querySelector(`input[value="${n}"]`);e&&(e.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",y.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{y.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),i.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const n=parseInt(d.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=i.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",d.value),A=e,f="",S(),m(),L(),i.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{i.classList.remove("show")}),wireModal(i),i?.querySelectorAll('input[name="clock-style-radio"]').forEach(n=>{n.addEventListener("change",()=>{A=n.value,f="",S(),m()})})})(),(function(){function x(g){const N=new Date(g.start),A=isNaN(N.getTime())?`invalid:${g.start==null?"null":String(g.start)}`:N.toISOString();return g.uid?`${g.uid}@${A}`:`${A}|${g.title||""}`}function b(){const g=new Map;return{size:()=>g.size,merge(N,A){if(A)for(const[D,R]of g){const f=new Date(R.start).getTime();f>=A.start.getTime()&&f<=A.end.getTime()&&g.delete(D)}for(const D of N&&Array.isArray(N.events)?N.events:[])g.set(x(D),D);return{events:[...g.values()].sort((D,R)=>new Date(D.start)-new Date(R.start))}}}}const j={eventKey:x,createCache:b};typeof module<"u"&&module.exports&&(module.exports=j),typeof window<"u"&&(window.CalendarCache=j)})(),(function(){const x=new ErrorHandler;injectModal("calendar-modal",`<div id="calendar-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content cal-modal-content" role="dialog" aria-modal="true" aria-labelledby="calendar-modal-title">
|
|
|
|
|
<h3 id="calendar-modal-title">\u{1F4C5} Calendar</h3>
|
|
|
|
|
<div class="cal-monthbar">
|
|
|
|
|
<button id="cal-prev" class="cal-nav-btn" aria-label="Previous month">\u2039</button>
|
|
|
|
|
<span id="cal-month-label" class="cal-month-label"></span>
|
|
|
|
@@ -2118,18 +2118,18 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="weather-modal-buttons"><button id="calendar-close">Close</button></div>
|
|
|
|
|
</div></div>`);function h(e){return String(e??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const j=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function m(e,o){const a=new Date(e),s=new Date;s.setHours(0,0,0,0);const d=new Date(a);d.setHours(0,0,0,0);const x=Math.round((d-s)/864e5),H=x===0?"Today":x===1?"Tomorrow":a.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),U=o?"all-day":a.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${H} ${U}`}function N(e,o){return o?"all-day":new Date(e).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function A(e){return`${e.getFullYear()}-${String(e.getMonth()+1).padStart(2,"0")}-${String(e.getDate()).padStart(2,"0")}`}const D=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(e,o){return D?D.merge(e,o):e}let g=null,L=null;function k(e){const o=e||new Date,a=new Date(o.getFullYear(),o.getMonth()-1,1,0,0,0,0),s=new Date(o.getFullYear(),o.getMonth()+2,0,23,59,59,999);return{start:a,end:s}}function M(e){return g&&L&&g.getTime()<=e.start.getTime()&&L.getTime()>=e.end.getTime()}function I(e){if(!g){g=e.start,L=e.end;return}e.start<g&&(g=e.start),e.end>L&&(L=e.end)}let $=null,E={events:[]};async function T(e){const o=k($);if(!e&&M(o))return E;const a=await fetch(`/api/v1/calendar/events?start=${o.start.toISOString()}&end=${o.end.toISOString()}`,{cache:"no-store"});if(a.status===404)return{notConfigured:!0};if(!a.ok)throw new Error(`Calendar API HTTP ${a.status}`);const s=await a.json();return E=R(s,o),I(o),E}function P(e){const o=j();if(!o.icon||!o.text)return;const a=e&&Array.isArray(e.events)?e.events:[],s=Date.now(),d=a.filter(H=>(H.end?new Date(H.end).getTime():new Date(H.start).getTime())>=s).sort((H,U)=>new Date(H.start)-new Date(U.start));if(!d.length){o.icon.textContent="\u{1F4C5}",o.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const x=d[0];o.icon.textContent=x.allDay?"\u{1F5D3}\uFE0F":"\u23F0",o.text.innerHTML=`<span class="cal-chip-title">${h(x.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${h(m(x.start,x.allDay))}</span>`}function v(e){const o={};for(const a of e&&Array.isArray(e.events)?e.events:[]){const s=A(new Date(a.start));(o[s]=o[s]||[]).push(a)}return o}function B(e){const o=document.getElementById("cal-grid"),a=document.getElementById("cal-month-label");if(!o||!a)return;$||($=new Date,$.setDate(1));const s=$.getFullYear(),d=$.getMonth();a.textContent=$.toLocaleDateString(void 0,{month:"long",year:"numeric"});const x=v(e),H=A(new Date),U=new Date(s,d,1).getDay(),_=new Date(s,d+1,0).getDate();let F=["S","M","T","W","T","F","S"].map(q=>`<div class="cal-dow">${q}</div>`).join("");for(let q=0;q<U;q++)F+='<div class="cal-day cal-pad"></div>';for(let q=1;q<=_;q++){const J=`${s}-${String(d+1).padStart(2,"0")}-${String(q).padStart(2,"0")}`,V=x[J]||[],X=["cal-day"];J===H&&X.push("cal-today"),V.length&&X.push("cal-has-events");const ne=V.slice(0,3).map(Z=>`<span class="cal-dot" title="${h(N(Z.start,Z.allDay))}"></span>`).join(""),oe=V.map(Z=>`${N(Z.start,Z.allDay)} \u2014 ${Z.title||"Untitled"}`).join(`
|
|
|
|
|
`);F+=`<div class="${X.join(" ")}" title="${h(oe||J)}"><span class="cal-daynum">${q}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}o.innerHTML=F}function z(e){const o=document.getElementById("calendar-upcoming-list");if(!o)return;const a=e&&Array.isArray(e.events)?e.events:[];if(!a.length){o.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}o.innerHTML=a.slice(0,12).map(s=>`<div class="cal-row">
|
|
|
|
|
<span class="cal-when">${h(m(s.start,s.allDay))}</span>
|
|
|
|
|
<span class="cal-evtitle">${h(s.title||"Untitled")}</span>
|
|
|
|
|
${s.location?`<span class="cal-loc">${h(s.location)}</span>`:""}
|
|
|
|
|
</div>`).join("")}async function O(){const e=document.getElementById("calendar-sources-list");if(!e)return;let o=[];try{const a=await fetch("/api/v1/calendar/sources",{cache:"no-store"});if(a.ok){const s=await a.json();o=s&&Array.isArray(s.sources)?s.sources:Array.isArray(s)?s:[]}}catch{}if(!o.length){e.innerHTML='<div class="cal-empty">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';return}e.innerHTML=o.map(a=>`
|
|
|
|
|
</div></div>`);function b(a){return String(a??"").replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}const j=()=>({icon:document.querySelector(".calendar-widget-icon"),text:document.querySelector(".calendar-widget-text")});function g(a,t){const o=new Date(a),l=new Date;l.setHours(0,0,0,0);const h=new Date(o);h.setHours(0,0,0,0);const $=Math.round((h-l)/864e5),U=$===0?"Today":$===1?"Tomorrow":o.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),F=t?"all-day":o.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${U} ${F}`}function N(a,t){return t?"all-day":new Date(a).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function A(a){return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}-${String(a.getDate()).padStart(2,"0")}`}const D=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(a,t){return D?D.merge(a,t):a}let f=[];function C(a){const t=a||new Date,o=new Date(t.getFullYear(),t.getMonth()-1,1,0,0,0,0),l=new Date(t.getFullYear(),t.getMonth()+2,0,23,59,59,999);return{start:o,end:l}}function E(a){return f.some(t=>t.start.getTime()<=a.start.getTime()&&t.end.getTime()>=a.end.getTime())}function M(a){const t=f.filter(l=>l.end.getTime()>=a.start.getTime()&&l.start.getTime()<=a.end.getTime()),o=f.filter(l=>!t.includes(l));t.length&&(a={start:new Date(Math.min(a.start.getTime(),...t.map(l=>l.start.getTime()))),end:new Date(Math.max(a.end.getTime(),...t.map(l=>l.end.getTime())))}),f=o.concat([a]).sort((l,h)=>l.start-h.start)}let w=null,z={events:[]};async function S(a){const t=C(w);if(!a&&E(t))return z;const o=await secureFetch(`/api/v1/calendar/events?start=${t.start.toISOString()}&end=${t.end.toISOString()}`,{cache:"no-store"});if(o.status===404)return{notConfigured:!0};if(!o.ok)throw new Error(`Calendar API HTTP ${o.status}`);const l=await o.json();return z=R(l,t),M(t),z}function T(a){const t=j();if(!t.icon||!t.text)return;const o=B(a&&Array.isArray(a.events)?a.events:[]);if(!o.length){t.icon.textContent="\u{1F4C5}",t.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const l=o[0];t.icon.textContent=l.allDay?"\u{1F5D3}\uFE0F":"\u23F0",t.text.innerHTML=`<span class="cal-chip-title">${b(l.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${b(g(l.start,l.allDay))}</span>`}function P(a){const t={};for(const o of a&&Array.isArray(a.events)?a.events:[]){const l=A(new Date(o.start));(t[l]=t[l]||[]).push(o)}return t}function v(a){const t=document.getElementById("cal-grid"),o=document.getElementById("cal-month-label");if(!t||!o)return;w||(w=new Date,w.setDate(1));const l=w.getFullYear(),h=w.getMonth();o.textContent=w.toLocaleDateString(void 0,{month:"long",year:"numeric"});const $=P(a),U=A(new Date),F=new Date(l,h,1).getDay(),_=new Date(l,h+1,0).getDate();let J=["S","M","T","W","T","F","S"].map(q=>`<div class="cal-dow">${q}</div>`).join("");for(let q=0;q<F;q++)J+='<div class="cal-day cal-pad"></div>';for(let q=1;q<=_;q++){const K=`${l}-${String(h+1).padStart(2,"0")}-${String(q).padStart(2,"0")}`,Z=$[K]||[],ee=["cal-day"];K===U&&ee.push("cal-today"),Z.length&&ee.push("cal-has-events");const ne=Z.slice(0,3).map(V=>`<span class="cal-dot" title="${b(N(V.start,V.allDay))}"></span>`).join(""),oe=Z.map(V=>`${N(V.start,V.allDay)} \u2014 ${V.title||"Untitled"}`).join(`
|
|
|
|
|
`);J+=`<div class="${ee.join(" ")}" title="${b(oe||K)}"><span class="cal-daynum">${q}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}t.innerHTML=J}function B(a){const t=Date.now();return(Array.isArray(a)?a:[]).filter(o=>(o.end?new Date(o.end).getTime():new Date(o.start).getTime())>=t).sort((o,l)=>new Date(o.start)-new Date(l.start))}function H(a){const t=document.getElementById("calendar-upcoming-list");if(!t)return;const o=B(a&&Array.isArray(a.events)?a.events:[]);if(!o.length){t.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}t.innerHTML=o.slice(0,12).map(l=>`<div class="cal-row">
|
|
|
|
|
<span class="cal-when">${b(g(l.start,l.allDay))}</span>
|
|
|
|
|
<span class="cal-evtitle">${b(l.title||"Untitled")}</span>
|
|
|
|
|
${l.location?`<span class="cal-loc">${b(l.location)}</span>`:""}
|
|
|
|
|
</div>`).join("")}async function O(){const a=document.getElementById("calendar-sources-list");if(!a)return;let t=[];try{const o=await secureFetch("/api/v1/calendar/sources",{cache:"no-store"});if(o.ok){const l=await o.json();t=l&&Array.isArray(l.sources)?l.sources:Array.isArray(l)?l:[]}}catch{}if(!t.length){a.innerHTML='<div class="cal-empty">No calendars connected. Deploy Radicale from the App Selector to auto-connect one, or add a CalDAV/ICS URL below.</div>';return}a.innerHTML=t.map(o=>`
|
|
|
|
|
<div class="cal-row cal-src-row">
|
|
|
|
|
<span class="cal-evtitle">${h(a.name||a.id)}
|
|
|
|
|
<span class="cal-loc">\xB7 ${h(a.type)}${a.autoProvisioned?" \xB7 auto-wired":""}</span></span>
|
|
|
|
|
<button class="cal-src-toggle cal-btn" data-id="${h(a.id)}" data-enabled="${a.enabled!==!1?"1":"0"}">${a.enabled!==!1?"\u2713 on":"off"}</button>
|
|
|
|
|
<button class="cal-src-del cal-btn cal-btn-danger" data-id="${h(a.id)}">remove</button>
|
|
|
|
|
</div>`).join(""),e.querySelectorAll(".cal-src-toggle").forEach(a=>a.addEventListener("click",async()=>{const s=a.dataset.id,d=a.dataset.enabled!=="1";await fetch(`/api/v1/calendar/sources/${encodeURIComponent(s)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:d})}),O(),y()})),e.querySelectorAll(".cal-src-del").forEach(a=>a.addEventListener("click",async()=>{const s=a.dataset.id;await fetch(`/api/v1/calendar/sources/${encodeURIComponent(s)}`,{method:"DELETE"}),O(),y()}))}let u=null;async function y(){if(!y._inFlight){y._inFlight=!0;try{const e=await T(!1);u=e,P(e),document.getElementById("calendar-modal").style.display==="flex"&&(B(e),z(e))}catch(e){b.log(e,"calendar-widget");const o=j();o.text&&(o.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{y._inFlight=!1}}}function C(){document.getElementById("calendar-modal").style.display="flex",B(u||{events:[]}),z(u||{events:[]}),O(),y(),T(!1).then(e=>{e&&(B(e),z(e))}).catch(()=>{})}function w(){document.getElementById("calendar-modal").style.display="none"}function S(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function i(e){document.getElementById("cal-add-status").textContent=e}document.getElementById("cal-add-test").addEventListener("click",async()=>{const e=S();if(!e.url){i("Enter a URL first.");return}i("Testing\u2026");try{const a=await(await fetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:e.url,user:e.user,pass:e.pass})})).json(),s=a&&a.probe;s&&s.ok?i(`\u2713 Reachable (${s.server||"server"})${s.looksLikeCalendar?" \u2014 looks like a calendar":""}`):i(`\u2717 ${s&&s.error?s.error:"Not reachable"}`)}catch(o){i(`\u2717 ${o.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const e=S();if(!e.name||!e.url){i("Name and URL are required.");return}try{const o=await fetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:e.name,type:e.type,url:e.url,user:e.user,pass:e.pass})});if(!o.ok){const a=await o.json().catch(()=>({}));i(`\u2717 ${a.error||a.message||`HTTP ${o.status}`}`);return}i("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(a=>{document.getElementById(a).value=""}),O(),y()}catch(o){i(`\u2717 ${o.message}`)}});const p=document.getElementById("calendar-widget");p&&p.addEventListener("click",C);const l=document.getElementById("calendar-settings");l&&l.addEventListener("click",e=>{e.stopPropagation(),C()});const f=document.getElementById("calendar-close");f&&f.addEventListener("click",w);async function c(e){$||($=new Date,$.setDate(1)),$=new Date($.getFullYear(),$.getMonth()+e,1);const o=await T(!1).catch(()=>u);B(o||{events:[]})}const n=document.getElementById("cal-prev");n&&n.addEventListener("click",()=>{c(-1)});const t=document.getElementById("cal-next");t&&t.addEventListener("click",()=>{c(1)}),window.refreshCalendarWidget=y;const r=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(y,r),setTimeout(y,400)})(),(function(){const b=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
|
|
|
|
|
<span class="cal-evtitle">${b(o.name||o.id)}
|
|
|
|
|
<span class="cal-loc">\xB7 ${b(o.type)}${o.autoProvisioned?" \xB7 auto-wired":""}</span></span>
|
|
|
|
|
<button class="cal-src-toggle cal-btn" data-id="${b(o.id)}" data-enabled="${o.enabled!==!1?"1":"0"}">${o.enabled!==!1?"\u2713 on":"off"}</button>
|
|
|
|
|
<button class="cal-src-del cal-btn cal-btn-danger" data-id="${b(o.id)}">remove</button>
|
|
|
|
|
</div>`).join(""),a.querySelectorAll(".cal-src-toggle").forEach(o=>o.addEventListener("click",async()=>{const l=o.dataset.id,h=o.dataset.enabled!=="1";o.disabled=!0;try{const $=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(l)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:h})});if(!$.ok)throw new Error(`HTTP ${$.status}`);O(),m(!0)}catch($){showNotification(`Calendar source update failed: ${$.message}`,"error")}finally{o.disabled=!1}})),a.querySelectorAll(".cal-src-del").forEach(o=>o.addEventListener("click",async()=>{const l=o.dataset.id;o.disabled=!0;try{const h=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(l)}`,{method:"DELETE"});if(!h.ok)throw new Error(`HTTP ${h.status}`);O(),m(!0)}catch(h){showNotification(`Calendar source removal failed: ${h.message}`,"error")}finally{o.disabled=!1}}))}let u=null;async function m(a){if(m._inFlight){a&&(m._pendingForce=!0);return}m._inFlight=!0;try{let t=await S(!!a);if(a&&w){const o=w;w=null;const l=await S(!0);w=o;const h=await S(!0);T(l),v(h),H(l),u=l,m._inFlight=!1;return}if(w){const o=w;w=null;const l=await S(!1);w=o;const h=await S(!1);T(l),v(h),H(l),u=l,m._inFlight=!1;return}u=t,T(t),document.getElementById("calendar-modal").classList.contains("show")&&(v(t),H(t))}catch(t){x.logError("calendar-widget",t);const o=j();o.text&&(o.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{m._inFlight=!1,m._pendingForce&&(m._pendingForce=!1,m(!0))}}let L=null;function k(){L=document.activeElement,document.getElementById("calendar-modal").classList.add("show"),v(u||{events:[]}),H(u||{events:[]}),O(),m(),document.getElementById("calendar-close").focus()}function I(){document.getElementById("calendar-modal").classList.remove("show"),L&&typeof L.focus=="function"&&L.focus()}function i(){return{type:document.getElementById("cal-add-type").value,name:document.getElementById("cal-add-name").value.trim(),url:document.getElementById("cal-add-url").value.trim(),user:document.getElementById("cal-add-user").value.trim(),pass:document.getElementById("cal-add-pass").value}}function c(a){document.getElementById("cal-add-status").textContent=a}document.getElementById("cal-add-test").addEventListener("click",async()=>{const a=i();if(!a.url){c("Enter a URL first.");return}c("Testing\u2026");try{const o=await(await secureFetch("/api/v1/calendar/discover",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({url:a.url,user:a.user,pass:a.pass})})).json(),l=o&&o.probe;l&&l.ok?c(`\u2713 Reachable (${l.server||"server"})${l.looksLikeCalendar?" \u2014 looks like a calendar":""}`):c(`\u2717 ${l&&l.error?l.error:"Not reachable"}`)}catch(t){c(`\u2717 ${t.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const a=i();if(!a.name||!a.url){c("Name and URL are required.");return}try{const t=await secureFetch("/api/v1/calendar/sources",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:a.name,type:a.type,url:a.url,user:a.user,pass:a.pass})});if(!t.ok){const o=await t.json().catch(()=>({}));c(`\u2717 ${o.error||o.message||`HTTP ${t.status}`}`);return}c("Saved."),document.getElementById("cal-add-details").open=!1,["cal-add-name","cal-add-url","cal-add-user","cal-add-pass"].forEach(o=>{document.getElementById(o).value=""}),O(),m(!0)}catch(t){c(`\u2717 ${t.message}`)}});const d=document.getElementById("calendar-widget");d&&(d.addEventListener("click",k),d.addEventListener("keydown",a=>{(a.key==="Enter"||a.key===" "||a.key==="Spacebar")&&(a.preventDefault(),k())}));const y=document.getElementById("calendar-settings");y&&y.addEventListener("click",a=>{a.stopPropagation(),k()});const p=document.getElementById("calendar-modal");p&&(document.getElementById("calendar-close").addEventListener("click",I),p.addEventListener("click",a=>{a.target===p&&I()}),document.addEventListener("keydown",a=>{a.key==="Escape"&&p.classList.contains("show")&&I()}));async function n(a){w||(w=new Date,w.setDate(1)),w=new Date(w.getFullYear(),w.getMonth()+a,1);const t=await S(!1).catch(()=>u);v(t||{events:[]})}const e=document.getElementById("cal-prev");e&&e.addEventListener("click",()=>{n(-1)});const r=document.getElementById("cal-next");r&&r.addEventListener("click",()=>{n(1)}),window.refreshCalendarWidget=m;const s=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(()=>{document.hidden||m(!0)},s),document.addEventListener("visibilitychange",()=>{document.hidden||m(!0)}),setTimeout(m,400)})(),(function(){const x=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width:560px;max-width:720px;">
|
|
|
|
|
<h3>\u{1F916} Connect your AI assistant</h3>
|
|
|
|
|
<div id="aiconn-intro" style="font-size:0.85rem;color:var(--muted);margin-bottom:12px;">
|
|
|
|
@@ -2183,12 +2183,12 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
<span id="aiconn-status" style="flex:1;font-size:0.78rem;color:var(--muted);align-self:center;"></span>
|
|
|
|
|
<button id="aiconn-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div></div>`);const h=g=>document.getElementById(g);let j=null;function m(g){h("aiconn-status").textContent=g}async function N(){try{const g=await secureFetch("/api/v1/mcp/info");if(!g.ok)throw new Error(`HTTP ${g.status}`);const L=await g.json();j=L,h("aiconn-endpoint").style.display="block",h("aiconn-endpoint-url").textContent=L.endpoint,h("aiconn-tools").textContent=`${L.toolCount} tools \xB7 MCP ${L.protocolVersion} \xB7 DashCaddy v${L.serverVersion}`}catch(g){b.log(g,"ai-connect-info"),m("MCP endpoint unavailable on this build.")}}async function A(){const g=h("aiconn-connections");try{const k=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),M=k&&Array.isArray(k.connections)?k.connections:[];if(!M.length){g.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}g.innerHTML=M.map(I=>`
|
|
|
|
|
</div></div>`);const b=f=>document.getElementById(f);let j=null;function g(f){b("aiconn-status").textContent=f}async function N(){try{const f=await secureFetch("/api/v1/mcp/info");if(!f.ok)throw new Error(`HTTP ${f.status}`);const C=await f.json();j=C,b("aiconn-endpoint").style.display="block",b("aiconn-endpoint-url").textContent=C.endpoint,b("aiconn-tools").textContent=`${C.toolCount} tools \xB7 MCP ${C.protocolVersion} \xB7 DashCaddy v${C.serverVersion}`}catch(f){x.log(f,"ai-connect-info"),g("MCP endpoint unavailable on this build.")}}async function A(){const f=b("aiconn-connections");try{const E=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),M=E&&Array.isArray(E.connections)?E.connections:[];if(!M.length){f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}f.innerHTML=M.map(w=>`
|
|
|
|
|
<div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;">
|
|
|
|
|
<span style="flex:1;">${escapeHtml(I.name)}
|
|
|
|
|
<span style="color:var(--muted);">\xB7 ${(I.scopes||[]).join(",")} \xB7 since ${escapeHtml((I.createdAt||"").slice(0,10))}${I.lastUsed?" \xB7 last used "+escapeHtml(String(I.lastUsed).slice(0,10)):""}</span></span>
|
|
|
|
|
<button class="aiconn-revoke" data-id="${escapeHtml(I.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
|
|
|
|
|
</div>`).join(""),g.querySelectorAll(".aiconn-revoke").forEach(I=>I.addEventListener("click",async()=>{I.disabled=!0;try{const $=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(I.dataset.id)}`,{method:"DELETE"});if($.ok)m("Connection revoked."),A();else{const E=await $.json().catch(()=>({}));m(E.error||"Revoke failed."),I.disabled=!1}}catch($){m("Revoke failed: "+$.message),I.disabled=!1}}))}catch{g.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function D(g){m(`Creating ${g} connection\u2026`);try{const L=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:g})}),k=await L.json().catch(()=>({}));if(!L.ok||!k.key){m(k.error||`Failed (HTTP ${L.status})`);return}h("aiconn-key").textContent=k.key;const M=k.httpConfig?JSON.stringify(k.httpConfig,null,2):"";h("aiconn-snippet").textContent=M||"(see note below)",h("aiconn-client-note").textContent=k.note||"",h("aiconn-config-title").textContent=g==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",h("aiconn-step-pick").style.display="none",h("aiconn-step-config").style.display="block",m(""),A()}catch(L){b.log(L,"ai-connect-create"),m("Failed: "+L.message)}}function R(g,L){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(g).then(()=>m(L)).catch(()=>m("Copy failed \u2014 select manually.")):m("Clipboard unavailable \u2014 select manually.")}h("ai-connect-btn").addEventListener("click",()=>{h("ai-connect-modal").style.display="flex",h("aiconn-step-pick").style.display="block",h("aiconn-step-config").style.display="none",m(""),N(),A()}),h("aiconn-close").addEventListener("click",()=>{h("ai-connect-modal").style.display="none"}),h("aiconn-back").addEventListener("click",()=>{h("aiconn-step-config").style.display="none",h("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(g=>g.addEventListener("click",()=>D(g.dataset.client))),h("aiconn-copy-key").addEventListener("click",()=>R(h("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),h("aiconn-copy-snippet").addEventListener("click",()=>R(h("aiconn-snippet").textContent,"Config copied.")),h("aiconn-copy-endpoint").addEventListener("click",()=>R(j?j.endpoint:h("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function b(){try{const R=await(await fetch("/api/v1/health-checks/status")).json();if(!R.success||!R.status)return;for(const[g,L]of Object.entries(R.status)){const k=document.getElementById("uptime-"+g),M=document.getElementById("uptime-bar-"+g);if(!k)continue;const I=L.uptime?.["24h"];if(I!=null){const $=I.toFixed(1);k.textContent=`${$}% uptime`,k.className="uptime-chip",I>=99.9?k.classList.add("excellent"):I>=99?k.classList.add("good"):I>=95?k.classList.add("degraded"):k.classList.add("poor"),M&&(M.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let h;try{h=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{h=new Set}let j=[];async function m(D){try{const g=await(await fetch("/api/v1/updates/available")).json();if(!g.success)return;document.querySelectorAll(".update-available-badge").forEach(k=>k.classList.remove("visible"));const L=g.updates||[];if(j=L,D&&L.length>0){const k=window._lastKnownUpdateCount||0;k>0&&L.length>k&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const k of L){const M=window.APPS||[];for(const I of M)if(I.containerId===k.containerId||I.id===k.containerName||I.name===k.containerName){if(h.has(I.id))break;const $=document.getElementById("update-badge-"+I.id),E=document.getElementById("update-btn-"+I.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(I.id)}),E&&(E.style.background="#f97316",E.style.borderColor="#f97316",E.style.boxShadow="0 0 6px #f9731688",E.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{b(),m()},5e3),setInterval(()=>{b(),m(!0)},6e4)}const A=window.refreshAll;A&&(window.refreshAll=async function(){try{await A(),setTimeout(b,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),N()})(),(function(){var b=null,h=null,j={},m={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"]Line truncated
|
|
|
|
|
<span style="flex:1;">${escapeHtml(w.name)}
|
|
|
|
|
<span style="color:var(--muted);">\xB7 ${(w.scopes||[]).join(",")} \xB7 since ${escapeHtml((w.createdAt||"").slice(0,10))}${w.lastUsed?" \xB7 last used "+escapeHtml(String(w.lastUsed).slice(0,10)):""}</span></span>
|
|
|
|
|
<button class="aiconn-revoke" data-id="${escapeHtml(w.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
|
|
|
|
|
</div>`).join(""),f.querySelectorAll(".aiconn-revoke").forEach(w=>w.addEventListener("click",async()=>{w.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(w.dataset.id)}`,{method:"DELETE"});if(z.ok)g("Connection revoked."),A();else{const S=await z.json().catch(()=>({}));g(S.error||"Revoke failed."),w.disabled=!1}}catch(z){g("Revoke failed: "+z.message),w.disabled=!1}}))}catch{f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function D(f){g(`Creating ${f} connection\u2026`);try{const C=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:f})}),E=await C.json().catch(()=>({}));if(!C.ok||!E.key){g(E.error||`Failed (HTTP ${C.status})`);return}b("aiconn-key").textContent=E.key;const M=E.httpConfig?JSON.stringify(E.httpConfig,null,2):"";b("aiconn-snippet").textContent=M||"(see note below)",b("aiconn-client-note").textContent=E.note||"",b("aiconn-config-title").textContent=f==="chatgpt"?"Access key (ChatGPT note below):":"Your access key (shown once):",b("aiconn-step-pick").style.display="none",b("aiconn-step-config").style.display="block",g(""),A()}catch(C){x.log(C,"ai-connect-create"),g("Failed: "+C.message)}}function R(f,C){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(f).then(()=>g(C)).catch(()=>g("Copy failed \u2014 select manually.")):g("Clipboard unavailable \u2014 select manually.")}b("ai-connect-btn").addEventListener("click",()=>{b("ai-connect-modal").style.display="flex",b("aiconn-step-pick").style.display="block",b("aiconn-step-config").style.display="none",g(""),N(),A()}),b("aiconn-close").addEventListener("click",()=>{b("ai-connect-modal").style.display="none"}),b("aiconn-back").addEventListener("click",()=>{b("aiconn-step-config").style.display="none",b("aiconn-step-pick").style.display="block"}),document.querySelectorAll(".aiconn-client-btn").forEach(f=>f.addEventListener("click",()=>D(f.dataset.client))),b("aiconn-copy-key").addEventListener("click",()=>R(b("aiconn-key").textContent,"Key copied \u2014 paste it into your client.")),b("aiconn-copy-snippet").addEventListener("click",()=>R(b("aiconn-snippet").textContent,"Config copied.")),b("aiconn-copy-endpoint").addEventListener("click",()=>R(j?j.endpoint:b("aiconn-endpoint-url").textContent,"Endpoint copied."))})(),(function(){async function x(){try{const R=await(await fetch("/api/v1/health-checks/status")).json();if(!R.success||!R.status)return;for(const[f,C]of Object.entries(R.status)){const E=document.getElementById("uptime-"+f),M=document.getElementById("uptime-bar-"+f);if(!E)continue;const w=C.uptime?.["24h"];if(w!=null){const z=w.toFixed(1);E.textContent=`${z}% uptime`,E.className="uptime-chip",w>=99.9?E.classList.add("excellent"):w>=99?E.classList.add("good"):w>=95?E.classList.add("degraded"):E.classList.add("poor"),M&&(M.style.width=z+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let b;try{b=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{b=new Set}let j=[];async function g(D){try{const f=await(await fetch("/api/v1/updates/available")).json();if(!f.success)return;document.querySelectorAll(".update-available-badge").forEach(E=>E.classList.remove("visible"));const C=f.updates||[];if(j=C,D&&C.length>0){const E=window._lastKnownUpdateCount||0;E>0&&C.length>E&&showNotification(`${C.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=C.length}if(!C.length)return;for(const E of C){const M=window.APPS||[];for(const w of M)if(w.containerId===E.containerId||w.id===E.containerName||w.name===E.containerName){if(b.has(w.id))break;const z=document.getElementById("update-badge-"+w.id),S=document.getElementById("update-btn-"+w.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(w.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(()=>{x(),g()},5e3),setInterval(()=>{x(),g(!0)},6e4)}const A=window.refreshAll;A&&(window.refreshAll=async function(){try{await A(),setTimeout(x,1e3)}catch(D){console.warn("[Card Badges] Error in refreshAll hook:",D.message)}}),N()})(),(function(){var x=null,b=null,j={},g={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"]Line truncated
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
|
|
|
|
<h3>DashCaddy License</h3>
|
|
|
|
|
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
|
|
|
@@ -2225,16 +2225,16 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
<button id="license-activate" class="btn-accent">Activate</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("license-modal"),h=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),m=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),A=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),g=document.getElementById("license-badge"),L=document.getElementById("license-details"),k=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let I=null;function $(){N.style.display="none",A.style.display="none"}function E(C){$(),N.textContent=C,N.style.display="block"}function T(C){$(),A.textContent=C,A.style.display="block"}function P(C){if(I=C,C.active){g.style.background="rgba(46,204,113,0.15)",g.style.color="var(--ok-fg)",D.textContent="\u2605",R.textContent="Premium Active";const i=C.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(C.expiresAt).toLocaleDateString()}</strong> (${C.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${C.code||"***"}</code></div>
|
|
|
|
|
</div>`);const x=document.getElementById("license-modal"),b=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),g=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),A=document.getElementById("license-success"),D=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),f=document.getElementById("license-badge"),C=document.getElementById("license-details"),E=document.getElementById("license-feature-list"),M=document.getElementById("license-activate-section");let w=null;function z(){N.style.display="none",A.style.display="none"}function S(L){z(),N.textContent=L,N.style.display="block"}function T(L){z(),A.textContent=L,A.style.display="block"}function P(L){if(w=L,L.active){f.style.background="rgba(46,204,113,0.15)",f.style.color="var(--ok-fg)",D.textContent="\u2605",R.textContent="Premium Active";const i=L.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(L.expiresAt).toLocaleDateString()}</strong> (${L.daysRemaining} days remaining)</div>`;C.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${L.code||"***"}</code></div>
|
|
|
|
|
${i}
|
|
|
|
|
`,M.style.display="none",j.style.display="none",m.style.display=""}else g.style.background="rgba(149,165,166,0.15)",g.style.color="var(--muted)",D.textContent="\u2606",R.textContent=C.expired?"License Expired":"Free Tier",L.innerHTML=C.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="",j.style.display="",m.style.display="none";const w=C.premiumFeatures||{},S=new Set(C.features||[]);k.innerHTML=Object.entries(w).map(([i,p])=>`<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;">${S.has(i)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
`,M.style.display="none",j.style.display="none",g.style.display=""}else f.style.background="rgba(149,165,166,0.15)",f.style.color="var(--muted)",D.textContent="\u2606",R.textContent=L.expired?"License Expired":"Free Tier",C.innerHTML=L.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="",j.style.display="",g.style.display="none";const k=L.premiumFeatures||{},I=new Set(L.features||[]);E.innerHTML=Object.entries(k).map(([i,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;">${I.has(i)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${p.description}</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 v(){try{const w=await(await fetch("/api/v1/license/status")).json();w.success&&(P(w.license),O(w.license))}catch(C){console.warn("Failed to load license status:",C.message)}}async function B(){const C=h.value.trim();if(!C){E("Please enter a license code.");return}$(),j.disabled=!0,j.textContent="Activating...";try{const S=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:C})})).json();S.success?(T(S.message),h.value="",P(S.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(S.license)):E(S.error||"Activation failed")}catch(w){E("Network error: "+w.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function z(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){m.disabled=!0,m.textContent="Deactivating...";try{const w=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();w.success?(T(w.message),await v(),showNotification("License deactivated.","info",3e3),O({active:!1})):E(w.error||"Deactivation failed")}catch(C){E("Network error: "+C.message)}finally{m.disabled=!1,m.textContent="Deactivate"}}}function O(C){const w=document.getElementById("license-status-topbar"),S=document.getElementById("license-topbar-icon"),i=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(w)if(w.className="license-status-topbar "+(C.active?"premium":"free"),C.active)if(S.textContent="\u2605",i.textContent="PREMIUM",C.lifetime)p.textContent="\xB7 LIFETIME";else{const l=C.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else S.textContent="\u2606",i.textContent=C.expired?"EXPIRED":"FREE TIER",p.textContent=""}function u(){$(),v(),b.classList.add("show")}h.addEventListener("input",function(){let C=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(C.length>this._prevLength&&(C=C.replace(/-/g,""),C.length>2&&!C.startsWith("DC")&&(C="DC"+C),C.startsWith("DC")&&C.length>2)){const w=["DC"],S=C.substring(2);for(let i=0;i<S.length;i+=5)w.push(S.substring(i,i+5));C=w.join("-")}this._prevLength=C.length,this.value=C}),j.addEventListener("click",B),m.addEventListener("click",z),h.addEventListener("keydown",C=>{C.key==="Enter"&&B()}),wireModal(b,document.getElementById("license-cancel"));const y=document.getElementById("license-status-topbar");y&&y.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(C){try{return(await(await fetch(`/api/v1/license/feature/${C}`)).json()).available}catch{return!1}},v().then(C=>{I&&O(I)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const b=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
|
|
|
|
</div>`).join("")}async function v(){try{const k=await(await fetch("/api/v1/license/status")).json();k.success&&(P(k.license),O(k.license))}catch(L){console.warn("Failed to load license status:",L.message)}}async function B(){const L=b.value.trim();if(!L){S("Please enter a license code.");return}z(),j.disabled=!0,j.textContent="Activating...";try{const I=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:L})})).json();I.success?(T(I.message),b.value="",P(I.license),showNotification("License activated! Premium features unlocked.","success",5e3),O(I.license)):S(I.error||"Activation failed")}catch(k){S("Network error: "+k.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function H(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){g.disabled=!0,g.textContent="Deactivating...";try{const k=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();k.success?(T(k.message),await v(),showNotification("License deactivated.","info",3e3),O({active:!1})):S(k.error||"Deactivation failed")}catch(L){S("Network error: "+L.message)}finally{g.disabled=!1,g.textContent="Deactivate"}}}function O(L){const k=document.getElementById("license-status-topbar"),I=document.getElementById("license-topbar-icon"),i=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(k)if(k.className="license-status-topbar "+(L.active?"premium":"free"),L.active)if(I.textContent="\u2605",i.textContent="PREMIUM",L.lifetime)c.textContent="\xB7 LIFETIME";else{const d=L.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else I.textContent="\u2606",i.textContent=L.expired?"EXPIRED":"FREE TIER",c.textContent=""}function u(){z(),v(),x.classList.add("show")}b.addEventListener("input",function(){let L=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(L.length>this._prevLength&&(L=L.replace(/-/g,""),L.length>2&&!L.startsWith("DC")&&(L="DC"+L),L.startsWith("DC")&&L.length>2)){const k=["DC"],I=L.substring(2);for(let i=0;i<I.length;i+=5)k.push(I.substring(i,i+5));L=k.join("-")}this._prevLength=L.length,this.value=L}),j.addEventListener("click",B),g.addEventListener("click",H),b.addEventListener("keydown",L=>{L.key==="Enter"&&B()}),wireModal(x,document.getElementById("license-cancel"));const m=document.getElementById("license-status-topbar");m&&m.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(L){try{return(await(await fetch(`/api/v1/license/feature/${L}`)).json()).available}catch{return!1}},v().then(L=>{w&&O(w)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const x=[{ms:3600*1e3,label:"1 hour"},{ms:1440*60*1e3,label:"24 hours"},{ms:10080*60*1e3,label:"7 days"}];injectModal("share-modal",`
|
|
|
|
|
<div id="share-modal" class="weather-modal" role="dialog" aria-labelledby="share-modal-title">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 460px; max-width: 580px;">
|
|
|
|
|
<h3 id="share-modal-title">Share <span id="share-modal-service-name">\u2026</span></h3>
|
|
|
|
@@ -2256,7 +2256,7 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
<div class="share-tab-panel" data-panel="public">
|
|
|
|
|
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
|
|
|
|
|
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
|
|
|
|
|
${b.map(n=>`<option value="${n.ms}">${n.label}</option>`).join("")}
|
|
|
|
|
${x.map(n=>`<option value="${n.ms}">${n.label}</option>`).join("")}
|
|
|
|
|
</select>
|
|
|
|
|
<button id="share-public-create" class="btn-accent" type="button"
|
|
|
|
|
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
|
|
|
@@ -2303,17 +2303,17 @@ This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST"
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`);const h=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),m=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),A=document.getElementById("share-issued-copy"),D=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),g=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),k=document.getElementById("share-cancel"),M=document.getElementById("share-public-create"),I=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),E=document.getElementById("share-public-ttl");let T=null,P="public";function v(){R.style.display="none",g.style.display="none",m.style.display="none"}function B(n){g.style.display="none",m.style.display="none",R.textContent=n,R.style.display="block"}function z(n){R.style.display="none",g.textContent=n,g.style.display="block"}function O(){return window.location.origin}function u(n){return n.startsWith("http")?n:O()+n}function y(n){if(!n)return"";const t=new Date(n);return Number.isNaN(t.getTime())?"":t.toLocaleString()}function C(n){if(n<=0)return"expired";const t=Math.floor(n/36e5);if(t>=24)return`${Math.floor(t/24)}d ${t%24}h left`;const r=Math.floor(n%36e5/6e4);return`${t}h ${r}m left`}function w(n){P=n,v(),h.querySelectorAll(".share-tab").forEach(t=>{const r=t.dataset.tab===n;t.classList.toggle("active",r),t.style.borderBottomColor=r?"var(--accent)":"transparent",t.style.color=r?"var(--fg)":"var(--muted)",t.style.fontWeight=r?"600":"400"}),h.querySelectorAll(".share-tab-panel").forEach(t=>{t.style.display=t.dataset.panel===n?"":"none"})}async function S(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const n=await fetch("/api/v1/share",{credentials:"same-origin"});if(!n.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const t=await n.json(),e=(t&&t.data||[]).filter(o=>o.serviceId===T.id);if(e.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=e.map(o=>{const a=o.expiresAt?C(new Date(o.expiresAt).getTime()-Date.now()):"";return`
|
|
|
|
|
<div class="share-row" data-share-id="${escapeHtml(o.id)}"
|
|
|
|
|
`);const b=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),g=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),A=document.getElementById("share-issued-copy"),D=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),f=document.getElementById("share-success"),C=document.getElementById("share-outstanding-list"),E=document.getElementById("share-cancel"),M=document.getElementById("share-public-create"),w=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),S=document.getElementById("share-public-ttl");let T=null,P="public";function v(){R.style.display="none",f.style.display="none",g.style.display="none"}function B(n){f.style.display="none",g.style.display="none",R.textContent=n,R.style.display="block"}function H(n){R.style.display="none",f.textContent=n,f.style.display="block"}function O(){return window.location.origin}function u(n){return n.startsWith("http")?n:O()+n}function m(n){if(!n)return"";const e=new Date(n);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function L(n){if(n<=0)return"expired";const e=Math.floor(n/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const r=Math.floor(n%36e5/6e4);return`${e}h ${r}m left`}function k(n){P=n,v(),b.querySelectorAll(".share-tab").forEach(e=>{const r=e.dataset.tab===n;e.classList.toggle("active",r),e.style.borderBottomColor=r?"var(--accent)":"transparent",e.style.color=r?"var(--fg)":"var(--muted)",e.style.fontWeight=r?"600":"400"}),b.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===n?"":"none"})}async function I(){C.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const n=await fetch("/api/v1/share",{credentials:"same-origin"});if(!n.ok){C.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await n.json(),s=(e&&e.data||[]).filter(a=>a.serviceId===T.id);if(s.length===0){C.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}C.innerHTML=s.map(a=>{const t=a.expiresAt?L(new Date(a.expiresAt).getTime()-Date.now()):"";return`
|
|
|
|
|
<div class="share-row" data-share-id="${escapeHtml(a.id)}"
|
|
|
|
|
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
|
|
|
|
<span style="flex: 1;">
|
|
|
|
|
<strong>${o.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
|
|
|
|
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(y(o.expiresAt))} (${a})</span>
|
|
|
|
|
<strong>${a.kind==="tailscale"?"Tailscale":"Public"}</strong>
|
|
|
|
|
<span style="color: var(--muted);"> \xB7 expires ${escapeHtml(m(a.expiresAt))} (${t})</span>
|
|
|
|
|
</span>
|
|
|
|
|
<button class="share-revoke" data-share-id="${escapeHtml(o.id)}" type="button"
|
|
|
|
|
<button class="share-revoke" data-share-id="${escapeHtml(a.id)}" type="button"
|
|
|
|
|
style="padding: 4px 10px; background: transparent; color: var(--bad-fg); border: 1px solid var(--bad-fg); border-radius: 4px; cursor: pointer; font-size: 0.8rem;">
|
|
|
|
|
Revoke
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""),L.querySelectorAll(".share-revoke").forEach(o=>{o.addEventListener("click",()=>i(o.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function i(n){try{const t=await fetch(`/api/v1/share/${encodeURIComponent(n)}`,{method:"DELETE",credentials:"same-origin"});if(!t.ok){const r=await t.json().catch(()=>({}));B(r&&r.error||`Revoke failed (HTTP ${t.status}).`);return}z("Share revoked."),S()}catch{B("Network error while revoking.")}}async function p(){if(!T)return;v(),M.disabled=!0;const n=M.textContent;M.textContent="Creating\u2026";try{const t=parseInt(E.value,10),r=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,ttlMs:t})}),e=await r.json().catch(()=>({}));if(!r.ok||!e.success){f(r.status,e);return}const o=e.data.urlPath;N.value=u(o),D.textContent=`Public link \xB7 expires ${y(e.data.expiresAt)}`,m.style.display="block",z("Share link created."),S()}catch{B("Network error while creating share.")}finally{M.disabled=!1,M.textContent=n}}async function l(){if(!T)return;const n=($.value||"").trim();if(!n||!n.includes("@")){B("A valid recipient email is required.");return}v(),I.disabled=!0;const t=I.textContent;I.textContent="Creating\u2026";try{const r=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,email:n})}),e=await r.json().catch(()=>({}));if(!r.ok||!e.success){f(r.status,e);return}const o=e.data&&e.data.urlPath;o?(N.value=u(o),D.textContent=`Tailscale invite \xB7 emailed to ${n} (or use this URL manually if delivery failed).`):(N.value="",D.textContent=`Tailscale invite sent to ${n}.`),m.style.display="block",z("Tailscale invite created."),$.value="",S()}catch{B("Network error while creating Tailscale invite.")}finally{I.disabled=!1,I.textContent=t}}function f(n,t){const r=t&&t.error||"";if(n===402){B("Share is a Pro feature. Activate a license to unlock.");return}if(n===400&&/tailscale/i.test(r)){B("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(n===403){B("You do not have permission to share services.");return}if(n===404){B("Service not found. It may have been deleted.");return}B(r||`Request failed (HTTP ${n}).`)}function c(n){!n||!n.id||(T={id:n.id,name:n.name||n.id},j.textContent=T.name,v(),w("public"),$.value="",E.value=String(b[1].ms),h.classList.add("show"),S())}h.querySelectorAll(".share-tab").forEach(n=>{n.addEventListener("click",()=>w(n.dataset.tab))}),M.addEventListener("click",p),I.addEventListener("click",l),k.addEventListener("click",()=>h.classList.remove("show")),wireModal(h,k),A.addEventListener("click",async()=>{const n=N.value;if(n)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(n):(N.select(),document.execCommand("copy"));const t=A.textContent;A.textContent="Copied!",setTimeout(()=>{A.textContent=t},1200)}catch{B("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})();
|
|
|
|
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|
|
|
|
|
`}).join(""),C.querySelectorAll(".share-revoke").forEach(a=>{a.addEventListener("click",()=>i(a.dataset.shareId))})}catch{C.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function i(n){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(n)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const r=await e.json().catch(()=>({}));B(r&&r.error||`Revoke failed (HTTP ${e.status}).`);return}H("Share revoked."),I()}catch{B("Network error while revoking.")}}async function c(){if(!T)return;v(),M.disabled=!0;const n=M.textContent;M.textContent="Creating\u2026";try{const e=parseInt(S.value,10),r=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,ttlMs:e})}),s=await r.json().catch(()=>({}));if(!r.ok||!s.success){y(r.status,s);return}const a=s.data.urlPath;N.value=u(a),D.textContent=`Public link \xB7 expires ${m(s.data.expiresAt)}`,g.style.display="block",H("Share link created."),I()}catch{B("Network error while creating share.")}finally{M.disabled=!1,M.textContent=n}}async function d(){if(!T)return;const n=(z.value||"").trim();if(!n||!n.includes("@")){B("A valid recipient email is required.");return}v(),w.disabled=!0;const e=w.textContent;w.textContent="Creating\u2026";try{const r=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,email:n})}),s=await r.json().catch(()=>({}));if(!r.ok||!s.success){y(r.status,s);return}const a=s.data&&s.data.urlPath;a?(N.value=u(a),D.textContent=`Tailscale invite \xB7 emailed to ${n} (or use this URL manually if delivery failed).`):(N.value="",D.textContent=`Tailscale invite sent to ${n}.`),g.style.display="block",H("Tailscale invite created."),z.value="",I()}catch{B("Network error while creating Tailscale invite.")}finally{w.disabled=!1,w.textContent=e}}function y(n,e){const r=e&&e.error||"";if(n===402){B("Share is a Pro feature. Activate a license to unlock.");return}if(n===400&&/tailscale/i.test(r)){B("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(n===403){B("You do not have permission to share services.");return}if(n===404){B("Service not found. It may have been deleted.");return}B(r||`Request failed (HTTP ${n}).`)}function p(n){!n||!n.id||(T={id:n.id,name:n.name||n.id},j.textContent=T.name,v(),k("public"),z.value="",S.value=String(x[1].ms),b.classList.add("show"),I())}b.querySelectorAll(".share-tab").forEach(n=>{n.addEventListener("click",()=>k(n.dataset.tab))}),M.addEventListener("click",c),w.addEventListener("click",d),E.addEventListener("click",()=>b.classList.remove("show")),wireModal(b,E),A.addEventListener("click",async()=>{const n=N.value;if(n)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(n):(N.select(),document.execCommand("copy"));const e=A.textContent;A.textContent="Copied!",setTimeout(()=>{A.textContent=e},1200)}catch{B("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=p})();
|
|
|
|
|
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|