|
|
|
@@ -90,14 +90,14 @@
|
|
|
|
|
<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"),g=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),f=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),z=document.getElementById("dashboard-title"),k=DC.NAME;let C=null,D=null,y=null,T="left",$=k;N?.addEventListener("change",()=>{N.checked?(P.style.display="none",M.style.display="",C=null,D=null):(P.style.display="flex",M.style.display="none",y=null)});function F(e,i){if(!e||!e.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const l=new FileReader;l.onload=s=>i(s.target.result),l.readAsDataURL(e)}R?.addEventListener("change",e=>{F(e.target.files[0],i=>{C=i,h.src=i,g.textContent="New dark logo ready to save"})}),f?.addEventListener("change",e=>{F(e.target.files[0],i=>{D=i,j.src=i,g.textContent="New light logo ready to save"})}),L?.addEventListener("change",e=>{F(e.target.files[0],i=>{y=i,h.src=i,j.src=i,g.textContent="New logo ready to save (both themes)"})});function S(e){x.setAttribute("data-logo-pos",e),document.querySelectorAll(".logo-pos-btn").forEach(i=>{i.style.background=i.dataset.pos===e?"var(--accent)":"var(--card-bg)",i.style.color=i.dataset.pos===e?"white":"var(--fg)"})}function I(e){$=e||k,document.title=$;const i=document.querySelector(".dashboard-title");i&&(i.textContent=$)}async function B(){try{const e=await fetch("/api/v1/logo");if(e.ok){const i=await e.json();i.customLogoDark&&(w.src=i.customLogoDark,h.src=i.customLogoDark),i.customLogoLight&&(H.src=i.customLogoLight,j.src=i.customLogoLight),!i.customLogoDark&&!i.customLogoLight&&i.customLogo&&(w.src=i.customLogo,H.src=i.customLogo,h.src=i.customLogo,j.src=i.customLogo),i.isDefault||(g.textContent="Using custom logo"),i.position&&(T=i.position,S(i.position)),i.dashboardTitle&&I(i.dashboardTitle)}}catch(e){console.warn("Could not load custom logo:",e.message)}}document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.addEventListener("click",()=>{T=e.dataset.pos,S(T)})}),document.getElementById("brand")?.addEventListener("click",()=>{C=null,D=null,y=null,R&&(R.value=""),f&&(f.value=""),L&&(L.value=""),N&&(N.checked=!1),P.style.display="flex",M.style.display="none",h.src=w.src,j.src=H.src;const e=w.src.includes("custom-logo")||H.src.includes("custom-logo");g.textContent=e?"Using custom logo":"Using default logos",S(T),z.value=$,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const e=z.value.trim()||k,i={position:T,dashboardTitle:e};N?.checked&&y?(i.dataDark=y,i.dataLight=y):(C&&(i.dataDark=C),D&&(i.dataLight=D));const l=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(l.ok){const s=await l.json(),a="?t="+Date.now();s.pathDark&&(w.src=s.pathDark+a,h.src=s.pathDark+a),s.pathLight&&(H.src=s.pathLight+a,j.src=s.pathLight+a),S(T),I(e),b.classList.remove("show")}else{const s=await l.json();showNotification("Failed to save: "+s.error,"error")}}catch(e){showNotification("Error saving: "+e.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
|
</div>`);const b=document.getElementById("logo-modal"),h=document.getElementById("logo-preview-dark"),j=document.getElementById("logo-preview-light"),v=document.getElementById("logo-status"),N=document.getElementById("logo-same-both"),M=document.getElementById("logo-dual-uploads"),$=document.getElementById("logo-single-upload"),R=document.getElementById("logo-upload-dark"),y=document.getElementById("logo-upload-light"),L=document.getElementById("logo-upload-single"),w=document.querySelector("#brand .brand-logo-dark"),z=document.querySelector("#brand .brand-logo-light"),x=document.querySelector(".top-row"),C=document.getElementById("dashboard-title"),k=DC.NAME;let T=null,P=null,f=null,B="left",D=k;N?.addEventListener("change",()=>{N.checked?(M.style.display="none",$.style.display="",T=null,P=null):(M.style.display="flex",$.style.display="none",f=null)});function F(e,i){if(!e||!e.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const l=new FileReader;l.onload=s=>i(s.target.result),l.readAsDataURL(e)}R?.addEventListener("change",e=>{F(e.target.files[0],i=>{T=i,h.src=i,v.textContent="New dark logo ready to save"})}),y?.addEventListener("change",e=>{F(e.target.files[0],i=>{P=i,j.src=i,v.textContent="New light logo ready to save"})}),L?.addEventListener("change",e=>{F(e.target.files[0],i=>{f=i,h.src=i,j.src=i,v.textContent="New logo ready to save (both themes)"})});function S(e){x.setAttribute("data-logo-pos",e),document.querySelectorAll(".logo-pos-btn").forEach(i=>{i.style.background=i.dataset.pos===e?"var(--accent)":"var(--card-bg)",i.style.color=i.dataset.pos===e?"white":"var(--fg)"})}function I(e){D=e||k,document.title=D;const i=document.querySelector(".dashboard-title");i&&(i.textContent=D)}async function H(){try{const e=await fetch("/api/v1/logo");if(e.ok){const i=await e.json();i.customLogoDark&&(w.src=i.customLogoDark,h.src=i.customLogoDark),i.customLogoLight&&(z.src=i.customLogoLight,j.src=i.customLogoLight),!i.customLogoDark&&!i.customLogoLight&&i.customLogo&&(w.src=i.customLogo,z.src=i.customLogo,h.src=i.customLogo,j.src=i.customLogo),i.isDefault||(v.textContent="Using custom logo"),i.position&&(B=i.position,S(i.position)),i.dashboardTitle&&I(i.dashboardTitle)}}catch(e){console.warn("Could not load custom logo:",e.message)}}document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.addEventListener("click",()=>{B=e.dataset.pos,S(B)})}),document.getElementById("brand")?.addEventListener("click",()=>{T=null,P=null,f=null,R&&(R.value=""),y&&(y.value=""),L&&(L.value=""),N&&(N.checked=!1),M.style.display="flex",$.style.display="none",h.src=w.src,j.src=z.src;const e=w.src.includes("custom-logo")||z.src.includes("custom-logo");v.textContent=e?"Using custom logo":"Using default logos",S(B),C.value=D,b.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const e=C.value.trim()||k,i={position:B,dashboardTitle:e};N?.checked&&f?(i.dataDark=f,i.dataLight=f):(T&&(i.dataDark=T),P&&(i.dataLight=P));const l=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)});if(l.ok){const s=await l.json(),a="?t="+Date.now();s.pathDark&&(w.src=s.pathDark+a,h.src=s.pathDark+a),s.pathLight&&(z.src=s.pathLight+a,j.src=s.pathLight+a),S(B),I(e),b.classList.remove("show")}else{const s=await l.json();showNotification("Failed to save: "+s.error,"error")}}catch(e){showNotification("Error saving: "+e.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
|
|
|
|
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",g.textContent="Using default logos",C=null,D=null,y=null,z.value=k,I(k),T="left",S("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const l=document.querySelector('link[rel="icon"]'),s=document.getElementById("favicon-preview"),a=document.getElementById("favicon-status");l&&(l.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(e){showNotification("Error resetting branding: "+e.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const E=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),o=document.getElementById("favicon-upload");if(E){const e=()=>{E.src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>"};E.addEventListener("error",e),E.complete&&E.naturalWidth===0&&e()}const 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 v(){try{const e=await fetch("/api/v1/favicon");if(e.ok){const i=await e.json();i.customFavicon&&(c.href=i.customFavicon+"?t="+Date.now(),E.src=i.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(e){console.warn("Could not load custom favicon:",e.message)}}o?.addEventListener("change",e=>{const i=e.target.files[0];if(!i)return;if(!i.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),o.value="";return}const l=new FileReader;l.onload=s=>{d=s.target.result,E.src=d,u.textContent="New favicon ready to save"},l.readAsDataURL(i)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const e=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(e.ok){const i=await e.json();c.href=i.path+"?t="+Date.now(),E.src=i.path+"?t="+Date.now(),u.textContent="Using custom favicon",d=null}else{const i=await e.json();showNotification("Failed to save favicon: "+i.error,"error")}}catch(e){showNotification("Error saving favicon: "+e.message,"error")}}),v(),B();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{b.classList.contains("show")&&p.options.length===0&&(async()=>{let i;try{const l=await fetch("/api/v1/config");l.ok&&(i=(await l.json()).timezone)}catch{}window.populateTimezoneSelect(p,i)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const i=p.value;if(i)try{const l=await fetch("/api/v1/config");if(!l.ok)return;const s=await l.json();s.timezone=i,s.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}catch(l){console.warn("Failed to save timezone:",l.message)}}))})(),window.populateTimezoneSelect=function(b,h){const j=Intl.supportedValuesOf("timeZone"),g=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const N of j){const P=document.createElement("option");P.value=N,P.textContent=N.replace(/_/g," "),N===g&&(P.selected=!0),b.appendChild(P)}},(function(){let b="homelab",h=null;async function j(){try{const o=await fetch("/api/v1/config");if(o.ok&&(h=await o.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(o){console.warn("Could not fetch server config, checking localStorage fallback:",o.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(c=>{c.style.display="none"});const o=document.getElementById(u);o&&(o.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let o='<div style="display: grid; gap: 20px;">';if(b==="homelab"){const d=document.getElementById("setup-tld")?.value?.trim()||".home",v=document.getElementById("setup-ca-name")?.value?.trim()||"",p=document.getElementById("setup-dns-ip")?.value?.trim()||"",e=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;o+=`
|
|
|
|
|
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(w.src="/assets/dashcaddy-logo-dark.png",z.src="/assets/dashcaddy-logo-light.png",h.src="/assets/dashcaddy-logo-dark.png",j.src="/assets/dashcaddy-logo-light.png",v.textContent="Using default logos",T=null,P=null,f=null,C.value=k,I(k),B="left",S("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const l=document.querySelector('link[rel="icon"]'),s=document.getElementById("favicon-preview"),a=document.getElementById("favicon-status");l&&(l.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(e){showNotification("Error resetting branding: "+e.message,"error")}}),wireModal(b,document.getElementById("logo-cancel"));const E=document.getElementById("favicon-preview"),u=document.getElementById("favicon-status"),o=document.getElementById("favicon-upload");if(E){const e=()=>{E.src="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 32 32%22><rect fill=%22%23667%22 width=%2232%22 height=%2232%22 rx=%224%22/></svg>"};E.addEventListener("error",e),E.complete&&E.naturalWidth===0&&e()}const 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 g(){try{const e=await fetch("/api/v1/favicon");if(e.ok){const i=await e.json();i.customFavicon&&(c.href=i.customFavicon+"?t="+Date.now(),E.src=i.customFavicon+"?t="+Date.now(),u.textContent="Using custom favicon")}}catch(e){console.warn("Could not load custom favicon:",e.message)}}o?.addEventListener("change",e=>{const i=e.target.files[0];if(!i)return;if(!i.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),o.value="";return}const l=new FileReader;l.onload=s=>{d=s.target.result,E.src=d,u.textContent="New favicon ready to save"},l.readAsDataURL(i)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(d)try{const e=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:d})});if(e.ok){const i=await e.json();c.href=i.path+"?t="+Date.now(),E.src=i.path+"?t="+Date.now(),u.textContent="Using custom favicon",d=null}else{const i=await e.json();showNotification("Failed to save favicon: "+i.error,"error")}}catch(e){showNotification("Error saving favicon: "+e.message,"error")}}),g(),H();const p=document.getElementById("settings-timezone");p&&(new MutationObserver(()=>{b.classList.contains("show")&&p.options.length===0&&(async()=>{let i;try{const l=await fetch("/api/v1/config");l.ok&&(i=(await l.json()).timezone)}catch{}window.populateTimezoneSelect(p,i)})()}).observe(b,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const i=p.value;if(i)try{const l=await fetch("/api/v1/config");if(!l.ok)return;const s=await l.json();s.timezone=i,s.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(s)})}catch(l){console.warn("Failed to save timezone:",l.message)}}))})(),(function(b){"use strict";var h={powerdns:8081,rfc2136:53};function j(M){var $=String(M??"").trim();if(!/^\d{1,5}$/.test($))return null;var R=parseInt($,10);return R>=1&&R<=65535?R:null}function v(M,$,R,y,L){var w=j(R),z=h[M]||null,x=w??(z||L),C={provider:M,ip:String($??"").trim(),port:x,token:String(y??"").trim()};return M==="powerdns"?(C.server=C.ip,C.apiPort=C.port,C.dnsPort=53,C.apiToken=C.token):M==="rfc2136"&&(C.server=C.ip,C.port=C.port),C}var N={buildDnsConfig:v,PROVIDER_PORT_DEFAULTS:h};typeof module=="object"&&module.exports?module.exports=N:b.DnsProviderConfig=N})(typeof self<"u"?self:this),window.populateTimezoneSelect=function(b,h){const j=Intl.supportedValuesOf("timeZone"),v=h||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";b.innerHTML="";for(const N of j){const M=document.createElement("option");M.value=N,M.textContent=N.replace(/_/g," "),N===v&&(M.selected=!0),b.appendChild(M)}},(function(){let b="homelab",h=null;async function j(){try{const o=await fetch("/api/v1/config");if(o.ok&&(h=await o.json(),h&&h.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(o){console.warn("Could not fetch server config, checking localStorage fallback:",o.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}j();const v=document.getElementById("setup-timezone");v&&window.populateTimezoneSelect(v);function N(u){document.querySelectorAll(".setup-step").forEach(c=>{c.style.display="none"});const o=document.getElementById(u);o&&(o.st
Line truncated
|
|
|
|
|
<div>
|
|
|
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
|
|
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
|
|
|
<div><strong>TLD:</strong> ${d}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${v}</div>
|
|
|
|
|
<div><strong>Certificate Authority:</strong> ${g}</div>
|
|
|
|
|
<div><strong>DNS Server:</strong> ${p}:${e}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> https://uptime${d}, https://nextcloud${d}</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -112,13 +112,13 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
<div><strong>Example URLs:</strong> http://${d}:8080, http://${d}:3000</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}else if(b==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",v=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=p==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;o+=`
|
|
|
|
|
`}else if(b==="public"){const d=document.getElementById("setup-public-domain")?.value?.trim()||"",g=document.getElementById("setup-public-email")?.value?.trim()||"",p=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",e=p==="subdirectory"?`https://${d}/sonarr, https://${d}/grafana`:`https://sonarr.${d}, https://grafana.${d}`;o+=`
|
|
|
|
|
<div>
|
|
|
|
|
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
|
|
|
|
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
|
|
|
|
<div><strong>Domain:</strong> ${d}</div>
|
|
|
|
|
<div><strong>SSL:</strong> Let's Encrypt</div>
|
|
|
|
|
<div><strong>Email:</strong> ${v}</div>
|
|
|
|
|
<div><strong>Email:</strong> ${g}</div>
|
|
|
|
|
<div><strong>Routing:</strong> ${p==="subdirectory"?"Subdirectory (domain.com/app)":"Subdomain (app.domain.com)"}</div>
|
|
|
|
|
<div><strong>Example URLs:</strong> ${e}</div>
|
|
|
|
|
</div>
|
|
|
|
@@ -127,7 +127,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
<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>
|
|
|
|
|
`,o+="</div>",u.innerHTML=o,N("setup-step-summary")}async function M(u){try{const o=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return o.ok?(await o.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${o.status}`),{function:"saveConfigToServer"}),!1)}catch(o){return errorHandler.logError("[SetupWizard] Save Config",o,{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 v=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:v,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 o=await M(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",d=o?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const f=document.getElementById("setup-step-1-next");f&&(f.onclick=function(u){u.preventDefault();const o=document.querySelector('input[name="config-type"]:checked');o&&(b=o.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 M({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(u){const o=u.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=o),d&&(d.textContent=o)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),N("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(u){u.preventDefault();const o=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!o||!o.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}P()});const z=document.getElementById("setup-simple-back");z&&(z.onclick=function(u){u.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-simple-next");k&&(k.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var o=document.getElementById("dns-requirement-note");o&&(o.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const C=document.getElementById("setup-public-back");C&&(C.onclick=function(u){u.preventDefault(),N("setup-step-1")});const D=document.getElementById("setup-public-next");D&&(D.onclick=function(u){u.preventDefault();const o=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!o){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const y=document.getElementById("setup-summary-back");y&&(y.onclick=function(u){u.preventDefault(),b==="homelab"?N("setup-step-homela
Line truncated
|
|
|
|
|
`,o+="</div>",u.innerHTML=o,N("setup-step-summary")}async function $(u){try{const o=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return o.ok?(await o.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${o.status}`),{function:"saveConfigToServer"}),!1)}catch(o){return errorHandler.logError("[SetupWizard] Save Config",o,{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 g=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns=DnsProviderConfig.buildDnsConfig(g,document.getElementById("setup-dns-ip")?.value?.trim()||"",document.getElementById("setup-dns-port")?.value?.trim()||"",document.getElementById("setup-dns-token")?.value?.trim()||"",DC.DEFAULTS.DNS_PORT),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 o=await $(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",d=o?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${c}. Settings saved to: ${d}`,"success",5e3),setTimeout(()=>location.reload(),500)}const y=document.getElementById("setup-step-1-next");y&&(y.onclick=function(u){u.preventDefault();const o=document.querySelector('input[name="config-type"]:checked');o&&(b=o.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 $({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const w=document.getElementById("setup-tld");w&&(w.oninput=function(u){const o=u.target.value||".home",c=document.getElementById("tld-preview"),d=document.getElementById("tld-preview-2");c&&(c.textContent=o),d&&(d.textContent=o)});const z=document.getElementById("setup-homelab-back");z&&(z.onclick=function(u){u.preventDefault(),N("setup-step-1")});const x=document.getElementById("setup-homelab-next");x&&(x.onclick=function(u){u.preventDefault();const o=document.getElementById("setup-tld")?.value?.trim()||"",c=document.getElementById("setup-ca-name")?.value?.trim()||"",d=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!o||!o.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!c){showNotification("Please enter a Certificate Authority name","warning");return}if(!d){showNotification("Please enter your DNS server IP address","warning");return}M()});const C=document.getElementById("setup-simple-back");C&&(C.onclick=function(u){u.preventDefault(),N("setup-step-1")});const k=document.getElementById("setup-simple-next");k&&(k.onclick=function(u){u.preventDefault(),M()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var o=document.getElementById("dns-requirement-note");o&&(o.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 o=document.getElementById("setup-public-domain")?.value?.trim()||"",c=document.getElementById("setup-public-email")?.value?.trim()||"";if(!o){showNotification("Please enter your domain name","warning");return}if(!c||!c.includes("@")){showNotification("Please enter a valid email address","warning");return}M()});const f=document.getElementById("setup-summary-back");f&&(f.onclick=function(u){u.preventDefault(),b==="homelab"?N("setup
Line truncated
|
|
|
|
|
<div class="app-selector-content">
|
|
|
|
|
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
|
|
|
|
<div id="app-selector-grid" class="app-selector-grid"></div>
|
|
|
|
@@ -333,12 +333,12 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const h="custom-apps";let j=null,g=null;const N=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return j=c.templates,g=c.categories,!0}catch(o){b.logError("[AppSelector] Fetch Templates",o,{function:"fetchApiTemplates"})}return!1}async function R(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function f(o){try{const d=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return o}async function L(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await M()){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}P.innerHTML="";const o={};for(const[d,v]of Object.entries(j)){const p=v.category||"Other";o[p]||(o[p]=[]),o[p].push({id:d,...v})}const c=g?Object.keys(g):Object.keys(o).sort();for(const d of c){const v=o[d];if(!v||v.length===0)continue;v.sort((i,l)=>(l.popularity||0)-(i.popularity||0));const p=document.createElement("div");p.className="app-category-header";const e=g?.[d]||{};p.innerHTML=`${escapeHtml(e.icon||"")} ${escapeHtml(d)}`,e.color&&(p.style.borderBottomColor=e.color),P.appendChild(p),v.forEach(i=>{const l=document.createElement("div");l.className="app-option";const s=i.isDashboardWidget,a=s&&safeGet("widget-"+i.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>`:"",n=!s&&i.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(i.difficulty)}</div>`:"";l.innerHTML=`
|
|
|
|
|
</div>`);const h="custom-apps";let j=null,v=null;const N=document.getElementById("app-selector-modal"),M=document.getElementById("app-selector-grid");async function $(){try{const c=await(await fetch("/api/v1/apps/templates")).json();if(c.success)return j=c.templates,v=c.categories,!0}catch(o){b.logError("[AppSelector] Fetch Templates",o,{function:"fetchApiTemplates"})}return!1}async function R(o){try{return await(await fetch(`/api/v1/apps/ports/${o}/check`)).json()}catch(c){return b.logError("[AppSelector] Check Port",c,{function:"checkPortAvailability"}),{available:!0}}}async function y(o){try{const d=await(await fetch(`/api/v1/apps/ports/${o}/suggest`)).json();if(d.success)return d.suggestedPort}catch(c){b.logError("[AppSelector] Get Suggested Port",c,{function:"getSuggestedPort"})}return o}async function L(){if(M.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!j&&!await $()){M.innerHTML='<div style="text-align: center; padding: 40px; color: var(--error);">Failed to load app templates. Please try again.</div>';return}M.innerHTML="";const o={};for(const[d,g]of Object.entries(j)){const p=g.category||"Other";o[p]||(o[p]=[]),o[p].push({id:d,...g})}const c=v?Object.keys(v):Object.keys(o).sort();for(const d of c){const g=o[d];if(!g||g.length===0)continue;g.sort((i,l)=>(l.popularity||0)-(i.popularity||0));const p=document.createElement("div");p.className="app-category-header";const e=v?.[d]||{};p.innerHTML=`${escapeHtml(e.icon||"")} ${escapeHtml(d)}`,e.color&&(p.style.borderBottomColor=e.color),M.appendChild(p),g.forEach(i=>{const l=document.createElement("div");l.className="app-option";const s=i.isDashboardWidget,a=s&&safeGet("widget-"+i.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>`:"",n=!s&&i.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${i.difficulty==="Easy"?"#2ecc71":i.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(i.difficulty)}</div>`:"";l.innerHTML=`
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(i.icon||"\u{1F4E6}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(i.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(i.description||"")}</div>
|
|
|
|
|
${t}${n}
|
|
|
|
|
`,s?l.onclick=()=>w(i,l):l.onclick=()=>H(i),P.appendChild(l)})}window.renderRecipeCards&&await window.renderRecipeCards(P)}function w(o,c){const d="widget-"+o.id+"-enabled",p=!(safeGet(d)!=="false");safeSet(d,String(p));const e=o.widgetSelector;if(e){const l=document.querySelector(e);l&&(l.style.display=p?"":"none")}const i=c.querySelector('div[style*="border-radius: 4px"]');i&&(i.textContent=p?"ON":"OFF",i.style.background=p?"#2ecc7130":"#e74c3c30",i.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function H(o){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),v=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),e=document.getElementById("deploy-ip"),i=document.getElementById("deploy-port"),l=document.getElementById("deploy-tailscale-only"),s=document.getElementById("tailscale-status");try{const V=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(V.success&&V.exists){const Y=V.container;confirm(`Found existing ${o.name} container:
|
|
|
|
|
`,s?l.onclick=()=>w(i,l):l.onclick=()=>z(i),M.appendChild(l)})}window.renderRecipeCards&&await window.renderRecipeCards(M)}function w(o,c){const d="widget-"+o.id+"-enabled",p=!(safeGet(d)!=="false");safeSet(d,String(p));const e=o.widgetSelector;if(e){const l=document.querySelector(e);l&&(l.style.display=p?"":"none")}const i=c.querySelector('div[style*="border-radius: 4px"]');i&&(i.textContent=p?"ON":"OFF",i.style.background=p?"#2ecc7130":"#e74c3c30",i.style.color=p?"#2ecc71":"#e74c3c"),showNotification(`${o.name} widget ${p?"enabled":"disabled"}`,"success",2e3)}async function z(o){const c=document.getElementById("app-deploy-modal"),d=document.getElementById("app-deploy-title"),g=document.getElementById("deploy-subdomain"),p=document.getElementById("deploy-url-preview"),e=document.getElementById("deploy-ip"),i=document.getElementById("deploy-port"),l=document.getElementById("deploy-tailscale-only"),s=document.getElementById("tailscale-status");try{const V=await(await secureFetch("/api/v1/apps/check-existing",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({appId:o.id})})).json();if(V.success&&V.exists){const Y=V.container;confirm(`Found existing ${o.name} container:
|
|
|
|
|
|
|
|
|
|
Container: ${Y.name}
|
|
|
|
|
Status: ${Y.status}
|
|
|
|
@@ -347,38 +347,38 @@ 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.`)&&(o._useExisting=!0,o._existingContainer=Y)}}catch{}d.textContent=`Deploy ${o.name}`;const a=o.subdomain||o.id.replace(/-/g,"");v.value=a;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const W=o.subpathSupport||"strip";W==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+o.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);">ⓘ '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const n=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),r=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),m=document.querySelector(`input[name="dns-type"][value="${n}"]`),A=document.querySelector(`input[name="ssl-type"][value="${r}"]`);m?m.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,e.value=SITE.defaults.targetIP||"localhost",l.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),U=_?.querySelector("div");if(_&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,V=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1"),V&&!V.dataset.moved&&(U.appendChild(V),V.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),G=document.getElementById("media-path-description");if(o.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),V=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",V.innerHTML="";const ae=[...new Set(X.mounts.map(te=>te.hostPath))];J.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=J.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))"),J.value=le.join(", ")},V.appendChild(Q)})}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 Z=document.getElementById("plex-claim-section");Z&&(o.id==="plex"||o.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="",o.docker?.volumes?.length){const W=o.mediaMount?.containerPath,V=o.docker.volumes.filter(Y=>!Y.includes("{{MEDIA_PATH}}")&&!(W&&Y.endsWith(":"+W)));V.length>0?(ee.style.display="block",V.forEach((Y,X)=>{const[ae,te]=Y.split(":"),Q=document.createElement("div");Q.style.cssText="display: flex; gap: 6px; align-items: center;",Q.innerHTML=`
|
|
|
|
|
Click Cancel to deploy a new container.`)&&(o._useExisting=!0,o._existingContainer=Y)}}catch{}d.textContent=`Deploy ${o.name}`;const a=o.subdomain||o.id.replace(/-/g,"");g.value=a;const t=document.getElementById("subpath-compat-warning");if(t)if(SITE.routingMode==="subdirectory"){const W=o.subpathSupport||"strip";W==="none"?(t.style.display="block",t.innerHTML='<span style="color: #ff9800;">⚠ <strong>'+o.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);">ⓘ '+o.name+" has unverified subdirectory support. It may require additional configuration.</span>"):t.style.display="none"}else t.style.display="none";const n=SITE.defaults.dnsType||(SITE.configurationType==="public"?"public":"private"),r=SITE.defaults.sslType||(SITE.configurationType==="public"?"letsencrypt":"internal"),m=document.querySelector(`input[name="dns-type"][value="${n}"]`),A=document.querySelector(`input[name="ssl-type"][value="${r}"]`);m?m.checked=!0:document.querySelector('input[name="dns-type"][value="private"]').checked=!0,A?A.checked=!0:document.querySelector('input[name="ssl-type"][value="internal"]').checked=!0,e.value=SITE.defaults.targetIP||"localhost",l.checked=!1;const O=document.querySelector("#app-deploy-modal .flex-col-gap")?.closest("div"),_=document.querySelector("#app-deploy-modal details"),U=_?.querySelector("div");if(_&&U&&(SITE.configurationType==="public"||SITE.configurationType==="homelab")){const W=document.querySelectorAll('#app-deploy-modal input[name="dns-type"]')[0]?.closest("div.flex-col-gap")?.parentElement,V=document.querySelectorAll('#app-deploy-modal input[name="ssl-type"]')[0]?.closest("div.flex-col-gap")?.parentElement;W&&!W.dataset.moved&&(U.appendChild(W),W.dataset.moved="1"),V&&!V.dataset.moved&&(U.appendChild(V),V.dataset.moved="1")}const q=document.getElementById("media-path-section"),J=document.getElementById("deploy-media-path"),G=document.getElementById("media-path-description");if(o.mediaMount){q.style.display="block",J.value="",J.placeholder="/media/Movies, /media/TVShows or click Browse";const W=document.getElementById("detected-mounts-container"),V=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",V.innerHTML="";const ae=[...new Set(X.mounts.map(te=>te.hostPath))];J.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=J.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))"),J.value=le.join(", ")},V.appendChild(Q)})}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 Z=document.getElementById("plex-claim-section");Z&&(o.id==="plex"||o.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="",o.docker?.volumes?.length){const W=o.mediaMount?.containerPath,V=o.docker.volumes.filter(Y=>!Y.includes("{{MEDIA_PATH}}")&&!(W&&Y.endsWith(":"+W)));V.length>0?(ee.style.display="block",V.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>
|
|
|
|
|
`,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=o.defaultPort||8080;i.value="",i.placeholder=`Default: ${oe}`;let K=document.getElementById("deploy-port-status");K||(K=document.createElement("div"),K.id="deploy-port-status",K.style.cssText="font-size: 0.8rem; margin-top: 4px;",i.parentNode.appendChild(K));async function ie(){const W=i.value||oe;K.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const V=await R(W);if(V.available)K.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const Y=await f(oe);K.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=o.defaultPort||8080;i.value="",i.placeholder=`Default: ${oe}`;let K=document.getElementById("deploy-port-status");K||(K=document.createElement("div"),K.id="deploy-port-status",K.style.cssText="font-size: 0.8rem; margin-top: 4px;",i.parentNode.appendChild(K));async function ie(){const W=i.value||oe;K.innerHTML='<span style="color: var(--muted);">Checking port...</span>';const V=await R(W);if(V.available)K.innerHTML=`<span style="color: #4caf50;">Port ${escapeHtml(String(W))} is available</span>`;else{const Y=await y(oe);K.innerHTML=`
|
|
|
|
|
<span style="color: #e74c3c;">Port ${escapeHtml(W)} in use by ${escapeHtml(V.conflict?.usedBy||"unknown")}</span>
|
|
|
|
|
`;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,K.innerHTML=`<span style="color: #4caf50;">Using suggested port ${escapeHtml(String(Y))}</span>`},K.appendChild(X)}}let re;i.oninput=function(){clearTimeout(re),re=setTimeout(ie,500)},ie();try{const V=await(await fetch("/api/v1/tailscale/status")).json();V.success&&V.installed&&V.connected?s.innerHTML=`
|
|
|
|
|
<span style="color: #4caf50;">Connected</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">${V.self?.hostname} (${V.self?.ip})</span>
|
|
|
|
|
<span style="color: var(--muted); margin-left: 8px;">| ${V.deviceCount} devices</span>
|
|
|
|
|
`:V.installed?s.innerHTML='<span style="color: #ff9800;">Not connected</span>':(s.innerHTML='<span style="color: var(--muted);">Not available</span>',l.disabled=!0)}catch{s.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=v.value||"subdomain",V=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(V==="private")X=`${Y==="none"?"http":"https"}://${buildDomain(W)}`;else if(V==="public"){const ae=Y==="none"?"http":"https",te=SITE.domain||W;X=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=i.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;X=`http://${e.value}:${ae}`}p.textContent=X}v.oninput=se,e.oninput=se,i.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(o)}async function x(o){const c=o.appTemplate,d=safeGetJSON(h,[]),v=c._useExisting&&c._existingContainer,p=d.find(e=>e.id===o.subdomain);if(!(p&&!v&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const e=d.indexOf(p);d.splice(e,1),safeSet(h,JSON.stringify(d))}if(v)o.port=c._existingContainer.primaryPort;else{const e=o.port||c.defaultPort||8080;showNotification(`Checking port ${e} availability...`,"info",0);const i=await R(e);if(!i.available){const l=await f(c.defaultPort||8080);if(confirm(`Port ${e} is already in use by ${i.conflict?.usedBy||"another container"}.
|
|
|
|
|
`:V.installed?s.innerHTML='<span style="color: #ff9800;">Not connected</span>':(s.innerHTML='<span style="color: var(--muted);">Not available</span>',l.disabled=!0)}catch{s.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function se(){const W=g.value||"subdomain",V=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(V==="private")X=`${Y==="none"?"http":"https"}://${buildDomain(W)}`;else if(V==="public"){const ae=Y==="none"?"http":"https",te=SITE.domain||W;X=SITE.domain?`${ae}://${W}.${SITE.domain}`:`${ae}://${W}`}else{const ae=i.value||o.defaultPort||DC.DEFAULTS.SERVICE_PORT;X=`http://${e.value}:${ae}`}p.textContent=X}g.oninput=se,e.oninput=se,i.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(o)}async function x(o){const c=o.appTemplate,d=safeGetJSON(h,[]),g=c._useExisting&&c._existingContainer,p=d.find(e=>e.id===o.subdomain);if(!(p&&!g&&!confirm(`An app with subdomain "${o.subdomain}" already exists. Redeploy?`))){if(p){const e=d.indexOf(p);d.splice(e,1),safeSet(h,JSON.stringify(d))}if(g)o.port=c._existingContainer.primaryPort;else{const e=o.port||c.defaultPort||8080;showNotification(`Checking port ${e} availability...`,"info",0);const i=await R(e);if(!i.available){const l=await y(c.defaultPort||8080);if(confirm(`Port ${e} is already in use by ${i.conflict?.usedBy||"another container"}.
|
|
|
|
|
|
|
|
|
|
Would you like to use port ${l} instead?`))o.port=l;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(v?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const e={appId:c.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||c.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};v&&(e.config.useExisting=!0,e.config.existingContainerId=c._existingContainer.id,e.config.existingPort=c._existingContainer.primaryPort,!o.port&&c._existingContainer.primaryPort&&(e.config.port=c._existingContainer.primaryPort));const i={app:c.id,port:o.port||c.defaultPort||null,subdomain:o.subdomain||"",media_path:o.mediaPath||"",env:o.plexClaimToken?{PLEX_CLAIM:o.plexClaimToken}:{}},s=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)})).json();if(s.success){const a={id:o.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:null,url:s.url||`http://127.0.0.1:${i.port}`,ip:o.ip,appTemplate:c.id,tailscaleOnly:o.tailscaleOnly||!1};d.push(a),safeSet(h,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.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!
|
|
|
|
|
Would you like to use port ${l} instead?`))o.port=l;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(g?`Configuring ${c.name} with existing container...`:`Deploying ${c.name}...`,"info",0);try{const e={appId:c.id,config:{subdomain:o.subdomain,ip:o.ip,createDns:o.dnsType==="private",port:o.port||c.defaultPort||null,sslType:o.sslType,dnsType:o.dnsType,tailscaleOnly:o.tailscaleOnly||!1,mediaPath:o.mediaPath||null,plexClaimToken:o.plexClaimToken||null,customVolumes:o.customVolumes||null}};g&&(e.config.useExisting=!0,e.config.existingContainerId=c._existingContainer.id,e.config.existingPort=c._existingContainer.primaryPort,!o.port&&c._existingContainer.primaryPort&&(e.config.port=c._existingContainer.primaryPort));const i={app:c.id,port:o.port||c.defaultPort||null,subdomain:o.subdomain||"",media_path:o.mediaPath||"",env:o.plexClaimToken?{PLEX_CLAIM:o.plexClaimToken}:{}},s=await(await secureFetch("/api/v1/deploys/catalog/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)})).json();if(s.success){const a={id:o.subdomain,name:c.name,logo:`/assets/${c.id}.png`,containerId:null,url:s.url||`http://127.0.0.1:${i.port}`,ip:o.ip,appTemplate:c.id,tailscaleOnly:o.tailscaleOnly||!1};d.push(a),safeSet(h,JSON.stringify(d)),window.APPS&&!window.APPS.some(n=>n.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: ${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 n=s.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(e){b.logError("[AppSelector] Deployment",e,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${e.message}`,"error",8e3)}}}async function z(o,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const v=12,p=async()=>{d++;try{const e=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<v?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 k(){safeGetJSON(h,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),window.openDeployConfigFor=async function(o){if(!j&&!await M())return!1;const c=String(o||"").toLowerCase(),d=Object.entries(j).find(([p])=>p.toLowerCase()===c);if(!d)return!1;const v={id:d[0],...d[1]};return v.isDashboardWidget?(await L(),N.classList.add("show"),"selector"):(await H(v),!0)},wireModal(N,document.getElementById("app-selector-cancel"));const C=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const o=JSON.parse(C.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 v={appTemplate:o,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(!v.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}C.classList.remove("show"),x(v)}),wireModal(C);const D=document.getElementById("folder-browser-modal"),y=document.getElementById("folder-browser-path"),T=document.getElementById("folder-browser-list"),$=document.getElementById("folder-browser-selected"),F=document.getElementById("folder-browser-selected-list");let S="",I=[],B=null;window.openFolderBrowser=function(o){B=o,I=o.value.split(",").map(c=>c.trim()).filter(c=>c),S="",u(),E(""),D.classList.add("show")};async function E(o){y.textContent=o||"Select a drive...",T.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(o)}`)).json();if(!d.success){T.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}S=d.path||"",y.textContent=S||"Select a drive...";let v="";d.parent&&d.parent!==d.path&&(v+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
\u26A0 Warning: ${s.warning}`),showNotification(t,"success",8e3),delete c._useExisting,delete c._existingContainer,s.url&&s.url.startsWith("https://")&&C(s.url,c.name),s.setupInstructions&&s.setupInstructions.length>0&&setTimeout(()=>{const n=s.setupInstructions.join(`
|
|
|
|
|
`);showNotification(`Setup Instructions for ${c.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(s.error||"Deployment failed")}catch(e){b.logError("[AppSelector] Deployment",e,{function:"deploy"}),showNotification(`Failed to deploy ${c.name}: ${e.message}`,"error",8e3)}}}async function C(o,c){showNotification(`\u23F3 Generating SSL certificate for ${c}...`,"warning",6e4);let d=0;const g=12,p=async()=>{d++;try{const e=await fetch(o,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${c} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return d<g?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 k(){safeGetJSON(h,[]).forEach(c=>{window.APPS.some(d=>d.id===c.id)||window.APPS.push(c)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{L(),N.classList.add("show")}),window.openDeployConfigFor=async function(o){if(!j&&!await $())return!1;const c=String(o||"").toLowerCase(),d=Object.entries(j).find(([p])=>p.toLowerCase()===c);if(!d)return!1;const g={id:d[0],...d[1]};return g.isDashboardWidget?(await L(),N.classList.add("show"),"selector"):(await z(g),!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 o=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 g={appTemplate:o,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(!g.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(o.mediaMount?.required&&!c){showNotification("Please enter a media library path for this application","warning");return}T.classList.remove("show"),x(g)}),wireModal(T);const P=document.getElementById("folder-browser-modal"),f=document.getElementById("folder-browser-path"),B=document.getElementById("folder-browser-list"),D=document.getElementById("folder-browser-selected"),F=document.getElementById("folder-browser-selected-list");let S="",I=[],H=null;window.openFolderBrowser=function(o){H=o,I=o.value.split(",").map(c=>c.trim()).filter(c=>c),S="",u(),E(""),P.classList.add("show")};async function E(o){f.textContent=o||"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(o)}`)).json();if(!d.success){B.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(d.error)}</div>`;return}S=d.path||"",f.textContent=S||"Select a drive...";let g="";d.parent&&d.parent!==d.path&&(g+=`<div class="folder-item" data-path="${escapeHtml(d.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
|
|
|
|
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
|
|
|
|
<span style="color: var(--muted);">.. Parent Directory</span>
|
|
|
|
|
</div>`),d.items.length===0&&!d.parent?v+='<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?v+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(p=>{const e=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",i=I.includes(p.path),l=i?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";v+=`<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; ${l}">
|
|
|
|
|
</div>`),d.items.length===0&&!d.parent?g+='<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?g+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':d.items.forEach(p=>{const e=p.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",i=I.includes(p.path),l=i?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";g+=`<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; ${l}">
|
|
|
|
|
<span style="font-size: 1.2rem;">${e}</span>
|
|
|
|
|
<span style="flex: 1;">${escapeHtml(p.name)}</span>
|
|
|
|
|
${i?'<span style="color: var(--success);">\u2713</span>':""}
|
|
|
|
|
</div>`}),T.innerHTML=v,T.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{E(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const e=I.includes(p.dataset.path);p.style.background=e?"color-mix(in srgb, var(--success) 20%, transparent)":""})})}catch(c){T.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Failed to load: ${escapeHtml(c.message)}</div>`}}function u(){if(I.length===0){$.style.display="none";return}$.style.display="block",F.innerHTML=I.map(o=>`
|
|
|
|
|
</div>`}),B.innerHTML=g,B.querySelectorAll(".folder-item").forEach(p=>{p.addEventListener("click",()=>{E(p.dataset.path)}),p.addEventListener("mouseenter",()=>{p.style.background="var(--card-bg)"}),p.addEventListener("mouseleave",()=>{const e=I.includes(p.dataset.path);p.style.background=e?"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 u(){if(I.length===0){D.style.display="none";return}D.style.display="block",F.innerHTML=I.map(o=>`
|
|
|
|
|
<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(o)}
|
|
|
|
|
<button type="button" data-remove-folder="${escapeHtml(o)}" aria-label="Remove ${escapeHtml(o)}" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
|
|
|
|
</span>
|
|
|
|
|
`).join(""),F.querySelectorAll("[data-remove-folder]").forEach(o=>{o.addEventListener("click",()=>window.removeSelectedFolder(o.dataset.removeFolder))})}window.removeSelectedFolder=function(o){I=I.filter(c=>c!==o),u(),E(S)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{S&&!I.includes(S)&&(I.push(S),u(),E(S))}),wireModal(D,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{B&&(B.value=I.join(", ")),D.classList.remove("show")}),k()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
|
|
|
|
`).join(""),F.querySelectorAll("[data-remove-folder]").forEach(o=>{o.addEventListener("click",()=>window.removeSelectedFolder(o.dataset.removeFolder))})}window.removeSelectedFolder=function(o){I=I.filter(c=>c!==o),u(),E(S)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{S&&!I.includes(S)&&(I.push(S),u(),E(S))}),wireModal(P,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{H&&(H.value=I.join(", ")),P.classList.remove("show")}),k()})(),(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,31 +445,31 @@ 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,g=1,N=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),f=document.getElementById("recipe-next");wireModal(P,M);async function L(){try{const S=await fetch("/api/v1/recipes/templates"),I=await S.json();if(I.success)return b=I.templates,h=I.categories,!0;if(S.status===403)return N=!1,!1}catch(S){console.warn("Failed to fetch recipe templates:",S.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(S){await w();let I;if(N&&b?I=b:I=H(),!I||I.length===0)return;const B=document.createElement("div");B.className="app-category-header",B.innerHTML="\u{1F9EA} Recipes",B.style.borderBottomColor="#8e44ad",S.appendChild(B);const E=Array.isArray(I)?I:Object.values(I);E.sort((u,o)=>(o.popularity||0)-(u.popularity||0));for(const u of E){const o=document.createElement("div");o.className="app-option",o.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;">${u.componentCount||u.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>';o.innerHTML=`
|
|
|
|
|
</div>`);let b=null,h=null,j=null,v=1,N=!1;const M=document.getElementById("recipe-deploy-modal"),$=document.getElementById("recipe-cancel"),R=document.getElementById("recipe-prev"),y=document.getElementById("recipe-next");wireModal(M,$);async function L(){try{const S=await fetch("/api/v1/recipes/templates"),I=await S.json();if(I.success)return b=I.templates,h=I.categories,!0;if(S.status===403)return N=!1,!1}catch(S){console.warn("Failed to fetch recipe templates:",S.message)}return!1}async function w(){try{N=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{N=!1}return N}window.renderRecipeCards=async function(S){await w();let I;if(N&&b?I=b:I=z(),!I||I.length===0)return;const H=document.createElement("div");H.className="app-category-header",H.innerHTML="\u{1F9EA} Recipes",H.style.borderBottomColor="#8e44ad",S.appendChild(H);const E=Array.isArray(I)?I:Object.values(I);E.sort((u,o)=>(o.popularity||0)-(u.popularity||0));for(const u of E){const o=document.createElement("div");o.className="app-option",o.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;">${u.componentCount||u.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>';o.innerHTML=`
|
|
|
|
|
${d}
|
|
|
|
|
<div class="app-option-icon">${escapeHtml(u.icon||"\u{1F9EA}")}</div>
|
|
|
|
|
<div class="app-option-name">${escapeHtml(u.name)}</div>
|
|
|
|
|
<div class="app-option-desc">${escapeHtml(u.description||"")}</div>
|
|
|
|
|
${c}
|
|
|
|
|
`,o.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(u)},S.appendChild(o)}};function H(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(S){j=S,g=1;const I=document.getElementById("app-selector-modal");I&&I.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${S.name}`,z(),k(),P.classList.add("show")}function z(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(S=>{const I=parseInt(S.dataset.step);S.classList.toggle("active",I===g),S.classList.toggle("completed",I<g)});for(let S=1;S<=4;S++){const I=document.getElementById(`recipe-step-${S}`);I&&(I.style.display=S===g?"":"none")}R.style.display=g>1&&g<4?"":"none",g===4?(f.style.display="none",M.textContent="Close"):g===3?(f.textContent="\u{1F680} Deploy",f.style.display="",M.textContent="Cancel"):(f.textContent="Next",f.style.display="",M.textContent="Cancel")}function k(){const S=document.getElementById("recipe-component-list");S.innerHTML="";const I=j.components||[];for(const B of I){const E=document.createElement("div");E.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=B.required,o=B.internal;E.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${B.id}"
|
|
|
|
|
`,o.onclick=()=>{if(!N){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}x(u)},S.appendChild(o)}};function z(){return[{id:"htpc-suite",name:"HTPC Suite",icon:"\u{1F3AC}",description:"Complete media automation: find, download, organize, and stream",componentCount:6,popularity:98},{id:"nextcloud-complete",name:"Nextcloud Complete",icon:"\u2601\uFE0F",description:"Full productivity suite: cloud storage, office editing, and collaboration",componentCount:4,popularity:90},{id:"smart-home",name:"Smart Home Hub",icon:"\u{1F3E0}",description:"Home automation: control, automate, and monitor IoT devices",componentCount:4,popularity:88},{id:"dev-environment",name:"Dev Environment",icon:"\u{1F4BB}",description:"Self-hosted development workflow: Git, CI/CD, IDE, and database",componentCount:4,popularity:82}]}function x(S){j=S,v=1;const I=document.getElementById("app-selector-modal");I&&I.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${S.name}`,C(),k(),M.classList.add("show")}function C(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(S=>{const I=parseInt(S.dataset.step);S.classList.toggle("active",I===v),S.classList.toggle("completed",I<v)});for(let S=1;S<=4;S++){const I=document.getElementById(`recipe-step-${S}`);I&&(I.style.display=S===v?"":"none")}R.style.display=v>1&&v<4?"":"none",v===4?(y.style.display="none",$.textContent="Close"):v===3?(y.textContent="\u{1F680} Deploy",y.style.display="",$.textContent="Cancel"):(y.textContent="Next",y.style.display="",$.textContent="Cancel")}function k(){const S=document.getElementById("recipe-component-list");S.innerHTML="";const I=j.components||[];for(const H of I){const E=document.createElement("div");E.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const u=H.required,o=H.internal;E.innerHTML=`
|
|
|
|
|
<input type="checkbox" ${u?"checked disabled":"checked"} data-component-id="${H.id}"
|
|
|
|
|
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(B.role||B.id)}</div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(H.role||H.id)}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">
|
|
|
|
|
${B.templateRef?escapeHtml(B.templateRef):"Built-in"}
|
|
|
|
|
${H.templateRef?escapeHtml(H.templateRef):"Built-in"}
|
|
|
|
|
${u?'<span style="color: var(--accent); margin-left: 6px;">Required</span>':'<span style="color: var(--muted); margin-left: 6px;">Optional</span>'}
|
|
|
|
|
${o?'<span style="color: var(--muted); margin-left: 6px;">(Internal)</span>':""}
|
|
|
|
|
</div>
|
|
|
|
|
${B.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(B.note)}</div>`:""}
|
|
|
|
|
${H.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(H.note)}</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,S.appendChild(E)}}function C(){const S=document.getElementById("recipe-volumes-section"),I=document.getElementById("recipe-volume-list"),B=j.sharedVolumes;if(B&&Object.keys(B).length>0){S.style.display="",I.innerHTML="";for(const[E,u]of Object.entries(B)){const o=document.createElement("div");o.style.cssText="display: grid; gap: 4px;",o.innerHTML=`
|
|
|
|
|
`,S.appendChild(E)}}function T(){const S=document.getElementById("recipe-volumes-section"),I=document.getElementById("recipe-volume-list"),H=j.sharedVolumes;if(H&&Object.keys(H).length>0){S.style.display="",I.innerHTML="";for(const[E,u]of Object.entries(H)){const o=document.createElement("div");o.style.cssText="display: grid; gap: 4px;",o.innerHTML=`
|
|
|
|
|
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(u.label||E)}</label>
|
|
|
|
|
<input type="text" data-volume-key="${E}" value="${escapeHtml(u.defaultPath||"")}"
|
|
|
|
|
placeholder="${escapeHtml(u.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(u.description||"")}</div>
|
|
|
|
|
`,I.appendChild(o)}}else S.style.display="none"}function D(){const S=document.getElementById("recipe-review-content"),I=y(),B=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),E={};B.forEach(d=>{E[d.dataset.volumeKey]=d.value});const u=document.getElementById("recipe-timezone").value||"UTC",o=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;S.innerHTML=`
|
|
|
|
|
`,I.appendChild(o)}}else S.style.display="none"}function P(){const S=document.getElementById("recipe-review-content"),I=f(),H=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),E={};H.forEach(d=>{E[d.dataset.volumeKey]=d.value});const u=document.getElementById("recipe-timezone").value||"UTC",o=document.getElementById("recipe-ip").value||"host.docker.internal",c=document.getElementById("recipe-tailscale").checked;S.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>
|
|
|
|
|
|
|
|
|
@@ -484,7 +484,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
|
|
|
|
|
${Object.keys(E).length>0?`<div style="margin-bottom: 12px;">
|
|
|
|
|
<strong>Volumes:</strong>
|
|
|
|
|
${Object.entries(E).map(([d,v])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(v)}</div>`).join("")}
|
|
|
|
|
${Object.entries(E).map(([d,g])=>`<div style="font-size: 0.85rem; font-family: monospace; color: var(--muted);">${d}: ${escapeHtml(g)}</div>`).join("")}
|
|
|
|
|
</div>`:""}
|
|
|
|
|
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted);">
|
|
|
|
@@ -492,23 +492,23 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
</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 y(){const S=document.querySelectorAll("#recipe-component-list input[data-component-id]"),I=new Set;S.forEach(E=>{E.checked&&I.add(E.dataset.componentId)});const B=j.components||[];return B.filter(E=>E.required).forEach(E=>I.add(E.id)),B.filter(E=>I.has(E.id))}async function T(){const S=document.getElementById("recipe-progress-list"),I=document.getElementById("recipe-deploy-result");I.style.display="none",S.innerHTML="";const B=y();for(const c of B){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=`
|
|
|
|
|
`}function f(){const S=document.querySelectorAll("#recipe-component-list input[data-component-id]"),I=new Set;S.forEach(E=>{E.checked&&I.add(E.dataset.componentId)});const H=j.components||[];return H.filter(E=>E.required).forEach(E=>I.add(E.id)),H.filter(E=>I.has(E.id))}async function B(){const S=document.getElementById("recipe-progress-list"),I=document.getElementById("recipe-deploy-result");I.style.display="none",S.innerHTML="";const H=f();for(const c of H){const d=document.createElement("div");d.id=`recipe-progress-${c.id}`,d.style.cssText="display: flex; align-items: center; gap: 10px; padding: 10px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border); font-size: 0.85rem;",d.innerHTML=`
|
|
|
|
|
<span class="recipe-progress-icon" style="font-size: 1.1rem;">\u23F3</span>
|
|
|
|
|
<span style="flex:1; font-weight: 500;">${escapeHtml(c.role||c.id)}</span>
|
|
|
|
|
<span class="recipe-progress-status" style="color: var(--muted);">Queued</span>
|
|
|
|
|
`,S.appendChild(d)}const E=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};E.forEach(c=>{u[c.dataset.volumeKey]=c.value});const o={selectedComponents:B.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:u},componentOverrides:{}};for(const c of B)$(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:o})})).json();if(d.success){for(const v of d.deployed||[])$(v.id,"success",v.url?`Running \u2192 ${v.url}`:"Running");for(const v of d.errors||[])$(v.componentId,"error",v.error);I.style.display="",I.innerHTML=`
|
|
|
|
|
`,S.appendChild(d)}const E=document.querySelectorAll("#recipe-volume-list input[data-volume-key]"),u={};E.forEach(c=>{u[c.dataset.volumeKey]=c.value});const o={selectedComponents:H.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:u},componentOverrides:{}};for(const c of H)D(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:o})})).json();if(d.success){for(const g of d.deployed||[])D(g.id,"success",g.url?`Running \u2192 ${g.url}`:"Running");for(const g of d.errors||[])D(g.componentId,"error",g.error);I.style.display="",I.innerHTML=`
|
|
|
|
|
<div style="padding: 14px; border-radius: 8px; background: rgba(46,204,113,0.1); border: 1px solid rgba(46,204,113,0.3);">
|
|
|
|
|
<div style="font-weight: 600; color: var(--ok-fg); margin-bottom: 6px;">${escapeHtml(d.message||"Deployed!")}</div>
|
|
|
|
|
${d.setupInstructions?`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 8px;">
|
|
|
|
|
<strong>Setup tips:</strong>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(v=>`<li>${escapeHtml(v)}</li>`).join("")}</ul>
|
|
|
|
|
<ul style="margin: 4px 0 0 16px; padding: 0;">${d.setupInstructions.map(g=>`<li>${escapeHtml(g)}</li>`).join("")}</ul>
|
|
|
|
|
</div>`:""}
|
|
|
|
|
</div>
|
|
|
|
|
`,showNotification(`${j.name} recipe deployed successfully!`,"success",5e3),window.loadServices&&window.loadServices()}else I.style.display="",I.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){I.style.display="",I.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 $(S,I,B){const E=document.getElementById(`recipe-progress-${S}`);if(!E)return;const u=E.querySelector(".recipe-progress-icon"),o=E.querySelector(".recipe-progress-status");I==="deploying"?(u.textContent="\u23F3",o.style.color="var(--accent)"):I==="success"?(u.textContent="\u2705",o.style.color="var(--ok-fg)"):I==="error"&&(u.textContent="\u274C",o.style.color="var(--bad-fg)"),o.textContent=B}f.addEventListener("click",()=>{if(g===3){g=4,z(),T();return}g<3&&(g++,z(),g===2&&C(),g===3&&D())}),R.addEventListener("click",()=>{g>1&&g<4&&(g--,z())}),window.groupRecipeCards=function(){const S=document.querySelectorAll(".service-card[data-recipe-id]");if(S.length===0)return;const I={};S.forEach(B=>{const E=B.dataset.recipeId;I[E]||(I[E]=[]),I[E].push(B)});for(const[B,E]of Object.entries(I))E.length<2||E.forEach((u,o)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",o===0){let c=u.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=B.replace(/-/g," "),u.style.position="relative",u.appendChild(c))}})},window.manageRecipe=async function(S,I){const B=`/api/v1/recipes/${S}/${I}`,E=I==="remove"?"DELETE":"POST",u=I==="remove"?`/api/v1/recipes/${S}`:B;if(!(I==="remove"&&!confirm(`Remove the entire ${S} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(u,{method:E})).json();c.success?(showNotification(`Recipe ${I}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${I} failed: ${c.error}`,"error",5e3)}catch(o){showNotification(`Network error: ${o.message}`,"error",5e3)}};const F=document.createElement("style");F.textContent=`
|
|
|
|
|
</div>`}}function D(S,I,H){const E=document.getElementById(`recipe-progress-${S}`);if(!E)return;const u=E.querySelector(".recipe-progress-icon"),o=E.querySelector(".recipe-progress-status");I==="deploying"?(u.textContent="\u23F3",o.style.color="var(--accent)"):I==="success"?(u.textContent="\u2705",o.style.color="var(--ok-fg)"):I==="error"&&(u.textContent="\u274C",o.style.color="var(--bad-fg)"),o.textContent=H}y.addEventListener("click",()=>{if(v===3){v=4,C(),B();return}v<3&&(v++,C(),v===2&&T(),v===3&&P())}),R.addEventListener("click",()=>{v>1&&v<4&&(v--,C())}),window.groupRecipeCards=function(){const S=document.querySelectorAll(".service-card[data-recipe-id]");if(S.length===0)return;const I={};S.forEach(H=>{const E=H.dataset.recipeId;I[E]||(I[E]=[]),I[E].push(H)});for(const[H,E]of Object.entries(I))E.length<2||E.forEach((u,o)=>{if(u.style.borderLeft="3px solid rgba(142,68,173,0.5)",o===0){let c=u.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=H.replace(/-/g," "),u.style.position="relative",u.appendChild(c))}})},window.manageRecipe=async function(S,I){const H=`/api/v1/recipes/${S}/${I}`,E=I==="remove"?"DELETE":"POST",u=I==="remove"?`/api/v1/recipes/${S}`:H;if(!(I==="remove"&&!confirm(`Remove the entire ${S} recipe? This will delete all containers and configuration.`)))try{const c=await(await secureFetch(u,{method:E})).json();c.success?(showNotification(`Recipe ${I}: ${c.results?.filter(d=>d.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${I} failed: ${c.error}`,"error",5e3)}catch(o){showNotification(`Network error: ${o.message}`,"error",5e3)}};const F=document.createElement("style");F.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(F),w()})(),(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"}}),g=await j.json();if(j.ok&&g.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=h,b.disabled=!1},2e3);else throw new Error(g.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(F),w()})(),(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"}}),v=await j.json();if(j.ok&&v.success)b.textContent="\u2705 Reloaded!",setTimeout(()=>{b.textContent=h,b.disabled=!1},2e3);else throw new Error(v.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">
|
|
|
|
|
<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"),g=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),f=document.getElementById("error-log-since"),L=document.getElementById("error-log-until"),w=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),x=document.getElementById("error-log-total"),z=50;let k=0,C=null,D=0,y=[];function T(u){if(!u)return null;const o=new Date(u);return isNaN(o.getTime())?null:o.toISOString()}async function $(){try{const u=await fetch("/api/v1/error-logs/contexts");if(!u.ok)return;const o=await u.json();if(!o.success||!Array.isArray(o.contexts))return;y=o.contexts;const c=M.value;M.innerHTML='<option value="">All</option>';for(const d of o.contexts){const v=document.createElement("option");v.value=d.name,v.textContent=`${d.name} (${d.count})`,M.appendChild(v)}c&&o.contexts.some(d=>d.name===c)&&(M.value=c)}catch{}}function F(){const u=new URLSearchParams;u.set("limit",String(z)),u.set("offset",String(k)),P.value&&u.set("level",P.value),M.value&&u.set("context",M.value);const o=T(f.value),c=T(L.value);o&&u.set("since",o),c&&u.set("until",c);const d=(R.value||"").trim();return d&&u.set("search",d),u}async function S(u){try{u?(C&&C.abort(),C=new AbortController):(C&&C.abort(),C=new AbortController,k=0,D++,w.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const o=D,c=F(),d=await fetch("/api/v1/error-logs?"+c.toString(),{signal:C.signal});if(!d.ok){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${d.status}</div>`,H.style.display="none",x.textContent="";return}const v=await d.json();if(!v.success){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(v.error||"unknown")}</div>`,H.style.display="none",x.textContent="";return}if(!u&&o!==D)return;const p=Array.isArray(v.logs)?v.logs:[];if(p.length===0&&!u){const i=v.filters&&(v.filters.level||v.filters.context||v.filters.search||v.filters.since||v.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";w.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(i)}</div>`,H.style.display="none",x.textContent=v.total?`${v.total} total`:"";return}let e="";u||(e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',e+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',e+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',e+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',e+='<th style="padding: 6px; text-align: left;">Message</th>',e+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',e+="</tr>");for(const i of p){const l=(i.level||"?").toUpperCase(),s=l==="ERR"?"var(--bad-fg)":l==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",a=i.timestamp?new Date(i.timestamp).toLocaleString():"\u2014",t=i.context||"\u2014",n=(i.error||"").split(`
|
|
|
|
|
`)[0],r=i.request&&i.request.ip||"";e+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',e+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(i.timestamp||"")}">${escapeHtml(a)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${s}; font-weight: 600;">${escapeHtml(l)}</span></td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(t)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(n)}</td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(r)}</td>`,e+="</tr>",i.detail&&(e+=`<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(i.detail)}</pre></td></tr>`)}if(!u)e+="</table>",w.innerHTML=e;else{const i=w.querySelector("table");i&&i.insertAdjacentHTML("beforeend",e)}k+=p.length,H.style.display=v.hasMore?"":"none",x.textContent=`${v.total} total${v.hasMore?" (showing "+k+")":""}`,w.querySelectorAll(".error-log-row").forEach(i=>{i.dataset.wired||(i.dataset.wired="true",i.addEventListener("click",()=>{const l=i.nextElementSibling;l&&l.classList.contains("error-log-detail")&&(l.style.display=l.style.display==="none"?"":"none")}))})}catch(o){if(o&&o.name==="AbortError")return;w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`,x.textContent=""}}async function I(){if(confirm("Clear the entire error log? This cannot be undone."))try{const o=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();o.success?(await $(),S(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(o.error||"Clear failed"),"error",4e3)}catch(u){showNotification("\u274C "+u.message,"error",4e3)}}let B;function E(){P?.addEventListener("change",()=>S(!1)),M?.addEventListener("change",()=>S(!1)),R?.addEventListener("input",()=>{clearTimeout(B),B=setTimeout(()=>S(!1),250)});let u;[f,L].forEach(o=>{o?.addEventListener("change",()=>{clearTimeout(u),u=setTimeout(()=>S(!1),250)})}),j?.addEventListener("click",()=>S(!1)),H?.addEventListener("click",()=>S(!0)),g?.addEventListener("click",I),wireModal(b,N)}h?.addEventListener("click",async()=>{b?.classList.add("show"),await $(),S(!1)}),E()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
|
|
|
|
</div>`);const b=document.getElementById("error-log-modal"),h=document.getElementById("view-error-logs"),j=document.getElementById("error-log-refresh"),v=document.getElementById("error-log-clear"),N=document.getElementById("error-log-close"),M=document.getElementById("error-log-level"),$=document.getElementById("error-log-context"),R=document.getElementById("error-log-search"),y=document.getElementById("error-log-since"),L=document.getElementById("error-log-until"),w=document.getElementById("error-log-container"),z=document.getElementById("error-log-load-more"),x=document.getElementById("error-log-total"),C=50;let k=0,T=null,P=0,f=[];function B(u){if(!u)return null;const o=new Date(u);return isNaN(o.getTime())?null:o.toISOString()}async function D(){try{const u=await fetch("/api/v1/error-logs/contexts");if(!u.ok)return;const o=await u.json();if(!o.success||!Array.isArray(o.contexts))return;f=o.contexts;const c=$.value;$.innerHTML='<option value="">All</option>';for(const d of o.contexts){const g=document.createElement("option");g.value=d.name,g.textContent=`${d.name} (${d.count})`,$.appendChild(g)}c&&o.contexts.some(d=>d.name===c)&&($.value=c)}catch{}}function F(){const u=new URLSearchParams;u.set("limit",String(C)),u.set("offset",String(k)),M.value&&u.set("level",M.value),$.value&&u.set("context",$.value);const o=B(y.value),c=B(L.value);o&&u.set("since",o),c&&u.set("until",c);const d=(R.value||"").trim();return d&&u.set("search",d),u}async function S(u){try{u?(T&&T.abort(),T=new AbortController):(T&&T.abort(),T=new AbortController,k=0,P++,w.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const o=P,c=F(),d=await fetch("/api/v1/error-logs?"+c.toString(),{signal:T.signal});if(!d.ok){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${d.status}</div>`,z.style.display="none",x.textContent="";return}const g=await d.json();if(!g.success){w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(g.error||"unknown")}</div>`,z.style.display="none",x.textContent="";return}if(!u&&o!==P)return;const p=Array.isArray(g.logs)?g.logs:[];if(p.length===0&&!u){const i=g.filters&&(g.filters.level||g.filters.context||g.filters.search||g.filters.since||g.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";w.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(i)}</div>`,z.style.display="none",x.textContent=g.total?`${g.total} total`:"";return}let e="";u||(e='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',e+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',e+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',e+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',e+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',e+='<th style="padding: 6px; text-align: left;">Message</th>',e+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',e+="</tr>");for(const i of p){const l=(i.level||"?").toUpperCase(),s=l==="ERR"?"var(--bad-fg)":l==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",a=i.timestamp?new Date(i.timestamp).toLocaleString():"\u2014",t=i.context||"\u2014",n=(i.error||"").split(`
|
|
|
|
|
`)[0],r=i.request&&i.request.ip||"";e+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',e+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(i.timestamp||"")}">${escapeHtml(a)}</td>`,e+=`<td style="padding: 6px;"><span style="color: ${s}; font-weight: 600;">${escapeHtml(l)}</span></td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(t)}</td>`,e+=`<td style="padding: 6px;">${escapeHtml(n)}</td>`,e+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(r)}</td>`,e+="</tr>",i.detail&&(e+=`<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(i.detail)}</pre></td></tr>`)}if(!u)e+="</table>",w.innerHTML=e;else{const i=w.querySelector("table");i&&i.insertAdjacentHTML("beforeend",e)}k+=p.length,z.style.display=g.hasMore?"":"none",x.textContent=`${g.total} total${g.hasMore?" (showing "+k+")":""}`,w.querySelectorAll(".error-log-row").forEach(i=>{i.dataset.wired||(i.dataset.wired="true",i.addEventListener("click",()=>{const l=i.nextElementSibling;l&&l.classList.contains("error-log-detail")&&(l.style.display=l.style.display==="none"?"":"none")}))})}catch(o){if(o&&o.name==="AbortError")return;w.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(o.message)}</div>`,x.textContent=""}}async function I(){if(confirm("Clear the entire error log? This cannot be undone."))try{const o=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();o.success?(await D(),S(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(o.error||"Clear failed"),"error",4e3)}catch(u){showNotification("\u274C "+u.message,"error",4e3)}}let H;function E(){M?.addEventListener("change",()=>S(!1)),$?.addEventListener("change",()=>S(!1)),R?.addEventListener("input",()=>{clearTimeout(H),H=setTimeout(()=>S(!1),250)});let u;[y,L].forEach(o=>{o?.addEventListener("change",()=>{clearTimeout(u),u=setTimeout(()=>S(!1),250)})}),j?.addEventListener("click",()=>S(!1)),z?.addEventListener("click",()=>S(!0)),v?.addEventListener("click",I),wireModal(b,N)}h?.addEventListener("click",async()=>{b?.classList.add("show"),await D(),S(!1)}),E()})(),(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"),g=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),f=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),x=document.getElementById("cl-stream-indicator"),z=document.getElementById("cl-stream-text"),k=document.getElementById("cl-line-count"),C=document.getElementById("cl-filter-count"),D=document.getElementById("cl-image"),y=document.getElementById("cl-status"),T=document.getElementById("cl-created");let $=null,F=[],S=[],I=null,B=!1,E=null;function u(n){if(!n)return"-";const r=new Date(n);return isNaN(r.getTime())?n:r.toLocaleString()}function o(n){if(!n)return"";const r=document.createElement("div");return r.textContent=n,r.innerHTML}function c(n,r){const m=n.stream==="stderr"?"log-stderr":"log-stdout",A=n.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
</div>`);const b=document.getElementById("container-logs-modal"),h=document.getElementById("cl-container-select"),j=document.getElementById("cl-log-content"),v=document.getElementById("cl-log-search"),N=document.getElementById("cl-log-tail"),M=document.getElementById("cl-refresh"),$=document.getElementById("cl-stream"),R=document.getElementById("cl-download"),y=document.getElementById("cl-clear-search"),L=document.getElementById("cl-close"),w=document.getElementById("cl-close-btn"),z=document.getElementById("cl-stream-status"),x=document.getElementById("cl-stream-indicator"),C=document.getElementById("cl-stream-text"),k=document.getElementById("cl-line-count"),T=document.getElementById("cl-filter-count"),P=document.getElementById("cl-image"),f=document.getElementById("cl-status"),B=document.getElementById("cl-created");let D=null,F=[],S=[],I=null,H=!1,E=null;function u(n){if(!n)return"-";const r=new Date(n);return isNaN(r.getTime())?n:r.toLocaleString()}function o(n){if(!n)return"";const r=document.createElement("div");return r.textContent=n,r.innerHTML}function c(n,r){const m=n.stream==="stderr"?"log-stderr":"log-stdout",A=n.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
|
|
|
|
<div class="log-entry ${m}" data-index="${r}" 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;">${r+1}</span>
|
|
|
|
|
<span class="log-stream-icon" style="color: ${n.stream==="stderr"?"#f59e0b":"#22c55e"};">${A}</span>
|
|
|
|
|
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${o(n.text)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
`}function d(n,r=""){if(!n||n.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',k.textContent="0 lines",C.textContent="0 filtered";return}if(F=n,S=r?n.filter(m=>m.text&&m.text.toLowerCase().includes(r.toLowerCase())):n,k.textContent=`${n.length} lines`,C.textContent=r?`${S.length} of ${n.length} shown`:`${n.length} shown`,S.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${o(r)}"</div>`;return}j.innerHTML=S.map((m,A)=>c(m,A)).join(""),j.scrollTop=j.scrollHeight}async function v(){try{const r=(await getJSON("/api/v1/logs/containers")).containers||[],m=h.value;h.innerHTML='<option value="">Select a container...</option>',r.forEach(A=>{const O=document.createElement("option");O.value=A.id,O.textContent=`${A.name} (${A.image.split(":")[0]}) - ${A.status}`,O.dataset.name=A.name,O.dataset.image=A.image,O.dataset.status=A.status,O.dataset.created=A.created,h.appendChild(O)}),m&&h.querySelector(`option[value="${m}"]`)&&(h.value=m,p(m))}catch(n){console.error("Failed to load containers:",n)}}function p(n){const r=h.querySelector(`option[value="${n}"]`);r&&(D.textContent=r.dataset.image||"-",y.textContent=r.dataset.status||"-",y.style.color=r.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",T.textContent=u(r.dataset.created))}async function e(){const n=h.value;if(!n){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}l(),$=n,p(n);const r=N.value,m=g.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const A=`/api/v1/logs/container/${n}${r!=="all"?`?tail=${r}`:""}`,O=await getJSON(A);O.logs&&O.logs.length>0?d(O.logs,m):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',k.textContent="0 lines",C.textContent="0 filtered")}catch(A){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${o(A.message)}</div>`}}function i(){const n=h.value;if(!n)return;l(),B=!0,M.textContent="\u23F9 Stop",H.style.display="flex",x.textContent="\u{1F7E2}",z.textContent="Connecting...";const r=`/api/v1/logs/stream/${n}`;I=new EventSource(r),I.onopen=()=>{x.textContent="\u{1F7E2}",z.textContent="Connected - streaming logs"},I.onmessage=m=>{try{const A=JSON.parse(m.data);if(A.error){x.textContent="\u{1F534}",z.textContent=`Error: ${A.error}`;return}F.push(A),S.push(A),k.textContent=`${F.length} lines`,C.textContent=`${S.length} shown`;const O=g.value.trim();if(!O||A.text&&A.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(A,S.length-1);const U=_.firstElementChild;U.style.background="#1a3a1a",j.appendChild(U),j.scrollTop=j.scrollHeight}}catch(A){console.error("Error parsing log:",A)}},I.onerror=()=>{x.textContent="\u{1F534}",z.textContent="Disconnected",B=!1,M.textContent="\u25B6 Stream"},b._eventSource=I}function l(){I&&(I.close(),I=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),B=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function s(){if(!F||F.length===0){showNotification("No logs to download","error");return}const n=h.querySelector(`option[value="${$}"]`)?.dataset.name||$,r=new Date().toISOString().replace(/[:.]/g,"-"),m=`${n}-logs-${r}.txt`,A=F.map(q=>{const J=q.timestamp||"",G=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${G} ${q.text}`}).join(`
|
|
|
|
|
`),O=new Blob([A],{type:"text/plain"}),_=URL.createObjectURL(O),U=document.createElement("a");U.href=_,U.download=m,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(_),showNotification(`Downloaded ${F.length} log lines`,"success")}h?.addEventListener("change",()=>{e()}),N?.addEventListener("change",()=>{e()}),P?.addEventListener("click",()=>{e()}),M?.addEventListener("click",()=>{B?l():i()}),R?.addEventListener("click",()=>{s()}),f?.addEventListener("click",()=>{g.value="",d(F,"")}),g?.addEventListener("input",()=>{clearTimeout(E),E=setTimeout(()=>{d(F,g.value.trim())},300)}),g?.addEventListener("keydown",n=>{n.key==="Escape"&&(g.value="",d(F,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),v()});function t(){l(),b.classList.remove("show")}L?.addEventListener("click",t),w?.addEventListener("click",t),document.addEventListener("keydown",n=>{n.key==="Escape"&&b.classList.contains("show")&&t()}),b.addEventListener("click",n=>{n.target===b&&t()}),window.openContainerLogsModal=function(n,r){b.classList.add("show"),v().then(()=>{const m=Array.from(h.options).find(A=>A.value===n||A.dataset.name===r);m?(h.value=m.value,p(m.value),e()):n?($=n,D.textContent=r||n,y.textContent="-",T.textContent="-",e()):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(n,r=""){if(!n||n.length===0){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',k.textContent="0 lines",T.textContent="0 filtered";return}if(F=n,S=r?n.filter(m=>m.text&&m.text.toLowerCase().includes(r.toLowerCase())):n,k.textContent=`${n.length} lines`,T.textContent=r?`${S.length} of ${n.length} shown`:`${n.length} shown`,S.length===0){j.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${o(r)}"</div>`;return}j.innerHTML=S.map((m,A)=>c(m,A)).join(""),j.scrollTop=j.scrollHeight}async function g(){try{const r=(await getJSON("/api/v1/logs/containers")).containers||[],m=h.value;h.innerHTML='<option value="">Select a container...</option>',r.forEach(A=>{const O=document.createElement("option");O.value=A.id,O.textContent=`${A.name} (${A.image.split(":")[0]}) - ${A.status}`,O.dataset.name=A.name,O.dataset.image=A.image,O.dataset.status=A.status,O.dataset.created=A.created,h.appendChild(O)}),m&&h.querySelector(`option[value="${m}"]`)&&(h.value=m,p(m))}catch(n){console.error("Failed to load containers:",n)}}function p(n){const r=h.querySelector(`option[value="${n}"]`);r&&(P.textContent=r.dataset.image||"-",f.textContent=r.dataset.status||"-",f.style.color=r.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",B.textContent=u(r.dataset.created))}async function e(){const n=h.value;if(!n){j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}l(),D=n,p(n);const r=N.value,m=v.value.trim();j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const A=`/api/v1/logs/container/${n}${r!=="all"?`?tail=${r}`:""}`,O=await getJSON(A);O.logs&&O.logs.length>0?d(O.logs,m):(j.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',k.textContent="0 lines",T.textContent="0 filtered")}catch(A){j.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${o(A.message)}</div>`}}function i(){const n=h.value;if(!n)return;l(),H=!0,$.textContent="\u23F9 Stop",z.style.display="flex",x.textContent="\u{1F7E2}",C.textContent="Connecting...";const r=`/api/v1/logs/stream/${n}`;I=new EventSource(r),I.onopen=()=>{x.textContent="\u{1F7E2}",C.textContent="Connected - streaming logs"},I.onmessage=m=>{try{const A=JSON.parse(m.data);if(A.error){x.textContent="\u{1F534}",C.textContent=`Error: ${A.error}`;return}F.push(A),S.push(A),k.textContent=`${F.length} lines`,T.textContent=`${S.length} shown`;const O=v.value.trim();if(!O||A.text&&A.text.toLowerCase().includes(O.toLowerCase())){const _=document.createElement("div");_.innerHTML=c(A,S.length-1);const U=_.firstElementChild;U.style.background="#1a3a1a",j.appendChild(U),j.scrollTop=j.scrollHeight}}catch(A){console.error("Error parsing log:",A)}},I.onerror=()=>{x.textContent="\u{1F534}",C.textContent="Disconnected",H=!1,$.textContent="\u25B6 Stream"},b._eventSource=I}function l(){I&&(I.close(),I=null),b._eventSource&&(b._eventSource.close(),b._eventSource=null),H=!1,$.textContent="\u25B6 Stream",z.style.display="none"}function s(){if(!F||F.length===0){showNotification("No logs to download","error");return}const n=h.querySelector(`option[value="${D}"]`)?.dataset.name||D,r=new Date().toISOString().replace(/[:.]/g,"-"),m=`${n}-logs-${r}.txt`,A=F.map(q=>{const J=q.timestamp||"",G=q.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${G} ${q.text}`}).join(`
|
|
|
|
|
`),O=new Blob([A],{type:"text/plain"}),_=URL.createObjectURL(O),U=document.createElement("a");U.href=_,U.download=m,document.body.appendChild(U),U.click(),document.body.removeChild(U),URL.revokeObjectURL(_),showNotification(`Downloaded ${F.length} log lines`,"success")}h?.addEventListener("change",()=>{e()}),N?.addEventListener("change",()=>{e()}),M?.addEventListener("click",()=>{e()}),$?.addEventListener("click",()=>{H?l():i()}),R?.addEventListener("click",()=>{s()}),y?.addEventListener("click",()=>{v.value="",d(F,"")}),v?.addEventListener("input",()=>{clearTimeout(E),E=setTimeout(()=>{d(F,v.value.trim())},300)}),v?.addEventListener("keydown",n=>{n.key==="Escape"&&(v.value="",d(F,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{b.classList.add("show"),g()});function t(){l(),b.classList.remove("show")}L?.addEventListener("click",t),w?.addEventListener("click",t),document.addEventListener("keydown",n=>{n.key==="Escape"&&b.classList.contains("show")&&t()}),b.addEventListener("click",n=>{n.target===b&&t()}),window.openContainerLogsModal=function(n,r){b.classList.add("show"),g().then(()=>{const m=Array.from(h.options).find(A=>A.value===n||A.dataset.name===r);m?(h.value=m.value,p(m.value),e()):n?(D=n,P.textContent=r||n,f.textContent="-",B.textContent="-",e()):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",`
|
|
|
|
|
<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"),g=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),f=document.getElementById("jd-close"),L=document.getElementById("jd-close-btn"),w=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),x=document.getElementById("jd-filter-count"),z=document.getElementById("jd-overflow"),k=document.getElementById("jd-unit-display"),C=document.getElementById("jd-stream-state");let D=!1,y=[],T=!1,$=null,F=null;function S(p){const e=document.createElement("div");return e.textContent=String(p),e.innerHTML}function I(p){D=p,j.innerHTML="",b.forEach(e=>{const i=document.createElement("option");i.value=e.unit,i.textContent=e.label+" ("+e.unit+")",j.appendChild(i)}),j.disabled=!p,p?(P.disabled=!1,M.disabled=!1):(w.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>',P.disabled=!0,M.disabled=!0)}async function B(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){I(!1);return}const e=await p.json();I(!!e.available)}catch{I(!1)}}function E(){const p=(g.value||"").trim().toLowerCase(),e=p?y.filter(i=>(i.textContent||"").toLowerCase().includes(p)):y;if(e.length===0)w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching "${S(p)}"`:"")+"</div>";else{const i=e.map(s=>{const a=s.timestamp?S(s.timestamp):"\u2014",t=S(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("");w.innerHTML=i,w.scrollHeight-w.scrollTop-w.clientHeight<80&&(w.scrollTop=w.scrollHeight)}H.textContent=`${y.length} entries`,x.textContent=p?`${e.length} of ${y.length} shown`:`${y.length} shown`}async function u(){if(!D)return;c();const p=j.value;if(!p)return;const e=Math.max(1,Math.min(5e3,Number(N.value)||200)),i=(g.value||"").trim();w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const l=new URL("/api/v1/logs/journal",window.location.origin);l.searchParams.set("unit",p),l.searchParams.set("tail",String(e)),i&&l.searchParams.set("search",i);const s=await fetch(l.toString()),a=await s.json();if(!s.ok||!a.success){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+S(a&&a.error||"HTTP "+s.status)+"</div>";return}k.textContent=p,y=(a.entries||[]).map(t=>({timestamp:t.timestamp,unit:t.unit,textContent:t.text||""})),z.style.display="none",E()}catch(l){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+S(l.message)+"</div>"}}function o(){if(!D)return;c();const p=j.value;if(!p)return;const e=(g.value||"").trim();k.textContent=p,M.textContent="\u23F8 Stop",M.classList.add("streaming"),C.textContent="streaming",C.style.color="var(--ok-fg, #4ade80)",y=[],E(),z.style.display="none";const i=new URL("/api/v1/logs/journal/stream",window.location.origin);i.searchParams.set("unit",p),e&&i.searchParams.set("search",e),$=new EventSource(i.toString()),$.onmessage=l=>{try{const s=JSON.parse(l.data);if(s.error){/stream (exceeded|line cap)/.test(s.error)&&(z.style.display="",c()),w.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+S(s.error)+"</div>",w.scrollTop=w.scrollHeight;return}y.push({timestamp:s.timestamp,unit:s.unit||p,textContent:s.text||""}),y.length>5e3&&(y=y.slice(y.length-5e3),z.style.display=""),E()}catch{}},$.onerror=()=>{},T=!0}function c(){if(T=!1,$){try{$.close()}catch{}$=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),C.textContent="disconnected",C.style.color="var(--muted)"}function d(){c(),h.classList.remove("show")}P.addEventListener("click",u),M.addEventListener("click",()=>T?c():o()),R.addEventListener("click",()=>{g.value="",E()}),g.addEventListener("input",()=>{clearTimeout(F),F=setTimeout(E,200)}),g.addEventListener("keydown",p=>{p.key==="Escape"&&(g.value="",E())}),f.addEventListener("click",d),L.addEventListener("click",d),h.addEventListener("click",p=>{p.target===h&&d()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&h.classList.contains("show")&&d()}),j.addEventListener("change",()=>{y.length>0&&u()});function v(){h.classList.add("show"),B()}window.openJournaldModal=v,document.getElementById("view-journald-logs")?.addEventListener("click",v)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
|
|
|
|
`);const h=document.getElementById("journald-modal"),j=document.getElementById("jd-unit-select"),v=document.getElementById("jd-search"),N=document.getElementById("jd-tail"),M=document.getElementById("jd-refresh"),$=document.getElementById("jd-stream"),R=document.getElementById("jd-clear-search"),y=document.getElementById("jd-close"),L=document.getElementById("jd-close-btn"),w=document.getElementById("jd-content"),z=document.getElementById("jd-line-count"),x=document.getElementById("jd-filter-count"),C=document.getElementById("jd-overflow"),k=document.getElementById("jd-unit-display"),T=document.getElementById("jd-stream-state");let P=!1,f=[],B=!1,D=null,F=null;function S(p){const e=document.createElement("div");return e.textContent=String(p),e.innerHTML}function I(p){P=p,j.innerHTML="",b.forEach(e=>{const i=document.createElement("option");i.value=e.unit,i.textContent=e.label+" ("+e.unit+")",j.appendChild(i)}),j.disabled=!p,p?(M.disabled=!1,$.disabled=!1):(w.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>',M.disabled=!0,$.disabled=!0)}async function H(){try{const p=await fetch("/api/v1/logs/journal/units");if(!p.ok){I(!1);return}const e=await p.json();I(!!e.available)}catch{I(!1)}}function E(){const p=(v.value||"").trim().toLowerCase(),e=p?f.filter(i=>(i.textContent||"").toLowerCase().includes(p)):f;if(e.length===0)w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(p?` matching "${S(p)}"`:"")+"</div>";else{const i=e.map(s=>{const a=s.timestamp?S(s.timestamp):"\u2014",t=S(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("");w.innerHTML=i,w.scrollHeight-w.scrollTop-w.clientHeight<80&&(w.scrollTop=w.scrollHeight)}z.textContent=`${f.length} entries`,x.textContent=p?`${e.length} of ${f.length} shown`:`${f.length} shown`}async function u(){if(!P)return;c();const p=j.value;if(!p)return;const e=Math.max(1,Math.min(5e3,Number(N.value)||200)),i=(v.value||"").trim();w.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const l=new URL("/api/v1/logs/journal",window.location.origin);l.searchParams.set("unit",p),l.searchParams.set("tail",String(e)),i&&l.searchParams.set("search",i);const s=await fetch(l.toString()),a=await s.json();if(!s.ok||!a.success){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+S(a&&a.error||"HTTP "+s.status)+"</div>";return}k.textContent=p,f=(a.entries||[]).map(t=>({timestamp:t.timestamp,unit:t.unit,textContent:t.text||""})),C.style.display="none",E()}catch(l){w.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+S(l.message)+"</div>"}}function o(){if(!P)return;c();const p=j.value;if(!p)return;const e=(v.value||"").trim();k.textContent=p,$.textContent="\u23F8 Stop",$.classList.add("streaming"),T.textContent="streaming",T.style.color="var(--ok-fg, #4ade80)",f=[],E(),C.style.display="none";const i=new URL("/api/v1/logs/journal/stream",window.location.origin);i.searchParams.set("unit",p),e&&i.searchParams.set("search",e),D=new EventSource(i.toString()),D.onmessage=l=>{try{const s=JSON.parse(l.data);if(s.error){/stream (exceeded|line cap)/.test(s.error)&&(C.style.display="",c()),w.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+S(s.error)+"</div>",w.scrollTop=w.scrollHeight;return}f.push({timestamp:s.timestamp,unit:s.unit||p,textContent:s.text||""}),f.length>5e3&&(f=f.slice(f.length-5e3),C.style.display=""),E()}catch{}},D.onerror=()=>{},B=!0}function c(){if(B=!1,D){try{D.close()}catch{}D=null}$.textContent="\u25B6 Stream",$.classList.remove("streaming"),T.textContent="disconnected",T.style.color="var(--muted)"}function d(){c(),h.classList.remove("show")}M.addEventListener("click",u),$.addEventListener("click",()=>B?c():o()),R.addEventListener("click",()=>{v.value="",E()}),v.addEventListener("input",()=>{clearTimeout(F),F=setTimeout(E,200)}),v.addEventListener("keydown",p=>{p.key==="Escape"&&(v.value="",E())}),y.addEventListener("click",d),L.addEventListener("click",d),h.addEventListener("click",p=>{p.target===h&&d()}),document.addEventListener("keydown",p=>{p.key==="Escape"&&h.classList.contains("show")&&d()}),j.addEventListener("change",()=>{f.length>0&&u()});function g(){h.classList.add("show"),H()}window.openJournaldModal=g,document.getElementById("view-journald-logs")?.addEventListener("click",g)})(),(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"),g=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let R=null;async function f(){try{const k=await(await fetch("/api/v1/containers")).json();if(!k.success||!k.containers)return;g.innerHTML='<option value="">-- Select a container --</option>';for(const C of k.containers){const D=document.createElement("option");D.value=C.id,D.textContent=`${C.name||C.id} (${C.image||"unknown"})`,D.dataset.name=C.name,D.dataset.image=C.image,D.dataset.status=C.status,D.dataset.created=C.created,g.appendChild(D)}}catch(z){console.error("Failed to load containers:",z)}}function L(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 w(){if(!R){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const z=document.getElementById("snapshot-name").value.trim();if(!z){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const k=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const D=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:z,leaveRunning:k})})).json();D.success?(M.textContent=`\u2713 Snapshot "${z}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${D.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(C){M.textContent=`\u2717 Error: ${C.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){b.classList.add("show"),f()}function x(){b.classList.remove("show"),N.style.display="none",R=null,g.selectedIndex=0}h?.addEventListener("click",H),j?.addEventListener("click",x),wireModal(b,j),g?.addEventListener("change",z=>{const k=g.options[g.selectedIndex];L(k)}),P?.addEventListener("click",w),b?.querySelectorAll(".panel-tab").forEach(z=>{z.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(k=>k.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(k=>k.classList.remove("active")),z.classList.add("active"),b.querySelector(`#${z.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
</div>`);const b=document.getElementById("snapshot-modal"),h=document.getElementById("snapshot-btn"),j=document.getElementById("snapshot-close"),v=document.getElementById("snapshot-container-select"),N=document.getElementById("snapshot-details"),M=document.getElementById("snapshot-create-btn"),$=document.getElementById("snapshot-create-status");let R=null;async function y(){try{const k=await(await fetch("/api/v1/containers")).json();if(!k.success||!k.containers)return;v.innerHTML='<option value="">-- Select a container --</option>';for(const T of k.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,v.appendChild(P)}}catch(C){console.error("Failed to load containers:",C)}}function L(C){if(!C||!C.value){N.style.display="none",R=null;return}R=C.value,document.getElementById("snapshot-image").textContent=C.dataset.image||"-",document.getElementById("snapshot-status").textContent=C.dataset.status||"-",document.getElementById("snapshot-created").textContent=C.dataset.created?new Date(C.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=C.value.substring(0,12),N.style.display=""}async function w(){if(!R){$.textContent="Please select a container first",$.style.color="var(--bad-fg)";return}const C=document.getElementById("snapshot-name").value.trim();if(!C){$.textContent="Please enter a snapshot name",$.style.color="var(--bad-fg)";return}const k=document.getElementById("snapshot-leave-running").checked;M.disabled=!0,M.textContent="Creating...",$.textContent="";try{const P=await(await fetch(`/api/v1/containers/${encodeURIComponent(R)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:C,leaveRunning:k})})).json();P.success?($.textContent=`\u2713 Snapshot "${C}" created successfully`,$.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):($.textContent=`\u2717 Failed: ${P.error||"Unknown error"}`,$.style.color="var(--bad-fg)")}catch(T){$.textContent=`\u2717 Error: ${T.message}`,$.style.color="var(--bad-fg)"}finally{M.disabled=!1,M.textContent="\u{1F4BE} Create Snapshot"}}function z(){b.classList.add("show"),y()}function x(){b.classList.remove("show"),N.style.display="none",R=null,v.selectedIndex=0}h?.addEventListener("click",z),j?.addEventListener("click",x),wireModal(b,j),v?.addEventListener("change",C=>{const k=v.options[v.selectedIndex];L(k)}),M?.addEventListener("click",w),b?.querySelectorAll(".panel-tab").forEach(C=>{C.addEventListener("click",()=>{b.querySelectorAll(".panel-tab").forEach(k=>k.classList.remove("active")),b.querySelectorAll(".panel-section").forEach(k=>k.classList.remove("active")),C.classList.add("active"),b.querySelector(`#${C.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
|
|
|
|
<div 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,19 +831,19 @@ 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"),g=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),f=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),z=document.getElementById("smart-retry-btn");let k=null;const C={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},D={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function y(E){N.style.display=E==="detect"?"block":"none",P.style.display=E==="credentials"?"block":"none",M.style.display=E==="progress"?"block":"none",R.style.display=E==="results"?"block":"none"}function T(E){const u={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"}},o=u[E]||u.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, ${o.bg} 20%, transparent); color: ${o.bg};">${o.icon} ${o.text}</span>`}async function $(){y("detect"),f.style.display="none";try{if(k=await(await fetch("/api/v1/arr/smart-detect")).json(),!k.success){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(k.error)}</div>`,f.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(k.services)){const v=C[c]||"\u{1F4E6}",p=D[c]||c,e=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>`:"",i=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",l=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<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;">${v}</span>
|
|
|
|
|
</div>`);const b=document.getElementById("arr-setup-modal"),h=document.getElementById("arr-setup-btn"),j=document.getElementById("arr-setup-cancel"),v=document.getElementById("smart-connect-btn"),N=document.getElementById("smart-phase-detect"),M=document.getElementById("smart-phase-credentials"),$=document.getElementById("smart-phase-progress"),R=document.getElementById("smart-phase-results"),y=document.getElementById("smart-detect-results"),L=document.getElementById("smart-credential-inputs"),w=document.getElementById("smart-progress-steps"),z=document.getElementById("smart-results-content"),x=document.getElementById("smart-plex-libraries"),C=document.getElementById("smart-retry-btn");let k=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 f(E){N.style.display=E==="detect"?"block":"none",M.style.display=E==="credentials"?"block":"none",$.style.display=E==="progress"?"block":"none",R.style.display=E==="results"?"block":"none"}function B(E){const u={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"}},o=u[E]||u.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, ${o.bg} 20%, transparent); color: ${o.bg};">${o.icon} ${o.text}</span>`}async function D(){f("detect"),y.style.display="none";try{if(k=await(await fetch("/api/v1/arr/smart-detect")).json(),!k.success){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(k.error)}</div>`,y.style.display="block";return}let u='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[c,d]of Object.entries(k.services)){const g=T[c]||"\u{1F4E6}",p=P[c]||c,e=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>`:"",i=d.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(d.version)}</span>`:"",l=(d.hasApiKey||d.hasToken)&&d.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";u+=`<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;">${g}</span>
|
|
|
|
|
<div style="flex: 1;">
|
|
|
|
|
<div style="font-weight: 500; font-size: 0.9rem;">${p}</div>
|
|
|
|
|
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
|
|
|
|
|
${e} ${i} ${l}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
${T(d.status)}
|
|
|
|
|
${B(d.status)}
|
|
|
|
|
</div>`}u+="</div>";const o=k.summary;u+=`<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(o.fullyConnected))}/${escapeHtml(String(o.totalDetected+(5-o.totalDetected)))} services detected ·
|
|
|
|
|
${escapeHtml(String(o.fullyConnected))} connected${o.needsApiKey>0?` · <strong>${escapeHtml(String(o.needsApiKey))} needs API key</strong>`:""}
|
|
|
|
|
</div>`,f.innerHTML=u,f.style.display="block",F(k),setTimeout(()=>{y("credentials")},800)}catch(E){f.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(E.message)}</div>`,f.style.display="block"}}function F(E){let u="";const o=E.services,c=["radarr","sonarr","prowlarr"];for(const p of c){const e=o[p];if(!e||e.status==="not_found"&&!e.url)continue;const i=C[p],l=D[p],s=e.status==="connected";u+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${s?"var(--ok-fg)":"var(--border)"};">
|
|
|
|
|
</div>`,y.innerHTML=u,y.style.display="block",F(k),setTimeout(()=>{f("credentials")},800)}catch(E){y.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(E.message)}</div>`,y.style.display="block"}}function F(E){let u="";const o=E.services,c=["radarr","sonarr","prowlarr"];for(const p of c){const e=o[p];if(!e||e.status==="not_found"&&!e.url)continue;const i=T[p],l=P[p],s=e.status==="connected";u+=`<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;">${i}</span>
|
|
|
|
|
<span style="font-weight: 500;">${l}</span>
|
|
|
|
@@ -868,10 +868,10 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<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>
|
|
|
|
|
${T(d.status)}
|
|
|
|
|
${B(d.status)}
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: var(--muted);">${escapeHtml(d.source||"")}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}const v=o.seerr;if(v){const p=v.status==="connected";let e="";if(v.configuredServices){const i=v.configuredServices;e=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
</div>`}const g=o.seerr;if(g){const p=g.status==="connected";let e="";if(g.configuredServices){const i=g.configuredServices;e=`<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">
|
|
|
|
|
Configured: ${i.radarr?"✓ Radarr":"✗ Radarr"} ·
|
|
|
|
|
${i.sonarr?"✓ Sonarr":"✗ Sonarr"} ·
|
|
|
|
|
${i.plex?"✓ Plex":"✗ Plex"}
|
|
|
|
@@ -879,25 +879,25 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<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>
|
|
|
|
|
${T(v.status)}
|
|
|
|
|
${B(g.status)}
|
|
|
|
|
</div>
|
|
|
|
|
${e}
|
|
|
|
|
</div>`}L.innerHTML=u,L.querySelectorAll("[data-smart-test]").forEach(p=>{p.addEventListener("click",()=>window.smartTestConnection(p.dataset.smartTest))})}window.smartTestConnection=async function(E){const u=document.getElementById(`smart-${E}-url`),o=document.getElementById(`smart-${E}-key`),c=document.getElementById(`smart-${E}-status`),d=u?.value.trim(),v=o?.value.trim();if(!d||!v){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const e=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:E,url:d,apiKey:v})})).json();e.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(e.appName||"Connected")} v${escapeHtml(e.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(e.error)}</span>`}catch(p){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(p.message)}</span>`}};async function S(){y("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const E={};for(const o of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${o}-url`)?.value.trim(),d=document.getElementById(`smart-${o}-key`)?.value.trim();d&&c?E[o]={apiKey:d,url:c}:d&&(E[o]={apiKey:d})}const u={services:Object.keys(E).length>0?E: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(u)})).json();let d="";for(const v of c.steps||[]){const p=v.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',e=v.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;">
|
|
|
|
|
</div>`}L.innerHTML=u,L.querySelectorAll("[data-smart-test]").forEach(p=>{p.addEventListener("click",()=>window.smartTestConnection(p.dataset.smartTest))})}window.smartTestConnection=async function(E){const u=document.getElementById(`smart-${E}-url`),o=document.getElementById(`smart-${E}-key`),c=document.getElementById(`smart-${E}-status`),d=u?.value.trim(),g=o?.value.trim();if(!d||!g){c.innerHTML='<span style="color: var(--bad-fg);">Enter URL and API key</span>';return}c.innerHTML='<span class="brand-spinner"></span>';try{const e=await(await secureFetch("/api/v1/arr/test-connection",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:E,url:d,apiKey:g})})).json();e.success?c.innerHTML=`<span style="color: var(--ok-fg);">✓ ${escapeHtml(e.appName||"Connected")} v${escapeHtml(e.version||"")}</span>`:c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(e.error)}</span>`}catch(p){c.innerHTML=`<span style="color: var(--bad-fg);">✗ ${escapeHtml(p.message)}</span>`}};async function S(){f("progress"),w.innerHTML='<div style="text-align: center; padding: 20px;"><span class="brand-spinner"></span><div style="color: var(--muted); margin-top: 8px;">Connecting services...</div></div>';const E={};for(const o of["radarr","sonarr","prowlarr"]){const c=document.getElementById(`smart-${o}-url`)?.value.trim(),d=document.getElementById(`smart-${o}-key`)?.value.trim();d&&c?E[o]={apiKey:d,url:c}:d&&(E[o]={apiKey:d})}const u={services:Object.keys(E).length>0?E: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(u)})).json();let d="";for(const g of c.steps||[]){const p=g.status==="success"?'<span style="color: var(--ok-fg);">✓</span>':'<span style="color: var(--bad-fg);">✗</span>',e=g.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(v.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(v.details||"")}</span>
|
|
|
|
|
</div>`}w.innerHTML=d,setTimeout(()=>I(c),500)}catch(o){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(o.message)}</div>`}}function I(E){y("results");const u=E.summary||{},o=u.failed===0&&u.succeeded>0,c=o?"var(--ok-fg)":"#f39c12",d=o?"✓":"⚠",v=o?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.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;">
|
|
|
|
|
<span>${escapeHtml(g.step)}</span>
|
|
|
|
|
<span style="margin-left: auto; font-size: 0.75rem; color: ${e};">${escapeHtml(g.details||"")}</span>
|
|
|
|
|
</div>`}w.innerHTML=d,setTimeout(()=>I(c),500)}catch(o){w.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Connection error: ${escapeHtml(o.message)}</div>`}}function I(E){f("results");const u=E.summary||{},o=u.failed===0&&u.succeeded>0,c=o?"var(--ok-fg)":"#f39c12",d=o?"✓":"⚠",g=o?"All Connected!":`${escapeHtml(String(u.succeeded))}/${escapeHtml(String(u.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};">${v}</div>
|
|
|
|
|
<div style="font-size: 1.1rem; font-weight: 600; color: ${c};">${g}</div>
|
|
|
|
|
<div style="font-size: 0.85rem; color: var(--muted); margin-top: 4px;">${escapeHtml(String(u.succeeded))} succeeded, ${escapeHtml(String(u.failed))} failed</div>
|
|
|
|
|
</div>`;p+='<div style="display: flex; flex-direction: column; gap: 4px;">';for(const e of E.steps||[]){const i=e.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;">
|
|
|
|
|
${i} ${escapeHtml(e.step)} <span style="margin-left: auto; color: var(--muted); font-size: 0.75rem;">${escapeHtml(e.details||"")}</span>
|
|
|
|
|
</div>`}p+="</div>",H.innerHTML=p,z.style.display=u.failed>0?"block":"none",E.steps?.some(e=>e.step.includes("Plex")&&e.status==="success")&&B()}async function B(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let o=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
</div>`}p+="</div>",z.innerHTML=p,C.style.display=u.failed>0?"block":"none",E.steps?.some(e=>e.step.includes("Plex")&&e.status==="success")&&H()}async function H(){try{const u=await(await fetch("/api/v1/plex/libraries")).json();if(u.success&&u.libraries?.length>0){let o=`<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(u.serverName)} Libraries</h4>
|
|
|
|
|
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const c of u.libraries){const d=c.type==="movie"?"\u{1F3AC}":c.type==="show"?"\u{1F4FA}":"\u{1F3B5}";o+=`<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>`}o+="</div></div>",x.innerHTML=o,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{b.classList.add("show"),x.style.display="none",$()}),wireModal(b,j),g?.addEventListener("click",S),z?.addEventListener("click",S)})(),(function(){const b=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
|
|
|
|
</div>`}o+="</div></div>",x.innerHTML=o,x.style.display="block"}}catch{}}h?.addEventListener("click",()=>{b.classList.add("show"),x.style.display="none",D()}),wireModal(b,j),v?.addEventListener("click",S),C?.addEventListener("click",S)})(),(function(){const b=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,15 +1109,15 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="notifications-save" class="btn-accent">Save Settings</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const h=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),g=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const z=document.getElementById(`${x}-enabled`),k=document.getElementById(`${x}-config`);z?.addEventListener("change",()=>{k.style.display=z.checked?"block":"none"})});const P=document.getElementById("health-check-enabled"),M=document.getElementById("health-check-config");P?.addEventListener("change",()=>{M.style.opacity=P.checked?"1":"0.5"});async function R(){try{const z=await(await fetch("/api/v1/notifications/config")).json();if(z.success){const k=z.config;document.getElementById("notifications-enabled").checked=k.enabled,document.getElementById("discord-enabled").checked=k.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=k.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=k.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=k.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=k.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=k.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=k.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=k.providers?.email?.enabled?"block":"none",k.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=k.providers.ntfy.serverUrl),k.providers?.email?.host&&(document.getElementById("email-host").value=k.providers.email.host),k.providers?.email?.from&&(document.getElementById("email-from").value=k.providers.email.from),k.providers?.email?.to&&(document.getElementById("email-to").value=k.providers.email.to),k.providers?.email?.port&&(document.getElementById("email-port").value=k.providers.email.port),k.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=k.providers.email.secure===!0),k.providers?.email?.username&&(document.getElementById("email-user").value=k.providers.email.username);const C=document.getElementById("email-pass");k.providers?.email?.hasPassword?(C.value="",C.placeholder="saved \u2014 leave blank to keep"):C.placeholder="app password",document.getElementById("health-check-enabled").checked=k.healthCheck?.enabled||!1,k.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=k.healthCheck.intervalMinutes),k.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(k.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=k.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=k.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=k.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=k.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=k.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=k.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=k.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=k.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=k.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=k.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=k.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=k.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=k.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=k.events?.alert!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function f(){try{const z=await(await fetch("/api/v1/notifications/history?limit=10")).json(),k=document.getElementById("notification-history");z.success&&z.history?.length>0?k.innerHTML=z.history.map(C=>{const D=new Date(C.timestamp).toLocaleString();return`
|
|
|
|
|
</div>`);const h=document.getElementById("notifications-modal"),j=document.getElementById("manage-notifications"),v=document.getElementById("notifications-save"),N=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(x=>{const C=document.getElementById(`${x}-enabled`),k=document.getElementById(`${x}-config`);C?.addEventListener("change",()=>{k.style.display=C.checked?"block":"none"})});const M=document.getElementById("health-check-enabled"),$=document.getElementById("health-check-config");M?.addEventListener("change",()=>{$.style.opacity=M.checked?"1":"0.5"});async function R(){try{const C=await(await fetch("/api/v1/notifications/config")).json();if(C.success){const k=C.config;document.getElementById("notifications-enabled").checked=k.enabled,document.getElementById("discord-enabled").checked=k.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=k.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=k.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=k.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=k.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=k.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=k.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=k.providers?.email?.enabled?"block":"none",k.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=k.providers.ntfy.serverUrl),k.providers?.email?.host&&(document.getElementById("email-host").value=k.providers.email.host),k.providers?.email?.from&&(document.getElementById("email-from").value=k.providers.email.from),k.providers?.email?.to&&(document.getElementById("email-to").value=k.providers.email.to),k.providers?.email?.port&&(document.getElementById("email-port").value=k.providers.email.port),k.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=k.providers.email.secure===!0),k.providers?.email?.username&&(document.getElementById("email-user").value=k.providers.email.username);const T=document.getElementById("email-pass");k.providers?.email?.hasPassword?(T.value="",T.placeholder="saved \u2014 leave blank to keep"):T.placeholder="app password",document.getElementById("health-check-enabled").checked=k.healthCheck?.enabled||!1,k.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=k.healthCheck.intervalMinutes),k.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(k.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=k.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=k.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=k.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=k.events?.["deploy-failed"]!==!1,document.getElementById("event-ssl-cert-expiry").checked=k.events?.["ssl-cert-expiry"]!==!1,document.getElementById("event-dns-propagation").checked=k.events?.["dns-propagation"]!==!1,document.getElementById("event-drift-detected").checked=k.events?.["drift-detected"]!==!1,document.getElementById("event-dependency-restart").checked=k.events?.["dependency-restart"]!==!1,document.getElementById("event-recipe-removed").checked=k.events?.["recipe-removed"]!==!1,document.getElementById("event-workflow").checked=k.events?.workflow!==!1,document.getElementById("event-backup-complete").checked=k.events?.["backup-complete"]!==!1,document.getElementById("event-backup-failed").checked=k.events?.["backup-failed"]!==!1,document.getElementById("event-update-available").checked=k.events?.["update-available"]!==!1,document.getElementById("event-resource-alert").checked=k.events?.alert!==!1}}catch(x){b.logError("[Notifications] Load Config",x,{function:"loadConfig"})}}async function y(){try{const C=await(await fetch("/api/v1/notifications/history?limit=10")).json(),k=document.getElementById("notification-history");C.success&&C.history?.length>0?k.innerHTML=C.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)"}[C.type]||"var(--muted)"}">${C.type==="success"?"\u2713":C.type==="error"?"\u2717":"\u2139"}</span>
|
|
|
|
|
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[T.type]||"var(--muted)"}">${T.type==="success"?"\u2713":T.type==="error"?"\u2717":"\u2139"}</span>
|
|
|
|
|
<div style="flex: 1; min-width: 0;">
|
|
|
|
|
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(C.title)}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${D}</div>
|
|
|
|
|
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(T.title)}</div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted);">${P}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""):k.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function L(){try{const x={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,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}},k=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();k.success?(showNotification("Notification settings saved","success",3e3),h.classList.remove("show")):showNotification(`Failed to save: ${k.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function w(x){try{const k=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();k.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${k.error}`,"error",3e3)}catch(z){showNotification(`Error: ${z.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const 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(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{h.classList.add("show"),R(),f()}),g?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const x=document.getElementById("notifications-send-test"),z=x.textContent;x.textContent="Sending...",x.disabled=!0;try{const C=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();C.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${C.results?.map(D=>`${D.provider}: ${D.error||"ok"}`).join(", ")}`,"error",5e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}finally{x.textContent=z,x.disabled=!1}});async function H(){try{const z=await(await fetch("/api/v1/notifications/status")).json();if(z.success&&z.lastSent){
Line truncated
|
|
|
|
|
`}).join(""):k.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(x){b.logError("[Notifications] Load History",x,{function:"loadHistory"})}}async function L(){try{const x={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,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}},k=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(x)})).json();k.success?(showNotification("Notification settings saved","success",3e3),h.classList.remove("show")):showNotification(`Failed to save: ${k.error}`,"error",3e3)}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}async function w(x){try{const k=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:x})})).json();k.success?showNotification(`Test ${x} notification sent!`,"success",3e3):showNotification(`Test failed: ${k.error}`,"error",3e3)}catch(C){showNotification(`Error: ${C.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>w("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>w("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>w("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>w("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const C=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();C.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(C.lastCheck).toLocaleString()} (${C.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(x){showNotification(`Error: ${x.message}`,"error",3e3)}}),j?.addEventListener("click",()=>{h.classList.add("show"),R(),y()}),v?.addEventListener("click",L),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const x=document.getElementById("notifications-send-test"),C=x.textContent;x.textContent="Sending...",x.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),z()):showNotification(`Test failed: ${T.results?.map(P=>`${P.provider}: ${P.error||"ok"}`).join(", ")}`,"error",5e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}finally{x.textContent=C,x.disabled=!1}});async function z(){try{const C=await(await fetch("/api/v1/notifications/status")).json();if(C.success&&C.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,7 +1223,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
|
|
|
|
<button id="backup-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),f=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),z=document.getElementById("backup-preview-content"),k=document.getElementById("backup-do-restore-btn"),C=document.getElementById("backup-result"),D=document.getElementById("backup-schedules-container");function y(l){!l||!l.querySelectorAll||l.querySelectorAll("[data-upgrade-msg]").forEach(s=>{s.addEventListener("click",()=>{showNotification(s.dataset.upgradeMsg,"info"),window.openLicenseModal&&window.openLicenseModal()})})}var T=document.getElementById("backup-history-container"),$=document.getElementById("backup-disk-container"),F=document.getElementById("pointintime-container"),S=null;M?.addEventListener("click",function(){P.classList.add("show"),C&&(C.style.display="none"),x&&(x.style.display="none"),H&&(H.style.display="none"),S=null}),wireModal(P,R),f?.addEventListener("click",async function(){f.disabled=!0,f.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var l=await fetch("/api/v1/backup/export"),s=await l.json();s.browserState=h();var a=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),t=URL.createObjectURL(a),n=document.createElement("a");n.href=t,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(t);var r=Object.keys(s.browserState).length,m=s.themes?Object.keys(s.themes).length:0;C.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(m?" + "+m+" themes":""),C.style.display="block",C.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",C.style.border="1px solid var(--ok-fg)"}catch(A){C.innerHTML="\u274C Export failed: "+escapeHtml(A.message),C.style.display="block",C.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",C.style.border="1px solid var(--bad-fg)"}f.disabled=!1,f.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(l){var s=l.target.files[0];if(s){H.textContent="\u{1F4C4} "+s.name,H.style.display="block",C.style.display="none";try{var a=await s.text(),t=JSON.parse(a);if(g(t)){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),n+="</div>",z.innerHTML=n,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),m=await r.json();if(m.success){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var A in m.preview.files){var O=m.preview.files[A],_=O.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+_+" "+escapeHtml(O.description)+"</span>"}n+="</div>",m.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+m.preview.serviceCount+" services</div>"),m.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+m.preview.themeCount+" custom themes</div>"),m.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+m.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),z.innerHTML=n,x.style.display="block"}else C.innerHTML="\u2Line truncated
|
|
|
|
|
</div>`);var M=document.getElementById("backup-modal"),$=document.getElementById("backup-restore-btn"),R=document.getElementById("backup-cancel"),y=document.getElementById("backup-export-btn"),L=document.getElementById("backup-select-file"),w=document.getElementById("backup-file-input"),z=document.getElementById("backup-file-name"),x=document.getElementById("backup-preview"),C=document.getElementById("backup-preview-content"),k=document.getElementById("backup-do-restore-btn"),T=document.getElementById("backup-result"),P=document.getElementById("backup-schedules-container");function f(l){!l||!l.querySelectorAll||l.querySelectorAll("[data-upgrade-msg]").forEach(s=>{s.addEventListener("click",()=>{showNotification(s.dataset.upgradeMsg,"info"),window.openLicenseModal&&window.openLicenseModal()})})}var B=document.getElementById("backup-history-container"),D=document.getElementById("backup-disk-container"),F=document.getElementById("pointintime-container"),S=null;$?.addEventListener("click",function(){M.classList.add("show"),T&&(T.style.display="none"),x&&(x.style.display="none"),z&&(z.style.display="none"),S=null}),wireModal(M,R),y?.addEventListener("click",async function(){y.disabled=!0,y.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var l=await fetch("/api/v1/backup/export"),s=await l.json();s.browserState=h();var a=new Blob([JSON.stringify(s,null,2)],{type:"application/json"}),t=URL.createObjectURL(a),n=document.createElement("a");n.href=t,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(t);var r=Object.keys(s.browserState).length,m=s.themes?Object.keys(s.themes).length:0;T.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(m?" + "+m+" 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(A){T.innerHTML="\u274C Export failed: "+escapeHtml(A.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)"}y.disabled=!1,y.innerHTML="\u2B07\uFE0F Download Full Backup"}),L?.addEventListener("click",function(){w.click()}),w?.addEventListener("change",async function(l){var s=l.target.files[0];if(s){z.textContent="\u{1F4C4} "+s.name,z.style.display="block",T.style.display="none";try{var a=await s.text(),t=JSON.parse(a);if(v(t)){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(t.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',t.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+t.services.length+" services</span>"),t.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+t.customApps.length+" custom apps</span>"),t.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(t.theme)+"</span>"),t.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(t.userThemes).length+" custom themes</span>"),n+="</div>",C.innerHTML=n,x.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),m=await r.json();if(m.success){S=t;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(t.exportedAt).toLocaleString()+" (v"+escapeHtml(t.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var A in m.preview.files){var O=m.preview.files[A],_=O.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+_+" "+escapeHtml(O.description)+"</span>"}n+="</div>",m.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+m.preview.serviceCount+" services</div>"),m.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+m.preview.themeCount+" custom themes</div>"),m.preview.browserStateCount&&(n+='<div style="margin-top: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Browser Preferences</div>',n+='<div style="font-size: 0.8rem; color: var(--accent);">\u{1F5A5}\uFE0F '+m.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),C.innerHTML=n,x.style.display="block"}else T.innerHTML="\u2Line truncated
|
|
|
|
|
|
|
|
|
|
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var a=await secureFetch("/api/v1/apps/"+encodeURIComponent(l)+"/revert/"+encodeURIComponent(s),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),t=await a.json();t.success?(showNotification(l+" restored to "+s,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(t.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
|
|
|
@@ -1305,7 +1305,7 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<button id="stats-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),g=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),f=document.getElementById("stats-last-update");let L=null,w=null;function H(d){if(d===0||!d)return"0 B";const v=1024,p=["B","KB","MB","GB"],e=Math.floor(Math.log(d)/Math.log(v));return parseFloat((d/Math.pow(v,e)).toFixed(1))+" "+p[e]}function x(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function z(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function k(){try{let d=null,v=!1;try{const i=await(await fetch("/api/v1/monitoring/stats")).json();i.success&&i.stats&&(d=i.stats,v=!0,w=i.stats)}catch{}if(!v){const i=await(await fetch("/api/v1/stats/containers")).json();if(i.success&&i.stats){d={};for(const l of i.stats)d[l.name]={name:l.name,current:{cpu:l.cpu,memory:{percent:l.memory.percent,usage:l.memory.used,limit:l.memory.limit,usageMB:Math.round(l.memory.used/1048576),limitMB:Math.round(l.memory.limit/1048576)},network:{rxBytes:l.network.rx,txBytes:l.network.tx,rxMB:(l.network.rx/1048576).toFixed(1),txMB:(l.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:l.status};w=d}}if(!d||Object.keys(d).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let p='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[e,i]of Object.entries(d)){const l=i.current||i,s=l.cpu?.percent||0,a=l.memory?.percent||0,t=x(s),n=z(a),r=l.memory?.usage||l.memory?.used||0,m=l.memory?.limit||0,A=l.network?.rxBytes||l.network?.rx||0,O=l.network?.txBytes||l.network?.tx||0,_=i.aggregated;p+=`
|
|
|
|
|
</div>`);const b=document.getElementById("stats-modal"),h=document.getElementById("container-stats-btn"),j=document.getElementById("stats-cancel"),v=document.getElementById("stats-refresh-btn"),N=document.getElementById("stats-auto-refresh"),M=document.getElementById("stats-container"),$=document.getElementById("stats-aggregated-container"),R=document.getElementById("stats-alerts-container"),y=document.getElementById("stats-last-update");let L=null,w=null;function z(d){if(d===0||!d)return"0 B";const g=1024,p=["B","KB","MB","GB"],e=Math.floor(Math.log(d)/Math.log(g));return parseFloat((d/Math.pow(g,e)).toFixed(1))+" "+p[e]}function x(d){return d<30?"#2ecc71":d<70?"#f39c12":"#e74c3c"}function C(d){return d<50?"#2ecc71":d<80?"#f39c12":"#e74c3c"}async function k(){try{let d=null,g=!1;try{const i=await(await fetch("/api/v1/monitoring/stats")).json();i.success&&i.stats&&(d=i.stats,g=!0,w=i.stats)}catch{}if(!g){const i=await(await fetch("/api/v1/stats/containers")).json();if(i.success&&i.stats){d={};for(const l of i.stats)d[l.name]={name:l.name,current:{cpu:l.cpu,memory:{percent:l.memory.percent,usage:l.memory.used,limit:l.memory.limit,usageMB:Math.round(l.memory.used/1048576),limitMB:Math.round(l.memory.limit/1048576)},network:{rxBytes:l.network.rx,txBytes:l.network.tx,rxMB:(l.network.rx/1048576).toFixed(1),txMB:(l.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:l.status};w=d}}if(!d||Object.keys(d).length===0){M.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[e,i]of Object.entries(d)){const l=i.current||i,s=l.cpu?.percent||0,a=l.memory?.percent||0,t=x(s),n=C(a),r=l.memory?.usage||l.memory?.used||0,m=l.memory?.limit||0,A=l.network?.rxBytes||l.network?.rx||0,O=l.network?.txBytes||l.network?.tx||0,_=i.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;">${i.name||e}</span>
|
|
|
|
@@ -1330,18 +1330,18 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
</div>
|
|
|
|
|
<span style="font-size: 0.8rem; font-weight: 500; color: ${n}; min-width: 45px; text-align: right;">${a.toFixed(1)}%</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${H(r)} / ${H(m)}</div>
|
|
|
|
|
<div style="font-size: 0.65rem; color: var(--muted); margin-top: 2px;">${z(r)} / ${z(m)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-size: 0.7rem; color: var(--muted); margin-bottom: 4px;">Network</div>
|
|
|
|
|
<div style="font-size: 0.8rem;">
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${H(A)}</span>
|
|
|
|
|
<span style="color: #3498db;">\u2193 ${z(A)}</span>
|
|
|
|
|
<span style="color: var(--muted); margin: 0 4px;">/</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${H(O)}</span>
|
|
|
|
|
<span style="color: #e74c3c;">\u2191 ${z(O)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}p+="</div>",P.innerHTML=p,f.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(d.message)}</div>`}}async function C(){if(!M)return;const d=w;if(!d||Object.keys(d).length===0){M.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 v='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,e]of Object.entries(d)){const i=e.aggregated;i&&(v+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
</div>`}p+="</div>",M.innerHTML=p,y.textContent="Updated: "+new Date().toLocaleTimeString()}catch(d){M.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(!$)return;const d=w;if(!d||Object.keys(d).length===0){$.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 g='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[p,e]of Object.entries(d)){const i=e.aggregated;i&&(g+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
|
|
|
|
<div style="font-weight: 600; margin-bottom: 10px;">${e.name||p}</div>
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${i.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
|
|
|
@@ -1350,13 +1350,13 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<div class="stat-mini-card"><span class="stat-val">${i.memory?.max?.toFixed(1)||0}%</span><span class="stat-lbl">Max Mem</span></div>
|
|
|
|
|
</div>
|
|
|
|
|
${i.dataPoints?`<div style="font-size: 0.7rem; color: var(--muted); margin-top: 6px;">${i.dataPoints} data points over ${i.timeRange||24}h</div>`:""}
|
|
|
|
|
</div>`)}v+="</div>",M.innerHTML=v}async function D(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=w;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 v=!1;try{v=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{v=!1}let p=[];try{const n=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();n.success&&(p=n.history||[])}catch{}let e={};try{const n=await(await fetch("/api/v1/monitoring/alerts/config")).json();n.success&&(e=n.configs||{})}catch{}const l=Object.entries(d).map(([t,n])=>{const r=e[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
|
|
|
|
</div>`)}g+="</div>",$.innerHTML=g}async function P(){if(!R)return;R.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading alerts...</div>';const d=w;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 g=!1;try{g=(await(await fetch("/api/v1/license/feature/resource-alerts")).json()).available}catch{g=!1}let p=[];try{const n=await(await fetch("/api/v1/monitoring/alerts?limit=50")).json();n.success&&(p=n.history||[])}catch{}let e={};try{const n=await(await fetch("/api/v1/monitoring/alerts/config")).json();n.success&&(e=n.configs||{})}catch{}const l=Object.entries(d).map(([t,n])=>{const r=e[t]||{cpuThreshold:80,memoryThreshold:90,diskIOThreshold:50,autoRestart:!1,enabled:!1};return`
|
|
|
|
|
<tr data-container="${t}">
|
|
|
|
|
<td style="font-weight: 600; padding: 8px;">${n.name||t}</td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${r.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${r.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${r.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${v?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${r.autoRestart?"checked":""} ${v?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-cpu" value="${r.cpuThreshold??80}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-mem" value="${r.memoryThreshold??90}" min="0" max="100" style="width: 60px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="number" class="alert-disk" value="${r.diskIOThreshold??50}" min="0" max="1000" style="width: 70px; font-size: 0.8rem; padding: 2px 4px;" ${g?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;"><input type="checkbox" class="alert-autorestart" ${r.autoRestart?"checked":""} ${g?"":"disabled"} /></td>
|
|
|
|
|
<td style="padding: 4px 8px;">
|
|
|
|
|
<button class="alert-test-btn btn-xs" data-container="${t}" data-name="${n.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>
|
|
|
|
@@ -1370,7 +1370,7 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<td style="padding: 6px 8px; text-align: center;">${r}</td>
|
|
|
|
|
<td style="padding: 6px 8px; font-size: 0.75rem; color: ${t.autoRestartTriggered?"#f39c12":"var(--muted)"};">${t.autoRestartTriggered?"\u21BB":""}</td>
|
|
|
|
|
</tr>
|
|
|
|
|
`}).join(""),a=v?`
|
|
|
|
|
`}).join(""),a=g?`
|
|
|
|
|
<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>
|
|
|
|
@@ -1423,7 +1423,7 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
</div>
|
|
|
|
|
`:'<div style="color: var(--muted); text-align: center; padding: 20px;">No alerts recorded yet.</div>'}
|
|
|
|
|
</div>
|
|
|
|
|
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(n=>{const r=n.dataset.container;t[r]={cpuThreshold:parseInt(n.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(n.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(n.querySelector(".alert-disk")?.value)||50,autoRestart:!!n.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const r=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),m=document.getElementById("save-all-alerts");m.textContent=r.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{m.textContent="Save All"},2e3)}catch{const r=document.getElementById("save-all-alerts");r.textContent="\u274C Error",setTimeout(()=>{r.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),b.classList.remove("show"),T(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=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=n},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),T(),typeof openLicenseModal=="function"&&openLicenseModal()})}function y(){L&&clearInterval(L),N?.checked&&(L=setInterval(k,DC.POLL.STATS))}function T(){L&&(clearInterval(L),L=null)}h?.addEventListener("click",()=>{b.classList.add("show"),k(),y()}),j?.addEventListener("click",()=>{b.classList.remove("show"),T()}),b?.addEventListener("click",d=>{d.target===b&&(b.classList.remove("show"),T())}),g?.addEventListener("click",k),N?.addEventListener("change",()=>{N.checked?y():T()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",C),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",D);const $=document.getElementById("stats-history-container"),F=document.getElementById("stats-history-container-area"),S=document.querySelectorAll(".stats-range-btn");let I="1h";function B(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 E(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function u(d,v,p,e,i){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const l=d.map(v).filter(q=>q!=null);if(l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const s=Math.max(...l,1),a=Math.min(...l,0),t=s-a||1,n=600,r=80,m=4,A=(n-m*2)/Math.max(l.length-1,1),O=l.map((q,J)=>{const G=m+J*A,Z=r-m-(q-a)/t*(r-m*2);return`${G.toFixed(1)},${Z.toFixed(1)}`}).join(" "),_=l[l.length-1],U=l.reduce((q,J)=>q+J,0)/l.length;return`
|
|
|
|
|
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const t={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(n=>{const r=n.dataset.container;t[r]={cpuThreshold:parseInt(n.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(n.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(n.querySelector(".alert-disk")?.value)||50,autoRestart:!!n.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const r=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:t})})).json(),m=document.getElementById("save-all-alerts");m.textContent=r.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{m.textContent="Save All"},2e3)}catch{const r=document.getElementById("save-all-alerts");r.textContent="\u274C Error",setTimeout(()=>{r.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",t=>{t.preventDefault(),b.classList.remove("show"),B(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=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=n},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{b.classList.remove("show"),B(),typeof openLicenseModal=="function"&&openLicenseModal()})}function f(){L&&clearInterval(L),N?.checked&&(L=setInterval(k,DC.POLL.STATS))}function B(){L&&(clearInterval(L),L=null)}h?.addEventListener("click",()=>{b.classList.add("show"),k(),f()}),j?.addEventListener("click",()=>{b.classList.remove("show"),B()}),b?.addEventListener("click",d=>{d.target===b&&(b.classList.remove("show"),B())}),v?.addEventListener("click",k),N?.addEventListener("change",()=>{N.checked?f():B()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",T),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",P);const D=document.getElementById("stats-history-container"),F=document.getElementById("stats-history-container-area"),S=document.querySelectorAll(".stats-range-btn");let I="1h";function H(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 E(d){return d==="raw"?"live (10s samples)":d==="hourly"?"hourly average":d==="daily"?"daily average":d}function u(d,g,p,e,i){if(!d||d.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const l=d.map(g).filter(q=>q!=null);if(l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(e)}</div>`;const s=Math.max(...l,1),a=Math.min(...l,0),t=s-a||1,n=600,r=80,m=4,A=(n-m*2)/Math.max(l.length-1,1),O=l.map((q,J)=>{const G=m+J*A,Z=r-m-(q-a)/t*(r-m*2);return`${G.toFixed(1)},${Z.toFixed(1)}`}).join(" "),_=l[l.length-1],U=l.reduce((q,J)=>q+J,0)/l.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(e)}</span>
|
|
|
|
@@ -1433,11 +1433,11 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<polyline fill="none" stroke="${p}" stroke-width="1.5" points="${O}" />
|
|
|
|
|
</svg>
|
|
|
|
|
</div>
|
|
|
|
|
`}function o(){if(!$)return;const d=w||{},v=$.value,p=Object.entries(d);if(p.length===0){$.innerHTML='<option value="">No containers</option>';return}$.innerHTML=p.map(([e,i])=>`<option value="${escapeHtml(e)}">${escapeHtml(i.name||e)}</option>`).join(""),v&&d[v]&&($.value=v)}async function c(){if(!F||!$)return;const d=$.value;if(!d){F.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const v=Date.now(),p=v-B(I);F.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const i=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${p}&endTime=${v}`)).json();if(!i.success)throw new Error(i.error||"Failed to load history");const l=i.samples||[],s=i.tier||"raw";if(l.length===0){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${I}. Tier: ${E(s)}.</div>`;return}const a=s==="raw",t=a?O=>O.cpu?.percent:O=>O.cpu?.avg,n=a?O=>O.memory?.percent:O=>O.memory?.avgPercent,r=a?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,m=a?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let A=`
|
|
|
|
|
`}function o(){if(!D)return;const d=w||{},g=D.value,p=Object.entries(d);if(p.length===0){D.innerHTML='<option value="">No containers</option>';return}D.innerHTML=p.map(([e,i])=>`<option value="${escapeHtml(e)}">${escapeHtml(i.name||e)}</option>`).join(""),g&&d[g]&&(D.value=g)}async function c(){if(!F||!D)return;const d=D.value;if(!d){F.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No container selected.</div>';return}const g=Date.now(),p=g-H(I);F.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading history...</div>';try{const i=await(await fetch(`/api/v1/monitoring/history/${encodeURIComponent(d)}?startTime=${p}&endTime=${g}`)).json();if(!i.success)throw new Error(i.error||"Failed to load history");const l=i.samples||[],s=i.tier||"raw";if(l.length===0){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CA}</span>No data for the last ${I}. Tier: ${E(s)}.</div>`;return}const a=s==="raw",t=a?O=>O.cpu?.percent:O=>O.cpu?.avg,n=a?O=>O.memory?.percent:O=>O.memory?.avgPercent,r=a?O=>O.network?.rxMB||0:O=>O.network?.rxMB||0,m=a?O=>O.network?.txMB||0:O=>O.network?.txMB||0;let A=`
|
|
|
|
|
<div style="font-size: 0.75rem; color: var(--muted); margin-bottom: 8px;">
|
|
|
|
|
${l.length} samples \xB7 ${escapeHtml(E(s))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(v).toLocaleString()}
|
|
|
|
|
${l.length} samples \xB7 ${escapeHtml(E(s))} \xB7 ${new Date(p).toLocaleString()} \u2192 ${new Date(g).toLocaleString()}
|
|
|
|
|
</div>
|
|
|
|
|
`;A+=u(l,t,"#2ecc71","CPU","%"),A+=u(l,n,"#3498db","Memory","%"),A+=u(l,r,"#9b59b6","Network RX"," MB"),A+=u(l,m,"#e67e22","Network TX"," MB"),F.innerHTML=A}catch(e){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(e.message)}</div>`}}S.forEach(d=>{d.addEventListener("click",()=>{S.forEach(v=>v.classList.remove("active")),d.classList.add("active"),I=d.dataset.range,c()})}),$?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{o(),c()})})(),(function(){injectModal("health-modal",`<div id="health-modal" class="weather-modal">
|
|
|
|
|
`;A+=u(l,t,"#2ecc71","CPU","%"),A+=u(l,n,"#3498db","Memory","%"),A+=u(l,r,"#9b59b6","Network RX"," MB"),A+=u(l,m,"#e67e22","Network TX"," MB"),F.innerHTML=A}catch(e){F.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u26A0\uFE0F</span>Failed to load history: ${escapeHtml(e.message)}</div>`}}S.forEach(d=>{d.addEventListener("click",()=>{S.forEach(g=>g.classList.remove("active")),d.classList.add("active"),I=d.dataset.range,c()})}),D?.addEventListener("change",c),document.querySelector('[data-panel="stats-history"]')?.addEventListener("click",()=>{o(),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,7 +1561,7 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
<button id="health-cancel">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),g=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),f=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save"),z="dashcaddy-health-settings",k={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},C=document.getElementById("health-global-save"),D=document.getElementById("health-global-reset"),y=document.getElementById("health-global-status"),T=document.getElementById("health-setting-retention"),$=document.getElementById("health-setting-interval"),F=document.getElementById("health-setting-stats-interval"),S=document.getElementById("health-setting-max-entries"),I=document.getElementById("health-setting-disk-threshold");function B(){try{const a=safeGet(z),t=a?JSON.parse(a):{};return Object.assign({},k,t)}catch{return Object.assign({},k)}}function E(){const a=B();T&&(T.value=a.retentionDays),$&&($.value=a.pollingInterval),F&&(F.value=a.statsPollingInterval),S&&(S.value=a.maxEntriesPerService),I&&(I.value=a.diskUsageThreshold)}function u(){const a={retentionDays:Math.max(1,Math.min(3650,parseInt(T?.value)||k.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt($?.value)||k.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(F?.value)||k.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(S?.value)||k.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(I?.value)||k.diskUsageThreshold))};try{safeSet(z,JSON.stringify(a)),E(),y&&(y.textContent="Saved \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(t){y&&(y.textContent="Save failed",y.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+t.message,"error")}}function o(){try{safeSet(z,JSON.stringify(k))}catch{}E(),y&&(y.textContent="Reset to defaults \u2713",y.style.color="var(--ok-fg)",setTimeout(()=>{y&&(y.textContent="")},2500))}E(),C?.addEventListener("click",u),D?.addEventListener("click",o);let c=null;function d(a){return a>=99.9?"var(--ok-fg)":a>=95?"#f39c12":"var(--bad-fg)"}function v(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 n=Object.values(t.status);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); text-align: left;">',r+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',r+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',r+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of n){const A=m.status==="up",O=A?"var(--dot-ok)":"var(--dot-bad)",_=m.uptime?.["24h"]??"-",U=m.uptime?.["7d"]??"-",q=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",J=m.timestamp?timeAgo(m.timestamp):"-";r+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,r+=`<td style="padding: 8px; color: ${typeof U=="number"?d(U):"var(--muted)"};">${typeof U=="number"?U.toFixed(1)+"%":U}</td>`,r+=`<td style="padding: 8px;">${q}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,r+="</tr>",r+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
|
|
|
|
</div>`);const b=document.getElementById("health-modal"),h=document.getElementById("health-check-btn"),j=document.getElementById("health-cancel"),v=document.getElementById("health-refresh-btn"),N=document.getElementById("health-status-container"),M=document.getElementById("health-incidents-container"),$=document.getElementById("health-config-container"),R=document.getElementById("health-last-update"),y=document.getElementById("health-add-btn"),L=document.getElementById("health-config-form"),w=document.getElementById("health-form-title"),z=document.getElementById("health-form-cancel"),x=document.getElementById("health-form-save"),C="dashcaddy-health-settings",k={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},T=document.getElementById("health-global-save"),P=document.getElementById("health-global-reset"),f=document.getElementById("health-global-status"),B=document.getElementById("health-setting-retention"),D=document.getElementById("health-setting-interval"),F=document.getElementById("health-setting-stats-interval"),S=document.getElementById("health-setting-max-entries"),I=document.getElementById("health-setting-disk-threshold");function H(){try{const a=safeGet(C),t=a?JSON.parse(a):{};return Object.assign({},k,t)}catch{return Object.assign({},k)}}function E(){const a=H();B&&(B.value=a.retentionDays),D&&(D.value=a.pollingInterval),F&&(F.value=a.statsPollingInterval),S&&(S.value=a.maxEntriesPerService),I&&(I.value=a.diskUsageThreshold)}function u(){const a={retentionDays:Math.max(1,Math.min(3650,parseInt(B?.value)||k.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(D?.value)||k.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(F?.value)||k.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(S?.value)||k.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(I?.value)||k.diskUsageThreshold))};try{safeSet(C,JSON.stringify(a)),E(),f&&(f.textContent="Saved \u2713",f.style.color="var(--ok-fg)",setTimeout(()=>{f&&(f.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(t){f&&(f.textContent="Save failed",f.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+t.message,"error")}}function o(){try{safeSet(C,JSON.stringify(k))}catch{}E(),f&&(f.textContent="Reset to defaults \u2713",f.style.color="var(--ok-fg)",setTimeout(()=>{f&&(f.textContent="")},2500))}E(),T?.addEventListener("click",u),P?.addEventListener("click",o);let c=null;function d(a){return a>=99.9?"var(--ok-fg)":a>=95?"#f39c12":"var(--bad-fg)"}function g(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 n=Object.values(t.status);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); text-align: left;">',r+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',r+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',r+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const m of n){const A=m.status==="up",O=A?"var(--dot-ok)":"var(--dot-bad)",_=m.uptime?.["24h"]??"-",U=m.uptime?.["7d"]??"-",q=m.avgResponseTime!=null?Math.round(m.avgResponseTime)+"ms":"-",J=m.timestamp?timeAgo(m.timestamp):"-";r+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(m.serviceId)}">`,r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${O}; margin-right: 6px;"></span>${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px; color: ${typeof _=="number"?d(_):"var(--muted)"};">${typeof _=="number"?_.toFixed(1)+"%":_}</td>`,r+=`<td style="padding: 8px; color: ${typeof U=="number"?d(U):"var(--muted)"};">${typeof U=="number"?U.toFixed(1)+"%":U}</td>`,r+=`<td style="padding: 8px;">${q}</td>`,r+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,r+="</tr>",r+=`<tr id="health-detail-${escapeHtml(m.serviceId)}" style="display: none;"><td colspan="6" style="padding: 12px; background: var(--bg); border-bottom: 1px solid var(--border);"><div class="panel-empty"><span class="brand-spinner"></span> Loading Line truncated
|
|
|
|
|
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; font-size: 0.82rem;">
|
|
|
|
|
<div><span style="color: var(--muted);">Total Checks</span><br><strong>${q.totalChecks||0}</strong></div>
|
|
|
|
|
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${d(q.uptime||0)};">${(q.uptime||0).toFixed(2)}%</strong></div>
|
|
|
|
@@ -1574,11 +1574,11 @@ This will replace current configuration, credentials, and data. Containers will
|
|
|
|
|
</div>`}else O.querySelector("td").innerHTML='<div class="panel-empty">No detailed stats available for this period.</div>'}catch(_){O.querySelector("td").innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(_.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 e(){try{const[a,t]=await Promise.all([fetch("/api/v1/health-checks/incidents"),fetch("/api/v1/health-checks/incidents/history?limit=50")]),n=await a.json(),r=await t.json();let m="";const A=n.success&&n.incidents?n.incidents:[];if(A.length>0){m+='<div style="margin-bottom: 16px;"><h4 style="color: var(--bad-fg); margin: 0 0 8px;">Open Incidents ('+A.length+")</h4>";for(const _ of A)m+=`<div style="padding: 10px 12px; margin-bottom: 8px; border: 1px solid var(--bad-fg)30; border-radius: 8px; background: var(--bad-bg);">
|
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center;">
|
|
|
|
|
<span style="font-weight: 500;">${escapeHtml(_.serviceId)}</span>
|
|
|
|
|
<span>${v(_.severity)}</span>
|
|
|
|
|
<span>${g(_.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>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const O=r.success&&r.history?r.history:[];if(O.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of O){const U=_.status==="resolved",q=U&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,m+=`<td style="padding: 6px;">${v(_.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${U?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,m+=`<td style="padding: 6px;">${q}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,m+="</tr>"}m+="</table>"}P.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(a){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}async function i(){try{const t=await(await fetch("/api/v1/health-checks/status")).json(),n=t.success&&t.status?Object.values(t.status):[];if(n.length===0){M.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 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;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of n){const A=m.status==="up";r+='<tr style="border-bottom: 1px solid var(--border);">',r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px; color: ${A?"var(--ok-fg)":"var(--bad-fg)"};">${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<button data-health-action="health-edit" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,r+=`<button data-health-action="health-delete" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,r+="</td></tr>"}r+="</table>",M.innerHTML=r,M.querySelectorAll("[data-health-action]").forEach(m=>{m.addEventListener("click",()=>{document.dispatchEvent(new CustomEvent(m.dataset.healthAction,{detail:m.dataset.serviceId}))})})}catch(a){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}function l(a,t,n,r,m,A,O){c=a||null,w.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=n||"",document.getElementById("health-form-timeout").value=r||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=A||99.9,document.getElementById("health-form-slow").value=O||5e3,L.style.display="",f.style.display="none"}function s(){L.style.display="none",f.style.display="",c=null}f?.addEventListener("click",()=>l("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",s),x?.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 n=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),r={name:document.getElementById("health-form-name").value.trim()||a,url:t,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:n.length?n:[200],sla:{target:parseFloat(documenLine truncated
|
|
|
|
|
</div>`;m+="</div>"}else m+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const O=r.success&&r.history?r.history:[];if(O.length>0){m+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',m+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Service</th><th style="padding: 6px; text-align: left;">Type</th><th style="padding: 6px; text-align: left;">Severity</th><th style="padding: 6px; text-align: left;">Status</th><th style="padding: 6px; text-align: left;">Duration</th><th style="padding: 6px; text-align: left;">When</th></tr>';for(const _ of O){const U=_.status==="resolved",q=U&&_.duration?_.duration<6e4?Math.round(_.duration/1e3)+"s":Math.round(_.duration/6e4)+"m":"-";m+='<tr style="border-bottom: 1px solid var(--border);">',m+=`<td style="padding: 6px;">${escapeHtml(_.serviceId)}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(_.type)}</td>`,m+=`<td style="padding: 6px;">${g(_.severity)}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${U?"var(--ok-fg)":"var(--bad-fg)"};">${_.status}</span></td>`,m+=`<td style="padding: 6px;">${q}</td>`,m+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(_.createdAt)}</td>`,m+="</tr>"}m+="</table>"}M.innerHTML=m||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(a){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}async function i(){try{const t=await(await fetch("/api/v1/health-checks/status")).json(),n=t.success&&t.status?Object.values(t.status):[];if(n.length===0){$.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 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;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const m of n){const A=m.status==="up";r+='<tr style="border-bottom: 1px solid var(--border);">',r+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(m.name||m.serviceId)}</td>`,r+=`<td style="padding: 8px; color: ${A?"var(--ok-fg)":"var(--bad-fg)"};">${A?"Up":"Down"}</td>`,r+=`<td style="padding: 8px;">${m.sla?.target?m.sla.target+"%":"-"}</td>`,r+='<td style="padding: 8px; text-align: right;">',r+=`<button data-health-action="health-edit" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,r+=`<button data-health-action="health-delete" data-service-id="${escapeHtml(m.serviceId)}" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,r+="</td></tr>"}r+="</table>",$.innerHTML=r,$.querySelectorAll("[data-health-action]").forEach(m=>{m.addEventListener("click",()=>{document.dispatchEvent(new CustomEvent(m.dataset.healthAction,{detail:m.dataset.serviceId}))})})}catch(a){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(a.message)}</div>`}}function l(a,t,n,r,m,A,O){c=a||null,w.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=n||"",document.getElementById("health-form-timeout").value=r||1e4,document.getElementById("health-form-codes").value=m||"200",document.getElementById("health-form-sla").value=A||99.9,document.getElementById("health-form-slow").value=O||5e3,L.style.display="",y.style.display="none"}function s(){L.style.display="none",y.style.display="",c=null}y?.addEventListener("click",()=>l("","","",1e4,"200",99.9,5e3)),z?.addEventListener("click",s),x?.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 n=document.getElementById("health-form-codes").value.split(",").map(m=>parseInt(m.trim())).filter(Boolean),r={name:document.getElementById("health-form-name").value.trim()||a,url:t,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:n.length?n:[200],sla:{target:parseFloat(documenLine 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"),g=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function f(){try{const e=await(await fetch("/api/v1/updates/available")).json();if(!e.success)throw new Error(e.error);const i=e.updates||[];if(i.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 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;">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 i){const n=(()=>{const r=window.APPS||[];for(const m of r)if(m.containerId===t.containerId||m.name===t.containerName||m.id===t.containerName)return m.id;return t.containerName})();l+=`<tr data-app-id="${escapeHtml(n)}" style="border-bottom: 1px solid var(--border);">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,l+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,l+=`<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>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<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>`,l+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,l+="</td></tr>"}l+="</table>",N.innerHTML=l,R.textContent=i.length+" update(s) available";const s=document.getElementById("updates-count-badge"),a=document.getElementById("updates-update-all-btn");s&&(s.textContent=i.length+" pending",s.style.display=""),a&&i.length>0&&(a.style.display=""),window._pendingUpdates=i,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,r=t.dataset.name;if(confirm(`Update "${r}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(A.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>f(),2e3);else throw new Error(A.error||"Update failed")}catch(m){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+m.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 n=t.dataset.id,r=t.dataset.name;if(confirm(`Rollback "${r}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(n)}`,{method:"POST"})).json();if(A.success)t.textContent="Rolled back!",setTimeout(()=>f(),2e3);else throw new Error(A.error||"Rollback failed")}catch(m){t.textContent="Failed",showNotification("Rollback error: "+m.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 L(){const p=window._pendingUpdates||[];if(!p.length)return;const e=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;e.textContent="\u23F3 Updating...",e.disabled=!0;let i=0,l=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?i++:l++}catch{l++}e.textCLine truncated
|
|
|
|
|
</div>`);const b=document.getElementById("updates-modal"),h=document.getElementById("updates-btn"),j=document.getElementById("updates-cancel"),v=document.getElementById("updates-check-btn"),N=document.getElementById("updates-available-container"),M=document.getElementById("updates-history-container"),$=document.getElementById("updates-auto-container"),R=document.getElementById("updates-last-check");async function y(){try{const e=await(await fetch("/api/v1/updates/available")).json();if(!e.success)throw new Error(e.error);const i=e.updates||[];if(i.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 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;">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 i){const n=(()=>{const r=window.APPS||[];for(const m of r)if(m.containerId===t.containerId||m.name===t.containerName||m.id===t.containerName)return m.id;return t.containerName})();l+=`<tr data-app-id="${escapeHtml(n)}" style="border-bottom: 1px solid var(--border);">`,l+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(t.containerName)}</td>`,l+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(t.imageName)}</td>`,l+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(t.currentDigest)}</code></td>`,l+=`<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>`,l+='<td style="padding: 8px; text-align: right;">',l+=`<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>`,l+=`<button class="rollback-btn" data-id="${escapeHtml(t.containerId)}" data-name="${escapeHtml(t.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,l+="</td></tr>"}l+="</table>",N.innerHTML=l,R.textContent=i.length+" update(s) available";const s=document.getElementById("updates-count-badge"),a=document.getElementById("updates-update-all-btn");s&&(s.textContent=i.length+" pending",s.style.display=""),a&&i.length>0&&(a.style.display=""),window._pendingUpdates=i,N.querySelectorAll(".update-now-btn").forEach(t=>{t.addEventListener("click",async()=>{const n=t.dataset.id,r=t.dataset.name;if(confirm(`Update "${r}" to the latest version? The container will restart.`)){t.textContent="Updating...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(n)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(A.success)t.textContent="Done!",t.style.background="var(--ok-fg)",setTimeout(()=>y(),2e3);else throw new Error(A.error||"Update failed")}catch(m){t.textContent="Failed",t.style.color="var(--bad-fg)",showNotification("Update error: "+m.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 n=t.dataset.id,r=t.dataset.name;if(confirm(`Rollback "${r}" to its previous version?`)){t.textContent="Rolling back...",t.disabled=!0;try{const A=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(n)}`,{method:"POST"})).json();if(A.success)t.textContent="Rolled back!",setTimeout(()=>y(),2e3);else throw new Error(A.error||"Rollback failed")}catch(m){t.textContent="Failed",showNotification("Rollback error: "+m.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 L(){const p=window._pendingUpdates||[];if(!p.length)return;const e=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${p.length} containers? Each will restart.`))return;e.textContent="\u23F3 Updating...",e.disabled=!0;let i=0,l=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?i++:l++}catch{l++}e.textCLine truncated
|
|
|
|
|
<select class="auto-schedule" data-id="${escapeHtml(m)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
|
|
|
|
<option value=""${O?"":" selected"}>Disabled</option>
|
|
|
|
|
<option value="daily"${O==="daily"?" selected":""}>Daily</option>
|
|
|
|
|
<option value="weekly"${O==="weekly"?" selected":""}>Weekly</option>
|
|
|
|
|
<option value="monthly"${O==="monthly"?" selected":""}>Monthly</option>
|
|
|
|
|
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(m)}" value="${escapeHtml(U)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(m)}"${_?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(m)}" data-name="${escapeHtml(r)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",M.innerHTML=t,M.querySelectorAll(".save-auto-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.closest("tr"),A=m.querySelector(".auto-schedule").value,O=m.querySelector(".auto-rollback").checked,_=m.querySelector(".auto-window").value.trim();n.textContent="Saving...",n.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)n.textContent="\u2713 Saved";else throw new Error(q.error)}catch(U){n.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{n.textContent="Save",n.disabled=!1},2e3)})})}catch(p){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const z=document.getElementById("dashcaddy-current-version"),k=document.getElementById("dashcaddy-update-badge"),C=document.getElementById("dashcaddy-update-details"),D=document.getElementById("dashcaddy-new-version"),y=document.getElementById("dashcaddy-changelog"),T=document.getElementById("dashcaddy-apply-btn"),$=document.getElementById("dashcaddy-check-btn"),F=document.getElementById("dashcaddy-rollback-btn"),S=document.getElementById("dashcaddy-status-bar"),I=document.getElementById("dashcaddy-history-container");let B=null;function E(p,e){S&&(S.style.display="block",S.style.background=e==="error"?"var(--bad-bg)":e==="success"?"var(--ok-bg)":"var(--bg)",S.style.color=e==="error"?"var(--bad-fg)":e==="success"?"var(--ok-fg)":"var(--fg)",S.textContent=p)}async function u(){try{const e=await(await fetch("/api/v1/system/version")).json();if(e.success){const i=e.commit&&e.commit!=="unknown"?e.commit:null;z.textContent="v"+e.version+(i?" ("+i.substring(0,7)+")":"")}}catch{z.textContent="Unable to fetch version"}}async function o(p){p||($.textContent="Checking...",$.disabled=!0);try{const i=await(await fetch("/api/v1/system/update-check")).json();if(B=i,i.success&&i.available&&i.remote){k.style.display="",C.style.display="",D.textContent="v"+i.remote.version,y.textContent=i.remote.changelog||"No changelog available.";const l=document.getElementById("updates-btn");if(l&&!l.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);",l.style.position="relative",l.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 k.style.display="none",C.style.display="none",await u(),p||E("You are running the latest version.","success");p||($.textContent="Check for Updates",$.disabled=!1)}catch(e){p||(E("Failed to check: "+e.message,"error"),$.textContent="Check for Updates",$.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;T.textContent="Updating...",T.disabled=!0,E("Downloading and applying update...","info");try{const e=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(e.success)return E("Update initiated: v"+(e.fromVersion||"?")+" \u2192 v"+(e.toVersion||"?")+". The container will restart shortly.","success"),T.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(i=>i.remove()),!0;throw new Error(e.error||"Update failed")}catch(p){throw E("Update failed: "+p.message,"error"),T.textContent="Update Now",T.disabled=!1,p}}async function d(){try{const e=await(await fetch("/api/v1/system/update-history")).json(),i=e.success&&e.history?e.history:[];if(i.length===0){I.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
</select></td>`,t+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(m)}" value="${escapeHtml(U)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,t+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(m)}"${_?" checked":""} /></td>`,t+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${q}</td>`,t+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(m)}" data-name="${escapeHtml(r)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,t+="</tr>"}t+="</table>",$.innerHTML=t,$.querySelectorAll(".save-auto-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.closest("tr"),A=m.querySelector(".auto-schedule").value,O=m.querySelector(".auto-rollback").checked,_=m.querySelector(".auto-window").value.trim();n.textContent="Saving...",n.disabled=!0;try{const q=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!A,schedule:A||"weekly",autoRollback:O,maintenanceWindow:_||void 0})})).json();if(q.success)n.textContent="\u2713 Saved";else throw new Error(q.error)}catch(U){n.textContent="\u2717 Error",showNotification("Save error: "+U.message,"error")}setTimeout(()=>{n.textContent="Save",n.disabled=!1},2e3)})})}catch(p){$.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.message)}</div>`}}const C=document.getElementById("dashcaddy-current-version"),k=document.getElementById("dashcaddy-update-badge"),T=document.getElementById("dashcaddy-update-details"),P=document.getElementById("dashcaddy-new-version"),f=document.getElementById("dashcaddy-changelog"),B=document.getElementById("dashcaddy-apply-btn"),D=document.getElementById("dashcaddy-check-btn"),F=document.getElementById("dashcaddy-rollback-btn"),S=document.getElementById("dashcaddy-status-bar"),I=document.getElementById("dashcaddy-history-container");let H=null;function E(p,e){S&&(S.style.display="block",S.style.background=e==="error"?"var(--bad-bg)":e==="success"?"var(--ok-bg)":"var(--bg)",S.style.color=e==="error"?"var(--bad-fg)":e==="success"?"var(--ok-fg)":"var(--fg)",S.textContent=p)}async function u(){try{const e=await(await fetch("/api/v1/system/version")).json();if(e.success){const i=e.commit&&e.commit!=="unknown"?e.commit:null;C.textContent="v"+e.version+(i?" ("+i.substring(0,7)+")":"")}}catch{C.textContent="Unable to fetch version"}}async function o(p){p||(D.textContent="Checking...",D.disabled=!0);try{const i=await(await fetch("/api/v1/system/update-check")).json();if(H=i,i.success&&i.available&&i.remote){k.style.display="",T.style.display="",P.textContent="v"+i.remote.version,f.textContent=i.remote.changelog||"No changelog available.";const l=document.getElementById("updates-btn");if(l&&!l.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);",l.style.position="relative",l.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 k.style.display="none",T.style.display="none",await u(),p||E("You are running the latest version.","success");p||(D.textContent="Check for Updates",D.disabled=!1)}catch(e){p||(E("Failed to check: "+e.message,"error"),D.textContent="Check for Updates",D.disabled=!1)}}async function c(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;B.textContent="Updating...",B.disabled=!0,E("Downloading and applying update...","info");try{const e=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(e.success)return E("Update initiated: v"+(e.fromVersion||"?")+" \u2192 v"+(e.toVersion||"?")+". The container will restart shortly.","success"),B.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(i=>i.remove()),!0;throw new Error(e.error||"Update failed")}catch(p){throw E("Update failed: "+p.message,"error"),B.textContent="Update Now",B.disabled=!1,p}}async function d(){try{const e=await(await fetch("/api/v1/system/update-history")).json(),i=e.success&&e.history?e.history:[];if(i.length===0){I.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let l='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';l+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
|
|
|
|
`+i.join(`
|
|
|
|
|
`)+`
|
|
|
|
|
|
|
|
|
|
Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){showNotification("Invalid version: "+l,"error");return}if(!confirm("Rollback DashCaddy to v"+l+"? The container will restart."))return;E("Rolling back to v"+l+"...","info");const a=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:l})})).json();if(a.success)E("Rollback to v"+l+" initiated. Container will restart.","success");else throw new Error(a.error||"Rollback failed")}catch(p){E("Rollback failed: "+p.message,"error")}}$?.addEventListener("click",()=>o(!1)),T?.addEventListener("click",()=>c().catch(()=>{})),F?.addEventListener("click",v),g?.addEventListener("click",w),h?.addEventListener("click",()=>{b?.classList.add("show"),f()}),wireModal(b,j),window.openUpdateModal=function(p){b?.classList.add("show"),f().then(()=>{if(!p)return;const e=N.querySelector(`[data-app-id="${p}"]`);e&&(e.scrollIntoView({behavior:"smooth",block:"center"}),e.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{e.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),d(),B||o(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=o,setTimeout(()=>o(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
|
|
|
|
Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){showNotification("Invalid version: "+l,"error");return}if(!confirm("Rollback DashCaddy to v"+l+"? The container will restart."))return;E("Rolling back to v"+l+"...","info");const a=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:l})})).json();if(a.success)E("Rollback to v"+l+" initiated. Container will restart.","success");else throw new Error(a.error||"Rollback failed")}catch(p){E("Rollback failed: "+p.message,"error")}}D?.addEventListener("click",()=>o(!1)),B?.addEventListener("click",()=>c().catch(()=>{})),F?.addEventListener("click",g),v?.addEventListener("click",w),h?.addEventListener("click",()=>{b?.classList.add("show"),y()}),wireModal(b,j),window.openUpdateModal=function(p){b?.classList.add("show"),y().then(()=>{if(!p)return;const e=N.querySelector(`[data-app-id="${p}"]`);e&&(e.scrollIntoView({behavior:"smooth",block:"center"}),e.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{e.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",z),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",x),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{u(),d(),H||o(!0)}),window.dcApplyUpdate=c,window.dcCheckForUpdate=o,setTimeout(()=>o(!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(!l)return;if(!i.includes(l)){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 g(R){if(!R||R===0)return"0 B";const f=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,L)).toFixed(1)+" "+f[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 w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of L){const x=H.name==="buildkit"||H.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(H.name)}">${escapeHtml(H.name.length>40?H.name.substring(0,37)+"...":H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.querySelectorAll(".dr-vol-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete volume "${H.dataset.name}"? Data will be lost.`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(H.dataset.name)}?force=true`),N()}catch(x){showNotification("Delete failed: "+x.message,"error"),H.textContent="Delete",H.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(L){showNotification("Create failed: "+L.message,"error")}});async function P(){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 w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const H of L){const x=["bridge","host","none"].includes(H.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<button class="dr-net-del" data-id="${escapeHtml(H.id)}" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.querySelectorAll(".dr-net-del").forEach(H=>{H.addEventListener("click",async()=>{if(confirm(`Delete network "${H.dataset.name}"?`)){H.textContent="...",H.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(H.dataset.id)}`),P()}catch(x){showNotification("Delete failed: "+x.message,"error"),H.textContent="Delete",H.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"),L=R.value.trim();if(!L){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:L,driver:f.value}),R.value="",showNotification(`Network "${L}" created`,"success"),P()}catch(w){showNotification("Create failed: "+w.message,"error")}});async fuLine truncated
|
|
|
|
|
</div>`);const b=document.getElementById("docker-resources-modal"),h=document.getElementById("docker-resources-btn"),j=document.getElementById("dr-close");function v(R){if(!R||R===0)return"0 B";const y=["B","KB","MB","GB","TB"],L=Math.floor(Math.log(Math.abs(R))/Math.log(1024));return(R/Math.pow(1024,L)).toFixed(1)+" "+y[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 w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const z of L){const x=z.name==="buildkit"||z.name.length===64;w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500; max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${escapeHtml(z.name)}">${escapeHtml(z.name.length>40?z.name.substring(0,37)+"...":z.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(z.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(z.scope)}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<button class="dr-vol-del" data-name="${escapeHtml(z.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.querySelectorAll(".dr-vol-del").forEach(z=>{z.addEventListener("click",async()=>{if(confirm(`Delete volume "${z.dataset.name}"? Data will be lost.`)){z.textContent="...",z.disabled=!0;try{await deleteAPI(`/api/v1/docker/volumes/${encodeURIComponent(z.dataset.name)}?force=true`),N()}catch(x){showNotification("Delete failed: "+x.message,"error"),z.textContent="Delete",z.disabled=!1}}})})}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-vol-name"),y=R.value.trim();if(!y){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:y}),R.value="",showNotification(`Volume "${y}" created`,"success"),N()}catch(L){showNotification("Create failed: "+L.message,"error")}});async function M(){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 w='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';w+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 6px; text-align: left;">Name</th><th style="padding: 6px;">Driver</th><th style="padding: 6px;">Scope</th><th style="padding: 6px;">Containers</th><th style="padding: 6px; text-align: right;">Actions</th></tr>';for(const z of L){const x=["bridge","host","none"].includes(z.name);w+='<tr style="border-bottom: 1px solid var(--border);">',w+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(z.name)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(z.driver)}</td>`,w+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(z.scope)}</td>`,w+=`<td style="padding: 6px; text-align: center;">${z.containers}</td>`,w+='<td style="padding: 6px; text-align: right;">',x||(w+=`<button class="dr-net-del" data-id="${escapeHtml(z.id)}" data-name="${escapeHtml(z.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),w+="</td></tr>"}w+="</table>",R.innerHTML=w,R.querySelectorAll(".dr-net-del").forEach(z=>{z.addEventListener("click",async()=>{if(confirm(`Delete network "${z.dataset.name}"?`)){z.textContent="...",z.disabled=!0;try{await deleteAPI(`/api/v1/docker/networks/${encodeURIComponent(z.dataset.id)}`),M()}catch(x){showNotification("Delete failed: "+x.message,"error"),z.textContent="Delete",z.disabled=!1}}})})}catch(y){R.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(y.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const R=document.getElementById("dr-net-name"),y=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:y.value}),R.value="",showNotification(`Network "${L}" created`,"success"),M()}catch(w){showNotification("Create failed: "+w.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,8 +1752,8 @@ Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){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 g=null;function N(M){document.getElementById("compose-step-paste").style.display=M==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=M==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=M==="progress"?"":"none"}h?.addEventListener("click",()=>{N("paste"),g=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const R=M.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 M=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const f=document.getElementById("compose-parse-btn"),L=f.textContent;f.textContent="Parsing...",f.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:R});g=w,g.stackName=R,P(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{f.textContent=L,f.disabled=!1}});function P(M){const R=document.getElementById("compose-preview-content");let f="";M.networks&&M.networks.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(f+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),f+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,f+='<div class="scroll-container" style="max-height: 350px;">';for(const L of M.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(f+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,f+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(f+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),f+="</div>",!L.skip&&(f+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),L.volumes?.length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(f+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(f+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const H=[];L.resources.cpus&&H.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&H.push(`Mem: ${L.resources.memory}MB`),f+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.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 M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.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 L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(f.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of f.results){const H=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${H} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{M.textContent="Deploy All",M.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
|
|
|
</div>`);const b=document.getElementById("compose-import-modal"),h=document.getElementById("compose-import-btn"),j=document.getElementById("compose-cancel");wireModal(b,j);let v=null;function N($){document.getElementById("compose-step-paste").style.display=$==="paste"?"":"none",document.getElementById("compose-step-preview").style.display=$==="preview"?"":"none",document.getElementById("compose-step-progress").style.display=$==="progress"?"":"none"}h?.addEventListener("click",()=>{N("paste"),v=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",b?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",$=>{const R=$.target.files[0];if(!R)return;const y=new FileReader;y.onload=()=>{document.getElementById("compose-yaml").value=y.result},y.readAsText(R)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const $=document.getElementById("compose-yaml").value.trim(),R=document.getElementById("compose-stack-name").value.trim()||"stack";if(!$){showNotification("Paste a docker-compose.yml","warning");return}const y=document.getElementById("compose-parse-btn"),L=y.textContent;y.textContent="Parsing...",y.disabled=!0;try{const w=await postJSON("/api/v1/apps/import-compose",{yaml:$,stackName:R});v=w,v.stackName=R,M(w),N("preview")}catch(w){showNotification("Parse failed: "+w.message,"error")}finally{y.textContent=L,y.disabled=!1}});function M($){const R=document.getElementById("compose-preview-content");let y="";$.networks&&$.networks.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${$.networks.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),$.volumes&&$.volumes.length>0&&(y+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${$.volumes.map(L=>`<code>${escapeHtml(L)}</code>`).join(", ")}</div>`),y+=`<div style="font-weight: 600; margin-bottom: 8px;">${$.services.length} service(s)</div>`,y+='<div class="scroll-container" style="max-height: 350px;">';for(const L of $.services){const w=L.skip?"var(--bad-fg)":"var(--border)";if(y+=`<div style="padding: 10px 14px; border: 1px solid ${w}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,y+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(L.name)}`,L.skip&&(y+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(L.reason)}</span>`),y+="</div>",!L.skip&&(y+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(L.image)}</code></div>`,L.ports?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${L.ports.map(z=>`${z.host}:${z.container}`).join(", ")}</div>`),L.volumes?.length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${L.volumes.length}</div>`),Object.keys(L.environment||{}).length&&(y+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(L.environment).length}</div>`),L.envFileWarning&&(y+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(L.envFileWarning)}</div>`),L.resources?.cpus||L.resources?.memory)){const z=[];L.resources.cpus&&z.push(`CPU: ${L.resources.cpus}`),L.resources.memory&&z.push(`Mem: ${L.resources.memory}MB`),y+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${z.join(", ")}</div>`}y+="</div>"}y+="</div>",R.innerHTML=y}document.getElementById("compose-back-btn")?.addEventListener("click",()=>N("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!v)return;const $=document.getElementById("compose-deploy-btn");$.textContent="Deploying...",$.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 y=await postJSON("/api/v1/apps/deploy-compose",{services:v.services,networks:v.networks,stackName:v.stackName});let L=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(y.stackName)}" \u2014 Deployment Complete</div>`;L+='<div class="scroll-container" style="max-height: 350px;">';for(const w of y.results){const z=w.status==="deployed"||w.status==="created"?"\u2705":w.status==="exists"?"\u26A1":w.status==="skipped"?"\u23ED":"\u274C";L+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',L+=`${z} <strong>${escapeHtml(w.name)}</strong> (${w.type}) \u2014 ${escapeHtml(w.status)}`,w.error&&(L+=` <span style="color: var(--bad-fg);">${escapeHtml(w.error)}</span>`),w.subdomain&&(L+=` \u2192 <code>${escapeHtml(w.subdomain)}</code>`),w.reason&&(L+=` <span style="color: var(--muted);">(${escapeHtml(w.reason)})</span>`),L+="</div>"}L+="</div>",L+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
|
|
|
|
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>N("paste"))}finally{$.textContent="Deploy All",$.disabled=!1}})})(),(function(){injectModal("exec-modal",`<div id="exec-modal" class="weather-modal">
|
|
|
|
|
<div class="weather-modal-content" style="min-width: 700px; max-width: 900px; padding-bottom: 0;">
|
|
|
|
|
<h3 id="exec-title">Terminal</h3>
|
|
|
|
|
<div id="exec-terminal" style="height: 420px; border-radius: 6px; overflow: hidden; background: #1e1e1e;"></div>
|
|
|
|
@@ -1761,11 +1761,11 @@ Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){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 g=null,N=null,P=null;function M(){if(N){try{N.close()}catch{}N=null}if(g){try{g.dispose()}catch{}g=null}P=null,h.innerHTML=""}function R(f,L){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||f}`,b?.classList.add("show"),typeof Terminal>"u"){h.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"&&(P=new FitAddon.FitAddon,g.loadAddon(P)),g.open(h),P&&setTimeout(()=>P.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(f)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(g.writeln("\x1B[32mConnecting...\x1B[0m"),P){const x=P.proposeDimensions();x&&N.send(JSON.stringify({type:"resize",cols:x.cols,rows:x.rows}))}},N.onmessage=x=>{if(typeof x.data=="string"){try{const z=JSON.parse(x.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(x.data)}else g.write(new Uint8Array(x.data))},N.onclose=()=>{g&&g.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{g&&g.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},g.onData(x=>{N&&N.readyState===WebSocket.OPEN&&N.send(x)}),g.onResize(({cols:x,rows:z})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:x,rows:z}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),b._resizeHandler=H}j?.addEventListener("click",()=>{M(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",f=>{f.target===b&&(M(),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 b=document.getElementById("exec-modal"),h=document.getElementById("exec-terminal"),j=document.getElementById("exec-close");let v=null,N=null,M=null;function $(){if(N){try{N.close()}catch{}N=null}if(v){try{v.dispose()}catch{}v=null}M=null,h.innerHTML=""}function R(y,L){if($(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${L||y}`,b?.classList.add("show"),typeof Terminal>"u"){h.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}v=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"&&(M=new FitAddon.FitAddon,v.loadAddon(M)),v.open(h),M&&setTimeout(()=>M.fit(),50);const w=location.protocol==="https:"?"wss:":"ws:";N=new WebSocket(`${w}//${location.host}/ws/exec/${encodeURIComponent(y)}`),N.binaryType="arraybuffer",N.onopen=()=>{if(v.writeln("\x1B[32mConnecting...\x1B[0m"),M){const x=M.proposeDimensions();x&&N.send(JSON.stringify({type:"resize",cols:x.cols,rows:x.rows}))}},N.onmessage=x=>{if(typeof x.data=="string"){try{const C=JSON.parse(x.data);if(C.type==="connected"){v.writeln(`\x1B[32mConnected (${C.shell})\x1B[0m\r
|
|
|
|
|
`);return}if(C.type==="error"){v.writeln(`\x1B[31mError: ${C.message}\x1B[0m`);return}if(C.type==="exit"){v.writeln(`\r
|
|
|
|
|
\x1B[33mSession ended.\x1B[0m`);return}}catch{}v.write(x.data)}else v.write(new Uint8Array(x.data))},N.onclose=()=>{v&&v.writeln(`\r
|
|
|
|
|
\x1B[33mDisconnected.\x1B[0m`)},N.onerror=()=>{v&&v.writeln(`\r
|
|
|
|
|
\x1B[31mConnection error.\x1B[0m`)},v.onData(x=>{N&&N.readyState===WebSocket.OPEN&&N.send(x)}),v.onResize(({cols:x,rows:C})=>{N&&N.readyState===WebSocket.OPEN&&N.send(JSON.stringify({type:"resize",cols:x,rows:C}))});const z=()=>{M&&M.fit()};window.addEventListener("resize",z),b._resizeHandler=z}j?.addEventListener("click",()=>{$(),b._resizeHandler&&window.removeEventListener("resize",b._resizeHandler),b?.classList.remove("show")}),b?.addEventListener("click",y=>{y.target===b&&($(),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 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(!l)return;if(!i.includes(l)){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"),g=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),f=document.getElementById("audit-until"),L=document.getElementById("audit-log-container"),w=document.getElementById("audit-load-more");let H=0,x=null,z=0;const k=50;function C($){if(!$)return null;const F=new Date($);return isNaN(F.getTime())?null:F.toISOString()}async function D($){try{$?(x&&x.abort(),x=new AbortController):(x&&x.abort(),x=new AbortController,H=0,z++,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const F=z,S=new URLSearchParams;S.set("limit",String(k)),S.set("offset",String(H));const I=P.value,B=M.value,E=C(R.value),u=C(f.value);I&&S.set("action",I),B&&S.set("outcome",B),E&&S.set("since",E),u&&S.set("until",u);const o=await fetch("/api/v1/audit-logs?"+S.toString(),{signal:x.signal});if(!o.ok){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${o.status}</div>`,w.style.display="none";return}const c=await o.json();if(!c.success){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.error||"unknown")}</div>`,w.style.display="none";return}if(!$&&F!==z)return;const d=Array.isArray(c.entries)?c.entries:[];if(d.length===0&&!$){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.";L.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,w.style.display="none";return}let v="";$||(v='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',v+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',v+='<th style="padding: 6px; text-align: left;">When</th>',v+='<th style="padding: 6px; text-align: left;">Actor</th>',v+='<th style="padding: 6px; text-align: left;">IP</th>',v+='<th style="padding: 6px; text-align: left;">Action</th>',v+='<th style="padding: 6px; text-align: left;">Resource</th>',v+='<th style="padding: 6px; text-align: left;">Result</th>',v+="</tr>");for(const p of d){const e=p.outcome==="success",i=y(p);v+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',v+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,v+=`<td style="padding: 6px; font-size: 0.78rem;">${i}</td>`,v+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,v+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,v+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,v+=`<td style="padding: 6px;"><span style="color: ${e?"var(--ok-fg)":"var(--bad-fg)"};">${e?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,v+="</tr>",p.details&&Object.keys(p.details).length>0&&(v+=`<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(!$)v+="</table>",L.innerHTML=v;else{const p=L.querySelector("table");p&&p.insertAdjacentHTML("beforeend",v)}H+=d.length,w.style.display=c.hasMore?"":"none",L.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const e=p.nextElementSibling;e&&e.classList.contains("audit-detail")&&(e.style.display=e.style.display==="none"?"":"none")}))})}catch(F){if(F&&F.name==="AbortError")return;L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(F.message)}</div>`}}function y($){const F=$.details||{},S=F.userEmail,I=F.userId,B=F.userRole,E=F.viaProvider;if(S){const u=B?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(B)}${E?"/"+escapeHtml(E):""}]</span>`:"";return`${escapeHtml(S)}${u}`}return I?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(I)}</span>`:$.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}h?.addEventListener("click",()=>{b?.classList.add("show"),D(!1)}),wireModal(b,j),g?.addEventListener("click",()=>D(!1)),P?.addEventListener("change",()=>D(!1)),M?.addEventListener("change",()=>D(!1));let T;[R,f].forEach($=>{$?.addEventListener("change",()=>{clearTimeout(T),T=setTimeout(()=>D(!1),250)})}),w?.addEventListener("click",()=>D(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const F=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
|
|
|
|
|
</div>`);const b=document.getElementById("audit-modal"),h=document.getElementById("audit-log-btn"),j=document.getElementById("audit-cancel"),v=document.getElementById("audit-refresh-btn"),N=document.getElementById("audit-clear-btn"),M=document.getElementById("audit-filter"),$=document.getElementById("audit-outcome-filter"),R=document.getElementById("audit-since"),y=document.getElementById("audit-until"),L=document.getElementById("audit-log-container"),w=document.getElementById("audit-load-more");let z=0,x=null,C=0;const k=50;function T(D){if(!D)return null;const F=new Date(D);return isNaN(F.getTime())?null:F.toISOString()}async function P(D){try{D?(x&&x.abort(),x=new AbortController):(x&&x.abort(),x=new AbortController,z=0,C++,L.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const F=C,S=new URLSearchParams;S.set("limit",String(k)),S.set("offset",String(z));const I=M.value,H=$.value,E=T(R.value),u=T(y.value);I&&S.set("action",I),H&&S.set("outcome",H),E&&S.set("since",E),u&&S.set("until",u);const o=await fetch("/api/v1/audit-logs?"+S.toString(),{signal:x.signal});if(!o.ok){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${o.status}</div>`,w.style.display="none";return}const c=await o.json();if(!c.success){L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.error||"unknown")}</div>`,w.style.display="none";return}if(!D&&F!==C)return;const d=Array.isArray(c.entries)?c.entries:[];if(d.length===0&&!D){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.";L.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(p)}</div>`,w.style.display="none";return}let g="";D||(g='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',g+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',g+='<th style="padding: 6px; text-align: left;">When</th>',g+='<th style="padding: 6px; text-align: left;">Actor</th>',g+='<th style="padding: 6px; text-align: left;">IP</th>',g+='<th style="padding: 6px; text-align: left;">Action</th>',g+='<th style="padding: 6px; text-align: left;">Resource</th>',g+='<th style="padding: 6px; text-align: left;">Result</th>',g+="</tr>");for(const p of d){const e=p.outcome==="success",i=f(p);g+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',g+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(p.timestamp||"")}">${timeAgo(p.timestamp)}</td>`,g+=`<td style="padding: 6px; font-size: 0.78rem;">${i}</td>`,g+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(p.ip||"-")}</td>`,g+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(p.action||"-")}</td>`,g+=`<td style="padding: 6px;">${escapeHtml(p.resource||"-")}</td>`,g+=`<td style="padding: 6px;"><span style="color: ${e?"var(--ok-fg)":"var(--bad-fg)"};">${e?"\u2713":"\u2717"} ${escapeHtml(p.outcome||"")}</span></td>`,g+="</tr>",p.details&&Object.keys(p.details).length>0&&(g+=`<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(!D)g+="</table>",L.innerHTML=g;else{const p=L.querySelector("table");p&&p.insertAdjacentHTML("beforeend",g)}z+=d.length,w.style.display=c.hasMore?"":"none",L.querySelectorAll(".audit-row").forEach(p=>{p.dataset.wired||(p.dataset.wired="true",p.addEventListener("click",()=>{const e=p.nextElementSibling;e&&e.classList.contains("audit-detail")&&(e.style.display=e.style.display==="none"?"":"none")}))})}catch(F){if(F&&F.name==="AbortError")return;L.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(F.message)}</div>`}}function f(D){const F=D.details||{},S=F.userEmail,I=F.userId,H=F.userRole,E=F.viaProvider;if(S){const u=H?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(H)}${E?"/"+escapeHtml(E):""}]</span>`:"";return`${escapeHtml(S)}${u}`}return I?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(I)}</span>`:D.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),v?.addEventListener("click",()=>P(!1)),M?.addEventListener("change",()=>P(!1)),$?.addEventListener("change",()=>P(!1));let B;[R,y].forEach(D=>{D?.addEventListener("change",()=>{clearTimeout(B),B=setTimeout(()=>P(!1),250)})}),w?.addEventListener("click",()=>P(!0)),N?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const F=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,28 +1890,28 @@ Enter version to rollback to:`);if(!l)return;if(!i.includes(l)){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"),g=b.querySelectorAll(".sec-tab"),N=b.querySelectorAll(".sec-panel");let P=[],M=[],R=null;g.forEach(o=>{o.addEventListener("click",()=>{g.forEach(c=>c.classList.toggle("active",c===o)),N.forEach(c=>c.style.display=c.dataset.panel===o.dataset.tab?"":"none"),o.dataset.tab==="overview"&&H(),o.dataset.tab==="events"&&T(),o.dataset.tab==="hosts"&&I()})}),h&&h.addEventListener("click",()=>{b.classList.add("show"),H(),L()}),j.addEventListener("click",f),b.addEventListener("click",o=>{o.target===b&&f()});function f(){b.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",o=>{try{P=JSON.parse(o.data).events||[],$()}catch{}}),R.addEventListener("security",o=>{try{const c=JSON.parse(o.data);P.unshift(c),P.length>500&&(P.length=500);const d=b.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?$():d==="overview"&&H()}catch{}}),R.onerror=()=>{}}catch(o){console.warn("[security] SSE failed:",o.message)}}function w(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&L()});async function H(){try{const o=new Date(Date.now()-864e5).toISOString(),[c,d,v]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(o)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(o)}`)]),p=(await c.json()).data||{},e=(await d.json()).data?.hosts||[],i=(await v.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${i} 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=`${e.length} hosts`,x("sec-top-actors",p.top_actors||[]),x("sec-top-targets",p.top_targets||[])}catch(o){console.warn("[security] refreshOverview failed:",o.message)}}function x(o,c){const d=document.getElementById(o);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(v=>`<tr><td style="padding:3px 0;word-break:break-all;">${E(String(v.key))}</td><td style="text-align:right;color:var(--muted);">${v.count}</td></tr>`).join("")+"</table>"}const z=document.getElementById("sec-filter-source"),k=document.getElementById("sec-filter-severity"),C=document.getElementById("sec-filter-host"),D=document.getElementById("sec-filter-actor"),y=document.getElementById("sec-refresh-btn");[z,k,C].forEach(o=>o.addEventListener("change",T)),D.addEventListener("input",u(T,250)),y.addEventListener("click",T);async function T(){try{const o=new URLSearchParams;o.set("limit","200"),z.value&&o.set("source_type",z.value),k.value&&o.set("severity",k.value),C.value&&o.set("source_host",C.value),D.value&&o.set("actor_prefix",D.value),P=(await(await fetch(`/api/v1/security/events?${o}`)).json()).data.events||[],$(),(!C.options.length||C.options.length===1)&&await S()}catch(o){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}function $(){const o=document.getElementById("sec-events-container");if(!P.length){o.innerHTML='<div class="panel-empty">No events</div>';return}o.innerHTML=P.slice(0,200).map(F).join("")}function F(o){const c=o.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",v=o.ts?new Date(o.ts).toLocaleTimeString():"",p=o.source_type||"",e=o.actor||"\u2014",i=o.target||"",l=o.action||"",s=o.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
|
|
|
|
</div>`);const b=document.getElementById("security-modal"),h=document.getElementById("security-center-btn"),j=document.getElementById("sec-cancel"),v=b.querySelectorAll(".sec-tab"),N=b.querySelectorAll(".sec-panel");let M=[],$=[],R=null;v.forEach(o=>{o.addEventListener("click",()=>{v.forEach(c=>c.classList.toggle("active",c===o)),N.forEach(c=>c.style.display=c.dataset.panel===o.dataset.tab?"":"none"),o.dataset.tab==="overview"&&z(),o.dataset.tab==="events"&&B(),o.dataset.tab==="hosts"&&I()})}),h&&h.addEventListener("click",()=>{b.classList.add("show"),z(),L()}),j.addEventListener("click",y),b.addEventListener("click",o=>{o.target===b&&y()});function y(){b.classList.remove("show"),w()}function L(){if(w(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{R=new EventSource("/api/v1/security/events/stream"),R.addEventListener("init",o=>{try{M=JSON.parse(o.data).events||[],D()}catch{}}),R.addEventListener("security",o=>{try{const c=JSON.parse(o.data);M.unshift(c),M.length>500&&(M.length=500);const d=b.querySelector(".sec-tab.active")?.dataset?.tab;d==="events"?D():d==="overview"&&z()}catch{}}),R.onerror=()=>{}}catch(o){console.warn("[security] SSE failed:",o.message)}}function w(){if(R){try{R.close()}catch{}R=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{b.classList.contains("show")&&L()});async function z(){try{const o=new Date(Date.now()-864e5).toISOString(),[c,d,g]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(o)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(o)}`)]),p=(await c.json()).data||{},e=(await d.json()).data?.hosts||[],i=(await g.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${i} 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=`${e.length} hosts`,x("sec-top-actors",p.top_actors||[]),x("sec-top-targets",p.top_targets||[])}catch(o){console.warn("[security] refreshOverview failed:",o.message)}}function x(o,c){const d=document.getElementById(o);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(g=>`<tr><td style="padding:3px 0;word-break:break-all;">${E(String(g.key))}</td><td style="text-align:right;color:var(--muted);">${g.count}</td></tr>`).join("")+"</table>"}const C=document.getElementById("sec-filter-source"),k=document.getElementById("sec-filter-severity"),T=document.getElementById("sec-filter-host"),P=document.getElementById("sec-filter-actor"),f=document.getElementById("sec-refresh-btn");[C,k,T].forEach(o=>o.addEventListener("change",B)),P.addEventListener("input",u(B,250)),f.addEventListener("click",B);async function B(){try{const o=new URLSearchParams;o.set("limit","200"),C.value&&o.set("source_type",C.value),k.value&&o.set("severity",k.value),T.value&&o.set("source_host",T.value),P.value&&o.set("actor_prefix",P.value),M=(await(await fetch(`/api/v1/security/events?${o}`)).json()).data.events||[],D(),(!T.options.length||T.options.length===1)&&await S()}catch(o){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}function D(){const o=document.getElementById("sec-events-container");if(!M.length){o.innerHTML='<div class="panel-empty">No events</div>';return}o.innerHTML=M.slice(0,200).map(F).join("")}function F(o){const c=o.severity||"info",d={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[c]||"#7f8c8d",g=o.ts?new Date(o.ts).toLocaleTimeString():"",p=o.source_type||"",e=o.actor||"\u2014",i=o.target||"",l=o.action||"",s=o.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;">${E(c)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${E(p)}</span>
|
|
|
|
|
<span title="${E(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${E(e)}</span>
|
|
|
|
|
<span title="${E(i)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${E(l)} ${E(i)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;">${E(s)}</span>
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${E(v)}</span>
|
|
|
|
|
</div>`}async function S(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],d=C.value;C.innerHTML='<option value="">All hosts</option>'+c.map(v=>`<option value="${E(v.id)}">${E(v.label||v.id)}</option>`).join(""),d&&(C.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",B),document.getElementById("sec-hosts-refresh").addEventListener("click",I);async function I(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=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(v=>{const p=v.enabled?v.last_seen_at?Date.now()-Date.parse(v.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
|
|
|
|
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${E(g)}</span>
|
|
|
|
|
</div>`}async function S(){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(g=>`<option value="${E(g.id)}">${E(g.label||g.id)}</option>`).join(""),d&&(T.value=d)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",H),document.getElementById("sec-hosts-refresh").addEventListener("click",I);async function I(){try{const c=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];$=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(g=>{const p=g.enabled?g.last_seen_at?Date.now()-Date.parse(g.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>${E(v.label||v.id)}</strong>
|
|
|
|
|
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${E(v.type)}</span>
|
|
|
|
|
<strong>${E(g.label||g.id)}</strong>
|
|
|
|
|
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${E(g.type)}</span>
|
|
|
|
|
<div style="color:var(--muted);font-size:0.78rem;margin-top:2px;">
|
|
|
|
|
id: ${E(v.id)} \xB7
|
|
|
|
|
registered ${new Date(v.registered_at).toLocaleDateString()} \xB7
|
|
|
|
|
last seen ${v.last_seen_at?new Date(v.last_seen_at).toLocaleString():"never"}
|
|
|
|
|
id: ${E(g.id)} \xB7
|
|
|
|
|
registered ${new Date(g.registered_at).toLocaleDateString()} \xB7
|
|
|
|
|
last seen ${g.last_seen_at?new Date(g.last_seen_at).toLocaleString():"never"}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div>
|
|
|
|
|
<span style="font-size:0.85rem;margin-right:12px;">${p}</span>
|
|
|
|
|
${v.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${E(v.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
|
|
|
|
${g.id==="self"?"":`<button class="btn-sm sec-host-del" data-id="${E(g.id)}" style="color:var(--bad-fg);">Remove</button>`}
|
|
|
|
|
</div>
|
|
|
|
|
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(v=>{v.addEventListener("click",async()=>{confirm(`Remove host ${v.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(v.dataset.id)}`,{method:"DELETE"}),I())})})}catch(o){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}async function B(){const o=prompt("Host id (lowercase, no spaces):");if(!o)return;const c=prompt("Display label:",o)||o,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const v=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:o,label:c,type:d})}),p=await v.json();if(!v.ok){alert("Failed: "+(p?.error?.message||v.statusText));return}alert(`\u2705 Host registered!
|
|
|
|
|
</div>`}).join(""),d.querySelectorAll(".sec-host-del").forEach(g=>{g.addEventListener("click",async()=>{confirm(`Remove host ${g.dataset.id}? Events already received will remain in the store.`)&&(await fetch(`/api/v1/security/hosts/${encodeURIComponent(g.dataset.id)}`,{method:"DELETE"}),I())})})}catch(o){document.getElementById("sec-hosts-container").innerHTML='<div class="panel-empty">Load failed: '+E(o.message)+"</div>"}}async function H(){const o=prompt("Host id (lowercase, no spaces):");if(!o)return;const c=prompt("Display label:",o)||o,d=prompt('Type ("dashcaddy", "service", or "agent"):',"agent")||"agent";try{const g=await fetch("/api/v1/security/hosts",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({id:o,label:c,type:d})}),p=await g.json();if(!g.ok){alert("Failed: "+(p?.error?.message||g.statusText));return}alert(`\u2705 Host registered!
|
|
|
|
|
|
|
|
|
|
id: ${p.data.host.id}
|
|
|
|
|
label: ${p.data.host.label}
|
|
|
|
@@ -1922,7 +1922,7 @@ type: ${p.data.host.type}
|
|
|
|
|
${p.data.api_key}
|
|
|
|
|
|
|
|
|
|
Send this key as: Authorization: Bearer <api_key>
|
|
|
|
|
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),I()}catch(v){alert("Failed: "+v.message)}}function E(o){return String(o).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])}function u(o,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>o.apply(this,arguments),c)}}})(),window.__dc133_buildRepoOptions=function(b,h){var j=h||function(g){return String(g).replace(/[&<>"']/g,function(N){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(b||[]).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(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`),I()}catch(g){alert("Failed: "+g.message)}}function E(o){return String(o).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[c])}function u(o,c){let d;return function(){clearTimeout(d),d=setTimeout(()=>o.apply(this,arguments),c)}}})(),window.__dc133_buildRepoOptions=function(b,h){var j=h||function(v){return String(v).replace(/[&<>"']/g,function(N){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[N]})};return'<option value="">\u2014 choose a repo \u2014</option>'+(b||[]).map(function(v){return'<option value="'+j(v.url)+'" data-id="'+j(v.id)+'">'+j(v.full_name)+(v.description?" \u2014 "+j(v.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">
|
|
|
|
|
<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`),I()}catch(v)
|
|
|
|
|
<button class="btn" id="dep-close">Close</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const b=D=>document.getElementById(D);let h=null,j=!1;function g(D){return window.escapeHtml?window.escapeHtml(String(D)):String(D).replace(/[&<>"']/g,y=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[y])}async function N(D,y){const T=await fetch("/dashcaddy-api/api/v1/deploys"+D,Object.assign({credentials:"include"},y||{}));let $=null;try{$=await T.json()}catch{$={success:!1,error:"non-JSON response"}}return{status:T.status,body:$}}function P(D){const y=b("dep-output");y.style.display="block",y.textContent=D||"(no output)",y.scrollTop=y.scrollHeight}async function M(){const D=b("dep-services"),{body:y}=await N("/services");if(!y.success){D.innerHTML="<em>"+g(y.error||"unavailable")+"</em>";return}const T=y.services||[];if(!T.length){D.innerHTML="<em>No shipdeck deployments on record yet.</em>";return}D.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>'+T.map($=>"<tr><td><strong>"+g($.name)+"</strong></td><td>"+g($.last_action)+"</td><td>"+g($.last_time)+"</td><td>"+g($.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+g($.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+g($.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const D=b("dep-repo-select"),{body:y}=await N("/repos");if(!y.success){D.innerHTML="<option>"+g(y.error||"unavailable")+"</option>";return}const T=y.repos||[];D.innerHTML=T.length?T.map($=>'<option value="'+g($.dir)+'">'+g($.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function f(D){const y=b("dep-journal"),T=D?"?service="+encodeURIComponent(D):"",{body:$}=await N("/journal"+T),F=$.rows||[];if(!F.length){y.innerHTML="<em>No journal rows.</em>";return}y.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>'+F.map(S=>"<tr><td>"+g(S.time)+"</td><td>"+g(S.service)+"</td><td>"+g(S.action)+"</td><td>"+g(S.epoch)+"</td><td>"+g(S.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function L(){const D=document.getElementById("dep-gh-gitea"),y=document.getElementById("dep-gh-gitea-host"),T=document.getElementById("dep-gh-gitea-token"),$=document.getElementById("dep-gh-anonymous"),F={};if(y&&y.value.trim()&&(F.gitea_url="https://"+y.value.trim().replace(/^https?:\/*/,"")),$&&$.checked)F.token="";else{const S=window.__dc133_requestToken(!1,T&&T.value);S!==void 0&&(F.token=S)}try{const{status:S,body:I}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(F)});if(!I.success||!(I.repos||[]).length){D.innerHTML='<option value="">'+g(I.error||"no repos found")+"</option>";return}D.innerHTML=window.__dc133_buildRepoOptions(I.repos,g)}catch{D.innerHTML='<option value="">unavailable</option>'}}async function w(){const D=document.getElementById("dep-gh-btn"),y=document.getElementById("dep-gh-url").value.trim(),T=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),$=document.getElementById("dep-gh-args").value.trim();if(!y||!T){P("Repo URL and name are required.");return}const F=$?$.split(/\s+/):[];H(!0,D,"Install"),P("Installing "+y+`
|
|
|
|
|
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const S=!!(document.getElementById("dep-gh-anonymous")||{}).checked,I=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,B=window.__dc133_requestToken(S,I),{status:E,body:u}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:y,service:T,args:F,token:B})});if(!u.success){P((u.output?u.output+`
|
|
|
|
|
`:"")+"FAIL "+(u.error||"HTTP "+E)),H(!1,D,"Install");return}const o=u.service||{};if(!(window.APPS||[]).some(d=>d.id===o.id)){const d={id:o.id,name:o.name||o.id,url:o.url,logo:o.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{}}P("INSTALLED "+(o.name||o.id)+" in "+(o.deploy_seconds||"?")+`s
|
|
|
|
|
</div>`);const b=P=>document.getElementById(P);let h=null,j=!1;function v(P){return window.escapeHtml?window.escapeHtml(String(P)):String(P).replace(/[&<>"']/g,f=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[f])}async function N(P,f){const B=await fetch("/dashcaddy-api/api/v1/deploys"+P,Object.assign({credentials:"include"},f||{}));let D=null;try{D=await B.json()}catch{D={success:!1,error:"non-JSON response"}}return{status:B.status,body:D}}function M(P){const f=b("dep-output");f.style.display="block",f.textContent=P||"(no output)",f.scrollTop=f.scrollHeight}async function $(){const P=b("dep-services"),{body:f}=await N("/services");if(!f.success){P.innerHTML="<em>"+v(f.error||"unavailable")+"</em>";return}const B=f.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(D=>"<tr><td><strong>"+v(D.name)+"</strong></td><td>"+v(D.last_action)+"</td><td>"+v(D.last_time)+"</td><td>"+v(D.last_epoch)+'</td><td><button class="btn btn-sm dep-rollback" data-service="'+v(D.name)+'">Rollback</button> <button class="btn btn-sm dep-status" data-service="'+v(D.name)+'">Status</button></td></tr>').join("")+"</tbody></table>"}async function R(){const P=b("dep-repo-select"),{body:f}=await N("/repos");if(!f.success){P.innerHTML="<option>"+v(f.error||"unavailable")+"</option>";return}const B=f.repos||[];P.innerHTML=B.length?B.map(D=>'<option value="'+v(D.dir)+'">'+v(D.name)+"</option>").join(""):'<option value="">(no repos with a Shipdeckfile found)</option>'}async function y(P){const f=b("dep-journal"),B=P?"?service="+encodeURIComponent(P):"",{body:D}=await N("/journal"+B),F=D.rows||[];if(!F.length){f.innerHTML="<em>No journal rows.</em>";return}f.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>'+F.map(S=>"<tr><td>"+v(S.time)+"</td><td>"+v(S.service)+"</td><td>"+v(S.action)+"</td><td>"+v(S.epoch)+"</td><td>"+v(S.duration||"\u2014")+"</td></tr>").join("")+"</tbody></table>"}async function L(){const P=document.getElementById("dep-gh-gitea"),f=document.getElementById("dep-gh-gitea-host"),B=document.getElementById("dep-gh-gitea-token"),D=document.getElementById("dep-gh-anonymous"),F={};if(f&&f.value.trim()&&(F.gitea_url="https://"+f.value.trim().replace(/^https?:\/*/,"")),D&&D.checked)F.token="";else{const S=window.__dc133_requestToken(!1,B&&B.value);S!==void 0&&(F.token=S)}try{const{status:S,body:I}=await N("/gitea-repos",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(F)});if(!I.success||!(I.repos||[]).length){P.innerHTML='<option value="">'+v(I.error||"no repos found")+"</option>";return}P.innerHTML=window.__dc133_buildRepoOptions(I.repos,v)}catch{P.innerHTML='<option value="">unavailable</option>'}}async function w(){const P=document.getElementById("dep-gh-btn"),f=document.getElementById("dep-gh-url").value.trim(),B=(document.getElementById("dep-gh-service").value.trim()||"").toLowerCase(),D=document.getElementById("dep-gh-args").value.trim();if(!f||!B){M("Repo URL and name are required.");return}const F=D?D.split(/\s+/):[];z(!0,P,"Install"),M("Installing "+f+`
|
|
|
|
|
Cloning, building, gating and DNS-ing... (~30-60s)`);try{const S=!!(document.getElementById("dep-gh-anonymous")||{}).checked,I=(document.getElementById("dep-gh-gitea-token")||{value:""}).value,H=window.__dc133_requestToken(S,I),{status:E,body:u}=await N("/install",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({repo_url:f,service:B,args:F,token:H})});if(!u.success){M((u.output?u.output+`
|
|
|
|
|
`:"")+"FAIL "+(u.error||"HTTP "+E)),z(!1,P,"Install");return}const o=u.service||{};if(!(window.APPS||[]).some(d=>d.id===o.id)){const d={id:o.id,name:o.name||o.id,url:o.url,logo:o.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{}}M("INSTALLED "+(o.name||o.id)+" in "+(o.deploy_seconds||"?")+`s
|
|
|
|
|
Card: `+(o.url||"")+`
|
|
|
|
|
`+(u.output||"").slice(-800)),M()}catch(S){P("install error: "+S.message)}H(!1,D,"Install")}function H(D,y,T){y&&(y.disabled=D,T&&(y.textContent=D?"Working\u2026":T))}async function x(){const D=b("dep-deploy-btn"),y=b("dep-repo-select").value;if(y){H(!0,D,"Deploy"),P("Deploying "+y+`
|
|
|
|
|
This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:y})});P((T.output||T.error||"")+(T.success?`
|
|
|
|
|
`+(u.output||"").slice(-800)),$()}catch(S){M("install error: "+S.message)}z(!1,P,"Install")}function z(P,f,B){f&&(f.disabled=P,B&&(f.textContent=P?"Working\u2026":B))}async function x(){const P=b("dep-deploy-btn"),f=b("dep-repo-select").value;if(f){z(!0,P,"Deploy"),M("Deploying "+f+`
|
|
|
|
|
This can take ~30-60s\u2026`);try{const{body:B}=await N("/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({dir:f})});M((B.output||B.error||"")+(B.success?`
|
|
|
|
|
\u2705 SUCCESS`:`
|
|
|
|
|
\u274C FAILED`)),M()}catch(T){P("deploy error: "+T.message)}H(!1,D,"Deploy")}}async function z(D,y){if(confirm("Roll back "+D+" to the previous release?")){H(!0,y,"Rollback"),P("Rolling back "+D+"\u2026");try{const{body:T}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:D})});P((T.output||T.error||"")+(T.success?`
|
|
|
|
|
\u274C FAILED`)),$()}catch(B){M("deploy error: "+B.message)}z(!1,P,"Deploy")}}async function C(P,f){if(confirm("Roll back "+P+" to the previous release?")){z(!0,f,"Rollback"),M("Rolling back "+P+"\u2026");try{const{body:B}=await N("/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({service:P})});M((B.output||B.error||"")+(B.success?`
|
|
|
|
|
\u2705 ROLLED BACK`:`
|
|
|
|
|
\u274C FAILED`)),M()}catch(T){P("rollback error: "+T.message)}H(!1,y,"Rollback")}}async function k(D,y){H(!0,y,"Status");try{const{body:T}=await N("/status?service="+encodeURIComponent(D));P(T.output||T.error||"(no output)")}catch(T){P("status error: "+T.message)}H(!1,y,"Status")}function C(){h=document.getElementById("deploys-btn"),h&&(h.addEventListener("click",async()=>{b("deploys-modal").classList.add("open"),j||(j=!0,M(),R(),f(""),L())}),b("dep-close").addEventListener("click",()=>{b("deploys-modal").classList.remove("open")}),document.querySelectorAll(".dep-tab").forEach(D=>{D.addEventListener("click",()=>{document.querySelectorAll(".dep-tab").forEach(y=>y.classList.remove("active")),D.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(y=>{y.style.display=y.dataset.panel===D.dataset.tab?"block":"none"})})}),b("dep-deploy-btn").addEventListener("click",x),b("dep-gh-btn").addEventListener("click",w),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(D=>{const y=document.getElementById(D);y&&y.addEventListener("change",()=>L())}),document.getElementById("dep-gh-gitea").addEventListener("change",D=>{const y=D.target.value;if(y){document.getElementById("dep-gh-url").value=y;const T=D.target.selectedOptions[0],$=T&&T.dataset?T.dataset.id:"",F=document.getElementById("dep-gh-service");$&&!F.value&&(F.value=$)}}),b("dep-journal-btn").addEventListener("click",()=>f(b("dep-journal-name").value.trim())),b("dep-status-btn").addEventListener("click",()=>{const D=b("dep-status-name").value.trim();D&&k(D)}),b("dep-services").addEventListener("click",D=>{const y=D.target.closest(".dep-rollback");if(y)return z(y.dataset.service,y);const T=D.target.closest(".dep-status");if(T)return k(T.dataset.service,T)}))}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",C):C()})(),(function(){const b=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
|
|
|
|
\u274C FAILED`)),$()}catch(B){M("rollback error: "+B.message)}z(!1,f,"Rollback")}}async function k(P,f){z(!0,f,"Status");try{const{body:B}=await N("/status?service="+encodeURIComponent(P));M(B.output||B.error||"(no output)")}catch(B){M("status error: "+B.message)}z(!1,f,"Status")}function T(){h=document.getElementById("deploys-btn"),h&&(h.addEventListener("click",async()=>{b("deploys-modal").classList.add("open"),j||(j=!0,$(),R(),y(""),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(f=>f.classList.remove("active")),P.classList.add("active"),document.querySelectorAll("#deploys-modal .dep-panel").forEach(f=>{f.style.display=f.dataset.panel===P.dataset.tab?"block":"none"})})}),b("dep-deploy-btn").addEventListener("click",x),b("dep-gh-btn").addEventListener("click",w),["dep-gh-gitea-host","dep-gh-gitea-token"].forEach(P=>{const f=document.getElementById(P);f&&f.addEventListener("change",()=>L())}),document.getElementById("dep-gh-gitea").addEventListener("change",P=>{const f=P.target.value;if(f){document.getElementById("dep-gh-url").value=f;const B=P.target.selectedOptions[0],D=B&&B.dataset?B.dataset.id:"",F=document.getElementById("dep-gh-service");D&&!F.value&&(F.value=D)}}),b("dep-journal-btn").addEventListener("click",()=>y(b("dep-journal-name").value.trim())),b("dep-status-btn").addEventListener("click",()=>{const P=b("dep-status-name").value.trim();P&&k(P)}),b("dep-services").addEventListener("click",P=>{const f=P.target.closest(".dep-rollback");if(f)return C(f.dataset.service,f);const B=P.target.closest(".dep-status");if(B)return k(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>
|
|
|
|
|
<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,7 +2026,7 @@ This can take ~30-60s\u2026`);try{const{body:T}=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",g="weather-geo",N="weather-unit";!safeGet(h)&&safeGet(j)&&safeSet(h,safeGet(j));function P(){return safeGet(N)||"imperial"}function M(){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"},L=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function w(y){return L[Math.round(y/22.5)%16]}async function H(y){const T=safeGet(g);if(T)try{const B=JSON.parse(T);if(B.query===y)return B}catch{}const $=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(y)}&count=1&language=en&format=json`);if(!$.ok)throw new Error("Geocoding failed");const F=await $.json();if(!F.results||!F.results.length)throw new Error("Location not found");const S=F.results[0],I={query:y,lat:S.latitude,lon:S.longitude,city:S.name,state:S.admin1||"",country:S.country||"",countryCode:S.country_code||""};return safeSet(g,JSON.stringify(I)),I}function x(y){return y.countryCode==="US"&&y.state?`${y.city}, ${y.state}`:y.country?`${y.city}, ${y.country}`:y.city}async function z(y){try{const T=await H(y),$=P(),F=$==="metric"?"celsius":"fahrenheit",S=$==="metric"?"kmh":"mph",I=`https://api.open-meteo.com/v1/forecast?latitude=${T.lat}&longitude=${T.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${F}&wind_speed_unit=${S}`,B=await fetch(I);if(!B.ok)throw new Error("Weather fetch failed");const u=(await B.json()).current,o=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:R[o]||"Unknown",icon:f[o]||"\u{1F324}\uFE0F",locationStr:x(T),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:$}}catch(T){return console.warn("Weather fetch failed:",T),null}}async function k(){const y=M();if(!y.icon||!y.temp||!y.condition||!y.location||!y.wind){console.warn("Weather widget elements not found");return}const T=safeGet(h);if(!T){y.location.textContent="Set Location",y.temp.textContent="--\xB0",y.condition.textContent="Click \u2699\uFE0F to configure",y.wind.textContent="--",y.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const $=await z(T);if($){const F=$.unit==="metric"?"\xB0C":"\xB0F",S=$.unit==="metric"?"km/h":"mph";y.location.textContent=$.locationStr,y.temp.textContent=`${$.temp}${F}`,y.condition.textContent=$.condition,y.wind.textContent=`Wind: ${$.windSpeed} ${S} ${$.windDir}`,y.icon.innerHTML=`<span class="weather-emoji">${escapeHtml($.icon)}</span>`}}catch($){b.logError("[Weather] Update Error",$,{function:"updateWeather"}),y.location.textContent="Weather Error",y.temp.textContent="Error",y.condition.textContent="Failed to load",y.wind.textContent="--"}}const C=document.getElementById("weather-modal"),D=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{D.value=safeGet(h)||"";const y=P(),T=C.querySelector(`input[name="weather-unit-radio"][value="${y}"]`);T&&(T.checked=!0),C.classList.add("show"),D.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{C.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const y=D.value.trim();if(y){safeGet(h)!==y&&safeSet(g,""),safeSet(h,y);const $=C.querySelector('input[name="weather-unit-radio"]:checked'),F=$?$.value:"imperial",S=P();safeSet(N,F),S!==F&&safeSet(g,""),C.classList.remove("show"),k()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(C),document.addEventListener("keydown",y=>{y.keyLine truncated
|
|
|
|
|
<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",v="weather-geo",N="weather-unit";!safeGet(h)&&safeGet(j)&&safeSet(h,safeGet(j));function M(){return safeGet(N)||"imperial"}function $(){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"},y={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 w(f){return L[Math.round(f/22.5)%16]}async function z(f){const B=safeGet(v);if(B)try{const H=JSON.parse(B);if(H.query===f)return H}catch{}const D=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(f)}&count=1&language=en&format=json`);if(!D.ok)throw new Error("Geocoding failed");const F=await D.json();if(!F.results||!F.results.length)throw new Error("Location not found");const S=F.results[0],I={query:f,lat:S.latitude,lon:S.longitude,city:S.name,state:S.admin1||"",country:S.country||"",countryCode:S.country_code||""};return safeSet(v,JSON.stringify(I)),I}function x(f){return f.countryCode==="US"&&f.state?`${f.city}, ${f.state}`:f.country?`${f.city}, ${f.country}`:f.city}async function C(f){try{const B=await z(f),D=M(),F=D==="metric"?"celsius":"fahrenheit",S=D==="metric"?"kmh":"mph",I=`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=${F}&wind_speed_unit=${S}`,H=await fetch(I);if(!H.ok)throw new Error("Weather fetch failed");const u=(await H.json()).current,o=u.weather_code;return{temp:Math.round(u.temperature_2m),condition:R[o]||"Unknown",icon:y[o]||"\u{1F324}\uFE0F",locationStr:x(B),windSpeed:Math.round(u.wind_speed_10m),windDir:w(u.wind_direction_10m),unit:D}}catch(B){return console.warn("Weather fetch failed:",B),null}}async function k(){const f=$();if(!f.icon||!f.temp||!f.condition||!f.location||!f.wind){console.warn("Weather widget elements not found");return}const B=safeGet(h);if(!B){f.location.textContent="Set Location",f.temp.textContent="--\xB0",f.condition.textContent="Click \u2699\uFE0F to configure",f.wind.textContent="--",f.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const D=await C(B);if(D){const F=D.unit==="metric"?"\xB0C":"\xB0F",S=D.unit==="metric"?"km/h":"mph";f.location.textContent=D.locationStr,f.temp.textContent=`${D.temp}${F}`,f.condition.textContent=D.condition,f.wind.textContent=`Wind: ${D.windSpeed} ${S} ${D.windDir}`,f.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(D.icon)}</span>`}}catch(D){b.logError("[Weather] Update Error",D,{function:"updateWeather"}),f.location.textContent="Weather Error",f.temp.textContent="Error",f.condition.textContent="Failed to load",f.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 f=M(),B=T.querySelector(`input[name="weather-unit-radio"][value="${f}"]`);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 f=P.value.trim();if(f){safeGet(h)!==f&&safeSet(v,""),safeSet(h,f);const D=T.querySelector('input[name="weather-unit-radio"]:checked'),F=D?D.value:"imperial",S=M();safeSet(N,F),S!==F&&safeSet(v,""),T.classList.remove("show"),k()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(T),document.addEventListener("keydown",f=>{f.keyLine truncated
|
|
|
|
|
<circle cx="${n}" cy="${r}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
|
|
|
|
${U}
|
|
|
|
|
${_}
|
|
|
|
@@ -2034,7 +2034,7 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
<line x1="${n}" y1="${r}" x2="${n+38*Math.cos(A*Math.PI/180)}" y2="${r+38*Math.sin(A*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
|
|
|
|
<line x1="${n}" y1="${r}" x2="${n+42*Math.cos(m*Math.PI/180)}" y2="${r+42*Math.sin(m*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
|
|
|
|
<circle cx="${n}" cy="${r}" r="3" fill="var(--fg)"/>
|
|
|
|
|
</svg>`,J=e.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${e.getHours()%12||12}:${String(s).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${z(e)}</span></div></div>`,L="analog"}function I(){const e=new Date,i=e.getHours()%12||12,l=e.getMinutes(),s=e.getSeconds(),a="clock-widget"+(P!=="default"?" "+P:"");switch(b.className!==a&&(b.className=a),P){case"lcd":y(e);break;case"lcd-blue":y(e);break;case"lcd-amber":y(e);break;case"lcd-retro":y(e);break;case"lcd-taxi":y(e);break;case"flip":T(e);break;case"binary":F(e);break;case"analog":S(e,!1);break;case"roman":S(e,!0);break;default:D(e)}l===0&&s===0&&i!==M&&(M=i,x(i)),l!==0&&(M=-1)}function B(){clearTimeout(H);const e=document.hidden?6e4:1e3,i=e-Date.now()%e+25;H=setTimeout(()=>{I(),B()},i)}document.addEventListener("visibilitychange",()=>{f="",k(),I(),B()}),I(),B();const E=[{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 u='<div class="clock-style-grid">';E.forEach(e=>{u+=`<label class="clock-style-option">
|
|
|
|
|
</svg>`,J=e.getHours()>=12?"PM":"AM";h.innerHTML=`<div class="analog-clock-wrap">${q}<div class="analog-info"><span class="analog-digital">${e.getHours()%12||12}:${String(s).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${C(e)}</span></div></div>`,L="analog"}function I(){const e=new Date,i=e.getHours()%12||12,l=e.getMinutes(),s=e.getSeconds(),a="clock-widget"+(M!=="default"?" "+M:"");switch(b.className!==a&&(b.className=a),M){case"lcd":f(e);break;case"lcd-blue":f(e);break;case"lcd-amber":f(e);break;case"lcd-retro":f(e);break;case"lcd-taxi":f(e);break;case"flip":B(e);break;case"binary":F(e);break;case"analog":S(e,!1);break;case"roman":S(e,!0);break;default:P(e)}l===0&&s===0&&i!==$&&($=i,x(i)),l!==0&&($=-1)}function H(){clearTimeout(z);const e=document.hidden?6e4:1e3,i=e-Date.now()%e+25;z=setTimeout(()=>{I(),H()},i)}document.addEventListener("visibilitychange",()=>{y="",k(),I(),H()}),I(),H();const E=[{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 u='<div class="clock-style-grid">';E.forEach(e=>{u+=`<label class="clock-style-option">
|
|
|
|
|
<input type="radio" name="clock-style-radio" value="${e.id}">
|
|
|
|
|
<span class="clock-style-card"><span class="clock-style-icon">${e.icon}</span><span class="clock-style-label">${e.label}</span></span>
|
|
|
|
|
</label>`}),u+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
|
|
|
@@ -2066,7 +2066,7 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
<button id="clock-settings-save">Save</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`);const o=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),v=document.getElementById("clock-volume-section");function p(){const e=safeGet("clock-style")||"default",i=o.querySelector(`input[value="${e}"]`);i&&(i.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",v.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{v.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),o.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const e=parseInt(d.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=e,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const e=o.querySelector('input[name="clock-style-radio"]:checked'),i=e?e.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),P=i,f="",k(),I(),B(),o.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{o.classList.remove("show")}),wireModal(o),o?.querySelectorAll('input[name="clock-style-radio"]').forEach(e=>{e.addEventListener("change",()=>{P=e.value,f="",k(),I()})})})(),(function(){function b(g){const N=new Date(g.start),P=isNaN(N.getTime())?`invalid:${g.start==null?"null":String(g.start)}`:N.toISOString();return g.uid?`${g.uid}@${P}`:`${P}|${g.title||""}`}function h(){const g=new Map;return{size:()=>g.size,merge(N,P){if(P)for(const[M,R]of g){const f=new Date(R.start).getTime();f>=P.start.getTime()&&f<=P.end.getTime()&&g.delete(M)}for(const M of N&&Array.isArray(N.events)?N.events:[])g.set(b(M),M);return{events:[...g.values()].sort((M,R)=>new Date(M.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>`);const o=document.getElementById("clock-settings-modal"),c=document.getElementById("clock-chimes-toggle"),d=document.getElementById("clock-chime-volume"),g=document.getElementById("clock-volume-section");function p(){const e=safeGet("clock-style")||"default",i=o.querySelector(`input[value="${e}"]`);i&&(i.checked=!0),c.checked=safeGet("clock-chimes")==="true",d.value=safeGet("clock-chime-volume")||"50",g.style.opacity=c.checked?"1":"0.4"}c?.addEventListener("change",()=>{g.style.opacity=c.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{p(),o.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const e=parseInt(d.value,10)/100,i=new Audio("/assets/sounds/church-bell.mp3");i.volume=e,i.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const e=o.querySelector('input[name="clock-style-radio"]:checked'),i=e?e.value:"default";safeSet("clock-style",i),safeSet("clock-chimes",String(c.checked)),safeSet("clock-chime-volume",d.value),M=i,y="",k(),I(),H(),o.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{o.classList.remove("show")}),wireModal(o),o?.querySelectorAll('input[name="clock-style-radio"]').forEach(e=>{e.addEventListener("change",()=>{M=e.value,y="",k(),I()})})})(),(function(){function b(v){const N=new Date(v.start),M=isNaN(N.getTime())?`invalid:${v.start==null?"null":String(v.start)}`:N.toISOString();return v.uid?`${v.uid}@${M}`:`${M}|${v.title||""}`}function h(){const v=new Map;return{size:()=>v.size,merge(N,M){if(M)for(const[$,R]of v){const y=new Date(R.start).getTime();y>=M.start.getTime()&&y<=M.end.getTime()&&v.delete($)}for(const $ of N&&Array.isArray(N.events)?N.events:[])v.set(b($),$);return{events:[...v.values()].sort(($,R)=>new Date($.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 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">
|
|
|
|
@@ -2118,9 +2118,9 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
</details>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="weather-modal-buttons"><button id="calendar-close">Close</button></div>
|
|
|
|
|
</div></div>`);function h(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 n=new Date(a),r=new Date;r.setHours(0,0,0,0);const m=new Date(n);m.setHours(0,0,0,0);const A=Math.round((m-r)/864e5),O=A===0?"Today":A===1?"Tomorrow":n.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),_=t?"all-day":n.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${O} ${_}`}function N(a,t){return t?"all-day":new Date(a).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function P(a){return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}-${String(a.getDate()).padStart(2,"0")}`}const M=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(a,t){return M?M.merge(a,t):a}let f=[];function L(a){const t=a||new Date,n=new Date(t.getFullYear(),t.getMonth()-1,1,0,0,0,0),r=new Date(t.getFullYear(),t.getMonth()+2,0,23,59,59,999);return{start:n,end:r}}function w(a){return f.some(t=>t.start.getTime()<=a.start.getTime()&&t.end.getTime()>=a.end.getTime())}function H(a){const t=f.filter(r=>r.end.getTime()>=a.start.getTime()&&r.start.getTime()<=a.end.getTime()),n=f.filter(r=>!t.includes(r));t.length&&(a={start:new Date(Math.min(a.start.getTime(),...t.map(r=>r.start.getTime()))),end:new Date(Math.max(a.end.getTime(),...t.map(r=>r.end.getTime())))}),f=n.concat([a]).sort((r,m)=>r.start-m.start)}let x=null,z={events:[]};async function k(a){const t=L(x);if(!a&&w(t))return z;const n=await secureFetch(`/api/v1/calendar/events?start=${t.start.toISOString()}&end=${t.end.toISOString()}`,{cache:"no-store"});if(n.status===404)return{notConfigured:!0};if(!n.ok)throw new Error(`Calendar API HTTP ${n.status}`);const r=await n.json();return z=R(r,t),H(t),z}function C(a){const t=j();if(!t.icon||!t.text)return;const n=T(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.icon.textContent="\u{1F4C5}",t.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const r=n[0];t.icon.textContent=r.allDay?"\u{1F5D3}\uFE0F":"\u23F0",t.text.innerHTML=`<span class="cal-chip-title">${h(r.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${h(g(r.start,r.allDay))}</span>`}function D(a){const t={};for(const n of a&&Array.isArray(a.events)?a.events:[]){const r=P(new Date(n.start));(t[r]=t[r]||[]).push(n)}return t}function y(a){const t=document.getElementById("cal-grid"),n=document.getElementById("cal-month-label");if(!t||!n)return;x||(x=new Date,x.setDate(1));const r=x.getFullYear(),m=x.getMonth();n.textContent=x.toLocaleDateString(void 0,{month:"long",year:"numeric"});const A=D(a),O=P(new Date),_=new Date(r,m,1).getDay(),U=new Date(r,m+1,0).getDate();let q=["S","M","T","W","T","F","S"].map(J=>`<div class="cal-dow">${J}</div>`).join("");for(let J=0;J<_;J++)q+='<div class="cal-day cal-pad"></div>';for(let J=1;J<=U;J++){const G=`${r}-${String(m+1).padStart(2,"0")}-${String(J).padStart(2,"0")}`,Z=A[G]||[],ee=["cal-day"];G===O&&ee.push("cal-today"),Z.length&&ee.push("cal-has-events");const ne=Z.slice(0,3).map(K=>`<span class="cal-dot" title="${h(N(K.start,K.allDay))}"></span>`).join(""),oe=Z.map(K=>`${N(K.start,K.allDay)} \u2014 ${K.title||"Untitled"}`).join(`
|
|
|
|
|
`);q+=`<div class="${ee.join(" ")}" title="${h(oe||G)}"><span class="cal-daynum">${J}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}t.innerHTML=q}function T(a){const t=Date.now();return(Array.isArray(a)?a:[]).filter(n=>(n.end?new Date(n.end).getTime():new Date(n.start).getTime())>=t).sort((n,r)=>new Date(n.start)-new Date(r.start))}function $(a){const t=document.getElementById("calendar-upcoming-list");if(!t)return;const n=T(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}t.innerHTML=n.slice(0,12).map(r=>`<div class="cal-row">
|
|
|
|
|
<span class="cal-when">${h(g(r.start,r.allDay))}</span>
|
|
|
|
|
</div></div>`);function h(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 v(a,t){const n=new Date(a),r=new Date;r.setHours(0,0,0,0);const m=new Date(n);m.setHours(0,0,0,0);const A=Math.round((m-r)/864e5),O=A===0?"Today":A===1?"Tomorrow":n.toLocaleDateString(void 0,{weekday:"short",month:"short",day:"numeric"}),_=t?"all-day":n.toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"});return`${O} ${_}`}function N(a,t){return t?"all-day":new Date(a).toLocaleTimeString(void 0,{hour:"numeric",minute:"2-digit"})}function M(a){return`${a.getFullYear()}-${String(a.getMonth()+1).padStart(2,"0")}-${String(a.getDate()).padStart(2,"0")}`}const $=typeof window<"u"&&window.CalendarCache?window.CalendarCache.createCache():null;function R(a,t){return $?$.merge(a,t):a}let y=[];function L(a){const t=a||new Date,n=new Date(t.getFullYear(),t.getMonth()-1,1,0,0,0,0),r=new Date(t.getFullYear(),t.getMonth()+2,0,23,59,59,999);return{start:n,end:r}}function w(a){return y.some(t=>t.start.getTime()<=a.start.getTime()&&t.end.getTime()>=a.end.getTime())}function z(a){const t=y.filter(r=>r.end.getTime()>=a.start.getTime()&&r.start.getTime()<=a.end.getTime()),n=y.filter(r=>!t.includes(r));t.length&&(a={start:new Date(Math.min(a.start.getTime(),...t.map(r=>r.start.getTime()))),end:new Date(Math.max(a.end.getTime(),...t.map(r=>r.end.getTime())))}),y=n.concat([a]).sort((r,m)=>r.start-m.start)}let x=null,C={events:[]};async function k(a){const t=L(x);if(!a&&w(t))return C;const n=await secureFetch(`/api/v1/calendar/events?start=${t.start.toISOString()}&end=${t.end.toISOString()}`,{cache:"no-store"});if(n.status===404)return{notConfigured:!0};if(!n.ok)throw new Error(`Calendar API HTTP ${n.status}`);const r=await n.json();return C=R(r,t),z(t),C}function T(a){const t=j();if(!t.icon||!t.text)return;const n=B(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.icon.textContent="\u{1F4C5}",t.text.innerHTML='<span class="cal-chip-muted">No upcoming appointments</span>';return}const r=n[0];t.icon.textContent=r.allDay?"\u{1F5D3}\uFE0F":"\u23F0",t.text.innerHTML=`<span class="cal-chip-title">${h(r.title||"Untitled")}</span><span class="cal-chip-when">\xB7 ${h(v(r.start,r.allDay))}</span>`}function P(a){const t={};for(const n of a&&Array.isArray(a.events)?a.events:[]){const r=M(new Date(n.start));(t[r]=t[r]||[]).push(n)}return t}function f(a){const t=document.getElementById("cal-grid"),n=document.getElementById("cal-month-label");if(!t||!n)return;x||(x=new Date,x.setDate(1));const r=x.getFullYear(),m=x.getMonth();n.textContent=x.toLocaleDateString(void 0,{month:"long",year:"numeric"});const A=P(a),O=M(new Date),_=new Date(r,m,1).getDay(),U=new Date(r,m+1,0).getDate();let q=["S","M","T","W","T","F","S"].map(J=>`<div class="cal-dow">${J}</div>`).join("");for(let J=0;J<_;J++)q+='<div class="cal-day cal-pad"></div>';for(let J=1;J<=U;J++){const G=`${r}-${String(m+1).padStart(2,"0")}-${String(J).padStart(2,"0")}`,Z=A[G]||[],ee=["cal-day"];G===O&&ee.push("cal-today"),Z.length&&ee.push("cal-has-events");const ne=Z.slice(0,3).map(K=>`<span class="cal-dot" title="${h(N(K.start,K.allDay))}"></span>`).join(""),oe=Z.map(K=>`${N(K.start,K.allDay)} \u2014 ${K.title||"Untitled"}`).join(`
|
|
|
|
|
`);q+=`<div class="${ee.join(" ")}" title="${h(oe||G)}"><span class="cal-daynum">${J}</span>${ne?`<span class="cal-dots">${ne}</span>`:""}</div>`}t.innerHTML=q}function B(a){const t=Date.now();return(Array.isArray(a)?a:[]).filter(n=>(n.end?new Date(n.end).getTime():new Date(n.start).getTime())>=t).sort((n,r)=>new Date(n.start)-new Date(r.start))}function D(a){const t=document.getElementById("calendar-upcoming-list");if(!t)return;const n=B(a&&Array.isArray(a.events)?a.events:[]);if(!n.length){t.innerHTML='<div class="cal-empty">No upcoming appointments.</div>';return}t.innerHTML=n.slice(0,12).map(r=>`<div class="cal-row">
|
|
|
|
|
<span class="cal-when">${h(v(r.start,r.allDay))}</span>
|
|
|
|
|
<span class="cal-evtitle">${h(r.title||"Untitled")}</span>
|
|
|
|
|
${r.location?`<span class="cal-loc">${h(r.location)}</span>`:""}
|
|
|
|
|
</div>`).join("")}async function F(){const a=document.getElementById("calendar-sources-list");if(!a)return;let t=[];try{const n=await secureFetch("/api/v1/calendar/sources",{cache:"no-store"});if(n.ok){const r=await n.json();t=r&&Array.isArray(r.sources)?r.sources:Array.isArray(r)?r:[]}}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(n=>`
|
|
|
|
@@ -2129,7 +2129,7 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
<span class="cal-loc">\xB7 ${h(n.type)}${n.autoProvisioned?" \xB7 auto-wired":""}</span></span>
|
|
|
|
|
<button class="cal-src-toggle cal-btn" data-id="${h(n.id)}" data-enabled="${n.enabled!==!1?"1":"0"}">${n.enabled!==!1?"\u2713 on":"off"}</button>
|
|
|
|
|
<button class="cal-src-del cal-btn cal-btn-danger" data-id="${h(n.id)}">remove</button>
|
|
|
|
|
</div>`).join(""),a.querySelectorAll(".cal-src-toggle").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.dataset.enabled!=="1";n.disabled=!0;try{const A=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:m})});if(!A.ok)throw new Error(`HTTP ${A.status}`);F(),I(!0)}catch(A){showNotification(`Calendar source update failed: ${A.message}`,"error")}finally{n.disabled=!1}})),a.querySelectorAll(".cal-src-del").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id;n.disabled=!0;try{const m=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"DELETE"});if(!m.ok)throw new Error(`HTTP ${m.status}`);F(),I(!0)}catch(m){showNotification(`Calendar source removal failed: ${m.message}`,"error")}finally{n.disabled=!1}}))}let S=null;async function I(a){if(I._inFlight){a&&(I._pendingForce=!0);return}I._inFlight=!0;try{let t=await k(!!a);if(a&&x){const n=x;x=null;const r=await k(!0);x=n;const m=await k(!0);C(r),y(m),$(r),S=r,I._inFlight=!1;return}if(x){const n=x;x=null;const r=await k(!1);x=n;const m=await k(!1);C(r),y(m),$(r),S=r,I._inFlight=!1;return}S=t,C(t),document.getElementById("calendar-modal").classList.contains("show")&&(y(t),$(t))}catch(t){b.logError("calendar-widget",t);const n=j();n.text&&(n.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{I._inFlight=!1,I._pendingForce&&(I._pendingForce=!1,I(!0))}}let B=null;function E(){B=document.activeElement,document.getElementById("calendar-modal").classList.add("show"),y(S||{events:[]}),$(S||{events:[]}),F(),I(),document.getElementById("calendar-close").focus()}function u(){document.getElementById("calendar-modal").classList.remove("show"),B&&typeof B.focus=="function"&&B.focus()}function o(){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=o();if(!a.url){c("Enter a URL first.");return}c("Testing\u2026");try{const n=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(),r=n&&n.probe;r&&r.ok?c(`\u2713 Reachable (${r.server||"server"})${r.looksLikeCalendar?" \u2014 looks like a calendar":""}`):c(`\u2717 ${r&&r.error?r.error:"Not reachable"}`)}catch(t){c(`\u2717 ${t.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const a=o();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 n=await t.json().catch(()=>({}));c(`\u2717 ${n.error||n.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(n=>{document.getElementById(n).value=""}),F(),I(!0)}catch(t){c(`\u2717 ${t.message}`)}});const d=document.getElementById("calendar-widget");d&&(d.addEventListener("click",E),d.addEventListener("keydown",a=>{(a.key==="Enter"||a.key===" "||a.key==="Spacebar")&&(a.preventDefault(),E())}));const v=document.getElementById("calendar-settings");v&&v.addEventListener("click",a=>{a.stopPropagation(),E()});const p=document.getElementById("calendar-modal");p&&(document.getElementById("calendar-close").addEventListener("click",u),p.addEventListener("click",a=>{a.target===p&&u()}),document.addEventListener("keydown",a=>{a.key==="Escape"&&p.classList.contains("show")&&u()}));async function e(a){x||(x=new Date,x.setDate(1)),x=new Date(x.getFullYear(),x.getMonth()+a,1);const t=await k(!1).catch(()=>S);y(t||{events:[]})}const i=document.getElementById("cal-prev");i&&i.addEventListener("click",()=>{e(-1)});const l=document.getElementById("cal-next");l&&l.addEventListener("click",()=>{e(1)}),window.refreshCalendarWidget=I;const s=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(()=>{document.hidden||I(!0)},s),document.addEventListener("visibilitychange",()=>{document.hidden||I(!0)}),setTimeout(I,400)})(),(function(){const b=new ErrorHandler;injectModal("ai-connect-modal",`<div id="ai-connect-modal" class="weather-modal">
|
|
|
|
|
</div>`).join(""),a.querySelectorAll(".cal-src-toggle").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id,m=n.dataset.enabled!=="1";n.disabled=!0;try{const A=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:m})});if(!A.ok)throw new Error(`HTTP ${A.status}`);F(),I(!0)}catch(A){showNotification(`Calendar source update failed: ${A.message}`,"error")}finally{n.disabled=!1}})),a.querySelectorAll(".cal-src-del").forEach(n=>n.addEventListener("click",async()=>{const r=n.dataset.id;n.disabled=!0;try{const m=await secureFetch(`/api/v1/calendar/sources/${encodeURIComponent(r)}`,{method:"DELETE"});if(!m.ok)throw new Error(`HTTP ${m.status}`);F(),I(!0)}catch(m){showNotification(`Calendar source removal failed: ${m.message}`,"error")}finally{n.disabled=!1}}))}let S=null;async function I(a){if(I._inFlight){a&&(I._pendingForce=!0);return}I._inFlight=!0;try{let t=await k(!!a);if(a&&x){const n=x;x=null;const r=await k(!0);x=n;const m=await k(!0);T(r),f(m),D(r),S=r,I._inFlight=!1;return}if(x){const n=x;x=null;const r=await k(!1);x=n;const m=await k(!1);T(r),f(m),D(r),S=r,I._inFlight=!1;return}S=t,T(t),document.getElementById("calendar-modal").classList.contains("show")&&(f(t),D(t))}catch(t){b.logError("calendar-widget",t);const n=j();n.text&&(n.text.innerHTML='<span class="cal-chip-muted">Calendar offline</span>')}finally{I._inFlight=!1,I._pendingForce&&(I._pendingForce=!1,I(!0))}}let H=null;function E(){H=document.activeElement,document.getElementById("calendar-modal").classList.add("show"),f(S||{events:[]}),D(S||{events:[]}),F(),I(),document.getElementById("calendar-close").focus()}function u(){document.getElementById("calendar-modal").classList.remove("show"),H&&typeof H.focus=="function"&&H.focus()}function o(){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=o();if(!a.url){c("Enter a URL first.");return}c("Testing\u2026");try{const n=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(),r=n&&n.probe;r&&r.ok?c(`\u2713 Reachable (${r.server||"server"})${r.looksLikeCalendar?" \u2014 looks like a calendar":""}`):c(`\u2717 ${r&&r.error?r.error:"Not reachable"}`)}catch(t){c(`\u2717 ${t.message}`)}}),document.getElementById("cal-add-save").addEventListener("click",async()=>{const a=o();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 n=await t.json().catch(()=>({}));c(`\u2717 ${n.error||n.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(n=>{document.getElementById(n).value=""}),F(),I(!0)}catch(t){c(`\u2717 ${t.message}`)}});const d=document.getElementById("calendar-widget");d&&(d.addEventListener("click",E),d.addEventListener("keydown",a=>{(a.key==="Enter"||a.key===" "||a.key==="Spacebar")&&(a.preventDefault(),E())}));const g=document.getElementById("calendar-settings");g&&g.addEventListener("click",a=>{a.stopPropagation(),E()});const p=document.getElementById("calendar-modal");p&&(document.getElementById("calendar-close").addEventListener("click",u),p.addEventListener("click",a=>{a.target===p&&u()}),document.addEventListener("keydown",a=>{a.key==="Escape"&&p.classList.contains("show")&&u()}));async function e(a){x||(x=new Date,x.setDate(1)),x=new Date(x.getFullYear(),x.getMonth()+a,1);const t=await k(!1).catch(()=>S);f(t||{events:[]})}const i=document.getElementById("cal-prev");i&&i.addEventListener("click",()=>{e(-1)});const l=document.getElementById("cal-next");l&&l.addEventListener("click",()=>{e(1)}),window.refreshCalendarWidget=I;const s=typeof DC<"u"&&DC.POLL&&DC.POLL.CALENDAR||3e5;setInterval(()=>{document.hidden||I(!0)},s),document.addEventListener("visibilitychange",()=>{document.hidden||I(!0)}),setTimeout(I,400)})(),(function(){const b=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:T}=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=f=>document.getElementById(f);let j=null;function g(f){h("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 L=await f.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(f){b.log(f,"ai-connect-info"),g("MCP endpoint unavailable on this build.")}}async function P(){const f=h("aiconn-connections");try{const w=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),H=w&&Array.isArray(w.connections)?w.connections:[];if(!H.length){f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}f.innerHTML=H.map(x=>`
|
|
|
|
|
</div></div>`);const h=y=>document.getElementById(y);let j=null;function v(y){h("aiconn-status").textContent=y}async function N(){try{const y=await secureFetch("/api/v1/mcp/info");if(!y.ok)throw new Error(`HTTP ${y.status}`);const L=await y.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(y){b.log(y,"ai-connect-info"),v("MCP endpoint unavailable on this build.")}}async function M(){const y=h("aiconn-connections");try{const w=await(await secureFetch("/api/v1/mcp/connections")).json().catch(()=>({})),z=w&&Array.isArray(w.connections)?w.connections:[];if(!z.length){y.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">No connections yet \u2014 choose an assistant above.</div>';return}y.innerHTML=z.map(x=>`
|
|
|
|
|
<div style="display:flex;gap:8px;align-items:center;font-size:0.8rem;">
|
|
|
|
|
<span style="flex:1;">${escapeHtml(x.name)}
|
|
|
|
|
<span style="color:var(--muted);">\xB7 ${(x.scopes||[]).join(",")} \xB7 since ${escapeHtml((x.createdAt||"").slice(0,10))}${x.lastUsed?" \xB7 last used "+escapeHtml(String(x.lastUsed).slice(0,10)):""}</span></span>
|
|
|
|
|
<button class="aiconn-revoke" data-id="${escapeHtml(x.id)}" style="font-size:0.75rem;color:var(--err-fg);">Revoke</button>
|
|
|
|
|
</div>`).join(""),f.querySelectorAll(".aiconn-revoke").forEach(x=>x.addEventListener("click",async()=>{x.disabled=!0;try{const z=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(x.dataset.id)}`,{method:"DELETE"});if(z.ok)g("Connection revoked."),P();else{const k=await z.json().catch(()=>({}));g(k.error||"Revoke failed."),x.disabled=!1}}catch(z){g("Revoke failed: "+z.message),x.disabled=!1}}))}catch{f.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function M(f){g(`Creating ${f} connection\u2026`);try{const L=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:f})}),w=await L.json().catch(()=>({}));if(!L.ok||!w.key){g(w.error||`Failed (HTTP ${L.status})`);return}h("aiconn-key").textContent=w.key;const H=w.httpConfig?JSON.stringify(w.httpConfig,null,2):"";h("aiconn-snippet").textContent=H||"(see note below)",h("aiconn-client-note").textContent=w.note||"",h("aiconn-config-title").textContent=f==="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",g(""),P()}catch(L){b.log(L,"ai-connect-create"),g("Failed: "+L.message)}}function R(f,L){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(f).then(()=>g(L)).catch(()=>g("Copy failed \u2014 select manually.")):g("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",g(""),N(),P()}),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(f=>f.addEventListener("click",()=>M(f.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[f,L]of Object.entries(R.status)){const w=document.getElementById("uptime-"+f),H=document.getElementById("uptime-bar-"+f);if(!w)continue;const x=L.uptime?.["24h"];if(x!=null){const z=x.toFixed(1);w.textContent=`${z}% uptime`,w.className="uptime-chip",x>=99.9?w.classList.add("excellent"):x>=99?w.classList.add("good"):x>=95?w.classList.add("degraded"):w.classList.add("poor"),H&&(H.style.width=z+"%")}}}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 g(M){try{const f=await(await fetch("/api/v1/updates/available")).json();if(!f.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=f.updates||[];if(j=L,M&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const H=window.APPS||[];for(const x of H)if(x.containerId===w.containerId||x.id===w.containerName||x.name===w.containerName){if(h.has(x.id))break;const z=document.getElementById("update-badge-"+x.id),k=document.getElementById("update-btn-"+x.id);z&&(z.classList.add("visible"),z.title="Update available \u2014 click to open Update Management.",z.style.cursor="pointer",z.onclick=C=>{C.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(x.id)}),k&&(k.style.background="#f97316",k.style.borderColor="#f97316",k.style.boxShadow="0 0 6px #f9731688",k.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{b(),g()},5e3),setInterval(()=>{b(),g(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(b,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),N()})(),(function(){var b=null,h=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>`).join(""),y.querySelectorAll(".aiconn-revoke").forEach(x=>x.addEventListener("click",async()=>{x.disabled=!0;try{const C=await secureFetch(`/api/v1/auth/keys/${encodeURIComponent(x.dataset.id)}`,{method:"DELETE"});if(C.ok)v("Connection revoked."),M();else{const k=await C.json().catch(()=>({}));v(k.error||"Revoke failed."),x.disabled=!1}}catch(C){v("Revoke failed: "+C.message),x.disabled=!1}}))}catch{y.innerHTML='<div style="font-size:0.8rem;color:var(--muted);">Could not load connections.</div>'}}async function $(y){v(`Creating ${y} connection\u2026`);try{const L=await secureFetch("/api/v1/mcp/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({client:y})}),w=await L.json().catch(()=>({}));if(!L.ok||!w.key){v(w.error||`Failed (HTTP ${L.status})`);return}h("aiconn-key").textContent=w.key;const z=w.httpConfig?JSON.stringify(w.httpConfig,null,2):"";h("aiconn-snippet").textContent=z||"(see note below)",h("aiconn-client-note").textContent=w.note||"",h("aiconn-config-title").textContent=y==="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",v(""),M()}catch(L){b.log(L,"ai-connect-create"),v("Failed: "+L.message)}}function R(y,L){navigator.clipboard&&navigator.clipboard.writeText?navigator.clipboard.writeText(y).then(()=>v(L)).catch(()=>v("Copy failed \u2014 select manually.")):v("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",v(""),N(),M()}),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(y=>y.addEventListener("click",()=>$(y.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[y,L]of Object.entries(R.status)){const w=document.getElementById("uptime-"+y),z=document.getElementById("uptime-bar-"+y);if(!w)continue;const x=L.uptime?.["24h"];if(x!=null){const C=x.toFixed(1);w.textContent=`${C}% uptime`,w.className="uptime-chip",x>=99.9?w.classList.add("excellent"):x>=99?w.classList.add("good"):x>=95?w.classList.add("degraded"):w.classList.add("poor"),z&&(z.style.width=C+"%")}}}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 v($){try{const y=await(await fetch("/api/v1/updates/available")).json();if(!y.success)return;document.querySelectorAll(".update-available-badge").forEach(w=>w.classList.remove("visible"));const L=y.updates||[];if(j=L,$&&L.length>0){const w=window._lastKnownUpdateCount||0;w>0&&L.length>w&&showNotification(`${L.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=L.length}if(!L.length)return;for(const w of L){const z=window.APPS||[];for(const x of z)if(x.containerId===w.containerId||x.id===w.containerName||x.name===w.containerName){if(h.has(x.id))break;const C=document.getElementById("update-badge-"+x.id),k=document.getElementById("update-btn-"+x.id);C&&(C.classList.add("visible"),C.title="Update available \u2014 click to open Update Management.",C.style.cursor="pointer",C.onclick=T=>{T.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(x.id)}),k&&(k.style.background="#f97316",k.style.borderColor="#f97316",k.style.boxShadow="0 0 6px #f9731688",k.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function N(){setTimeout(()=>{b(),v()},5e3),setInterval(()=>{b(),v(!0)},6e4)}const M=window.refreshAll;M&&(window.refreshAll=async function(){try{await M(),setTimeout(b,1e3)}catch($){console.warn("[Card Badges] Error in refreshAll hook:",$.message)}}),N()})(),(function(){var b=null,h=null,j={},v={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:T}=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"),g=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),f=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let x=null;function z(){N.style.display="none",P.style.display="none"}function k(B){z(),N.textContent=B,N.style.display="block"}function C(B){z(),P.textContent=B,P.style.display="block"}function D(B){if(x=B,B.active){f.style.background="rgba(46,204,113,0.15)",f.style.color="var(--ok-fg)",M.textContent="\u2605",R.textContent="Premium Active";const o=B.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(B.expiresAt).toLocaleDateString()}</strong> (${B.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${B.code||"***"}</code></div>
|
|
|
|
|
</div>`);const b=document.getElementById("license-modal"),h=document.getElementById("license-code-input"),j=document.getElementById("license-activate"),v=document.getElementById("license-deactivate"),N=document.getElementById("license-error"),M=document.getElementById("license-success"),$=document.getElementById("license-badge-icon"),R=document.getElementById("license-badge-text"),y=document.getElementById("license-badge"),L=document.getElementById("license-details"),w=document.getElementById("license-feature-list"),z=document.getElementById("license-activate-section");let x=null;function C(){N.style.display="none",M.style.display="none"}function k(H){C(),N.textContent=H,N.style.display="block"}function T(H){C(),M.textContent=H,M.style.display="block"}function P(H){if(x=H,H.active){y.style.background="rgba(46,204,113,0.15)",y.style.color="var(--ok-fg)",$.textContent="\u2605",R.textContent="Premium Active";const o=H.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(H.expiresAt).toLocaleDateString()}</strong> (${H.daysRemaining} days remaining)</div>`;L.innerHTML=`
|
|
|
|
|
<div>Code: <code style="font-family: monospace;">${H.code||"***"}</code></div>
|
|
|
|
|
${o}
|
|
|
|
|
`,H.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)",M.textContent="\u2606",R.textContent=B.expired?"License Expired":"Free Tier",L.innerHTML=B.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",j.style.display="",g.style.display="none";const E=B.premiumFeatures||{},u=new Set(B.features||[]);w.innerHTML=Object.entries(E).map(([o,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);">
|
|
|
|
|
`,z.style.display="none",j.style.display="none",v.style.display=""}else y.style.background="rgba(149,165,166,0.15)",y.style.color="var(--muted)",$.textContent="\u2606",R.textContent=H.expired?"License Expired":"Free Tier",L.innerHTML=H.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",z.style.display="",j.style.display="",v.style.display="none";const E=H.premiumFeatures||{},u=new Set(H.features||[]);w.innerHTML=Object.entries(E).map(([o,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;">${u.has(o)?"\u2705":"\u{1F512}"}</span>
|
|
|
|
|
<div>
|
|
|
|
|
<div style="font-weight: 600; font-size: 0.9rem;">${c.name}</div>
|
|
|
|
|
<div style="font-size: 0.78rem; color: var(--muted);">${c.description}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>`).join("")}async function y(){try{const E=await(await fetch("/api/v1/license/status")).json();E.success&&(D(E.license),F(E.license))}catch(B){console.warn("Failed to load license status:",B.message)}}async function T(){const B=h.value.trim();if(!B){k("Please enter a license code.");return}z(),j.disabled=!0,j.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:B})})).json();u.success?(C(u.message),h.value="",D(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),F(u.license)):k(u.error||"Activation failed")}catch(E){k("Network error: "+E.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function $(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){g.disabled=!0,g.textContent="Deactivating...";try{const E=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();E.success?(C(E.message),await y(),showNotification("License deactivated.","info",3e3),F({active:!1})):k(E.error||"Deactivation failed")}catch(B){k("Network error: "+B.message)}finally{g.disabled=!1,g.textContent="Deactivate"}}}function F(B){const E=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),o=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(E)if(E.className="license-status-topbar "+(B.active?"premium":"free"),B.active)if(u.textContent="\u2605",o.textContent="PREMIUM",B.lifetime)c.textContent="\xB7 LIFETIME";else{const d=B.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else u.textContent="\u2606",o.textContent=B.expired?"EXPIRED":"FREE TIER",c.textContent=""}function S(){z(),y(),b.classList.add("show")}h.addEventListener("input",function(){let B=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(B.length>this._prevLength&&(B=B.replace(/-/g,""),B.length>2&&!B.startsWith("DC")&&(B="DC"+B),B.startsWith("DC")&&B.length>2)){const E=["DC"],u=B.substring(2);for(let o=0;o<u.length;o+=5)E.push(u.substring(o,o+5));B=E.join("-")}this._prevLength=B.length,this.value=B}),j.addEventListener("click",T),g.addEventListener("click",$),h.addEventListener("keydown",B=>{B.key==="Enter"&&T()}),wireModal(b,document.getElementById("license-cancel"));const I=document.getElementById("license-status-topbar");I&&I.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=S,window.checkPremiumFeature=async function(B){try{return(await(await fetch(`/api/v1/license/feature/${B}`)).json()).available}catch{return!1}},y().then(B=>{x&&F(x)})})(),(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 f(){try{const E=await(await fetch("/api/v1/license/status")).json();E.success&&(P(E.license),F(E.license))}catch(H){console.warn("Failed to load license status:",H.message)}}async function B(){const H=h.value.trim();if(!H){k("Please enter a license code.");return}C(),j.disabled=!0,j.textContent="Activating...";try{const u=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:H})})).json();u.success?(T(u.message),h.value="",P(u.license),showNotification("License activated! Premium features unlocked.","success",5e3),F(u.license)):k(u.error||"Activation failed")}catch(E){k("Network error: "+E.message)}finally{j.disabled=!1,j.textContent="Activate"}}async function D(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){v.disabled=!0,v.textContent="Deactivating...";try{const E=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();E.success?(T(E.message),await f(),showNotification("License deactivated.","info",3e3),F({active:!1})):k(E.error||"Deactivation failed")}catch(H){k("Network error: "+H.message)}finally{v.disabled=!1,v.textContent="Deactivate"}}}function F(H){const E=document.getElementById("license-status-topbar"),u=document.getElementById("license-topbar-icon"),o=document.getElementById("license-topbar-text"),c=document.getElementById("license-topbar-time");if(E)if(E.className="license-status-topbar "+(H.active?"premium":"free"),H.active)if(u.textContent="\u2605",o.textContent="PREMIUM",H.lifetime)c.textContent="\xB7 LIFETIME";else{const d=H.daysRemaining;c.textContent=d!=null?"\xB7 "+d+"d remaining":""}else u.textContent="\u2606",o.textContent=H.expired?"EXPIRED":"FREE TIER",c.textContent=""}function S(){C(),f(),b.classList.add("show")}h.addEventListener("input",function(){let H=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(H.length>this._prevLength&&(H=H.replace(/-/g,""),H.length>2&&!H.startsWith("DC")&&(H="DC"+H),H.startsWith("DC")&&H.length>2)){const E=["DC"],u=H.substring(2);for(let o=0;o<u.length;o+=5)E.push(u.substring(o,o+5));H=E.join("-")}this._prevLength=H.length,this.value=H}),j.addEventListener("click",B),v.addEventListener("click",D),h.addEventListener("keydown",H=>{H.key==="Enter"&&B()}),wireModal(b,document.getElementById("license-cancel"));const I=document.getElementById("license-status-topbar");I&&I.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=S,window.checkPremiumFeature=async function(H){try{return(await(await fetch(`/api/v1/license/feature/${H}`)).json()).available}catch{return!1}},f().then(H=>{x&&F(x)})})(),(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 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>
|
|
|
|
@@ -2303,7 +2303,7 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
`);const h=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),g=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),f=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),x=document.getElementById("share-ts-create"),z=document.getElementById("share-ts-email"),k=document.getElementById("share-public-ttl");let C=null,D="public";function y(){R.style.display="none",f.style.display="none",g.style.display="none"}function T(e){f.style.display="none",g.style.display="none",R.textContent=e,R.style.display="block"}function $(e){R.style.display="none",f.textContent=e,f.style.display="block"}function F(){return window.location.origin}function S(e){return e.startsWith("http")?e:F()+e}function I(e){if(!e)return"";const i=new Date(e);return Number.isNaN(i.getTime())?"":i.toLocaleString()}function B(e){if(e<=0)return"expired";const i=Math.floor(e/36e5);if(i>=24)return`${Math.floor(i/24)}d ${i%24}h left`;const l=Math.floor(e%36e5/6e4);return`${i}h ${l}m left`}function E(e){D=e,y(),h.querySelectorAll(".share-tab").forEach(i=>{const l=i.dataset.tab===e;i.classList.toggle("active",l),i.style.borderBottomColor=l?"var(--accent)":"transparent",i.style.color=l?"var(--fg)":"var(--muted)",i.style.fontWeight=l?"600":"400"}),h.querySelectorAll(".share-tab-panel").forEach(i=>{i.style.display=i.dataset.panel===e?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const e=await fetch("/api/v1/share",{credentials:"same-origin"});if(!e.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const i=await e.json(),s=(i&&i.data||[]).filter(a=>a.serviceId===C.id);if(s.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=s.map(a=>{const t=a.expiresAt?B(new Date(a.expiresAt).getTime()-Date.now()):"";return`
|
|
|
|
|
`);const h=document.getElementById("share-modal"),j=document.getElementById("share-modal-service-name"),v=document.getElementById("share-issued"),N=document.getElementById("share-issued-url"),M=document.getElementById("share-issued-copy"),$=document.getElementById("share-issued-meta"),R=document.getElementById("share-error"),y=document.getElementById("share-success"),L=document.getElementById("share-outstanding-list"),w=document.getElementById("share-cancel"),z=document.getElementById("share-public-create"),x=document.getElementById("share-ts-create"),C=document.getElementById("share-ts-email"),k=document.getElementById("share-public-ttl");let T=null,P="public";function f(){R.style.display="none",y.style.display="none",v.style.display="none"}function B(e){y.style.display="none",v.style.display="none",R.textContent=e,R.style.display="block"}function D(e){R.style.display="none",y.textContent=e,y.style.display="block"}function F(){return window.location.origin}function S(e){return e.startsWith("http")?e:F()+e}function I(e){if(!e)return"";const i=new Date(e);return Number.isNaN(i.getTime())?"":i.toLocaleString()}function H(e){if(e<=0)return"expired";const i=Math.floor(e/36e5);if(i>=24)return`${Math.floor(i/24)}d ${i%24}h left`;const l=Math.floor(e%36e5/6e4);return`${i}h ${l}m left`}function E(e){P=e,f(),h.querySelectorAll(".share-tab").forEach(i=>{const l=i.dataset.tab===e;i.classList.toggle("active",l),i.style.borderBottomColor=l?"var(--accent)":"transparent",i.style.color=l?"var(--fg)":"var(--muted)",i.style.fontWeight=l?"600":"400"}),h.querySelectorAll(".share-tab-panel").forEach(i=>{i.style.display=i.dataset.panel===e?"":"none"})}async function u(){L.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const e=await fetch("/api/v1/share",{credentials:"same-origin"});if(!e.ok){L.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const i=await e.json(),s=(i&&i.data||[]).filter(a=>a.serviceId===T.id);if(s.length===0){L.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}L.innerHTML=s.map(a=>{const t=a.expiresAt?H(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;">
|
|
|
|
@@ -2315,5 +2315,5 @@ This can take ~30-60s\u2026`);try{const{body:T}=await N("/deploy",{method:"POST"
|
|
|
|
|
Revoke
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
`}).join(""),L.querySelectorAll(".share-revoke").forEach(a=>{a.addEventListener("click",()=>o(a.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function o(e){try{const i=await fetch(`/api/v1/share/${encodeURIComponent(e)}`,{method:"DELETE",credentials:"same-origin"});if(!i.ok){const l=await i.json().catch(()=>({}));T(l&&l.error||`Revoke failed (HTTP ${i.status}).`);return}$("Share revoked."),u()}catch{T("Network error while revoking.")}}async function c(){if(!C)return;y(),H.disabled=!0;const e=H.textContent;H.textContent="Creating\u2026";try{const i=parseInt(k.value,10),l=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,ttlMs:i})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){v(l.status,s);return}const a=s.data.urlPath;N.value=S(a),M.textContent=`Public link \xB7 expires ${I(s.data.expiresAt)}`,g.style.display="block",$("Share link created."),u()}catch{T("Network error while creating share.")}finally{H.disabled=!1,H.textContent=e}}async function d(){if(!C)return;const e=(z.value||"").trim();if(!e||!e.includes("@")){T("A valid recipient email is required.");return}y(),x.disabled=!0;const i=x.textContent;x.textContent="Creating\u2026";try{const l=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:C.id,email:e})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){v(l.status,s);return}const a=s.data&&s.data.urlPath;a?(N.value=S(a),M.textContent=`Tailscale invite \xB7 emailed to ${e} (or use this URL manually if delivery failed).`):(N.value="",M.textContent=`Tailscale invite sent to ${e}.`),g.style.display="block",$("Tailscale invite created."),z.value="",u()}catch{T("Network error while creating Tailscale invite.")}finally{x.disabled=!1,x.textContent=i}}function v(e,i){const l=i&&i.error||"";if(e===402){T("Share is a Pro feature. Activate a license to unlock.");return}if(e===400&&/tailscale/i.test(l)){T("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(e===403){T("You do not have permission to share services.");return}if(e===404){T("Service not found. It may have been deleted.");return}T(l||`Request failed (HTTP ${e}).`)}function p(e){!e||!e.id||(C={id:e.id,name:e.name||e.id},j.textContent=C.name,y(),E("public"),z.value="",k.value=String(b[1].ms),h.classList.add("show"),u())}h.querySelectorAll(".share-tab").forEach(e=>{e.addEventListener("click",()=>E(e.dataset.tab))}),H.addEventListener("click",c),x.addEventListener("click",d),w.addEventListener("click",()=>h.classList.remove("show")),wireModal(h,w),P.addEventListener("click",async()=>{const e=N.value;if(e)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(e):(N.select(),document.execCommand("copy"));const i=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=i},1200)}catch{T("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
|
|
|
|
|
`}).join(""),L.querySelectorAll(".share-revoke").forEach(a=>{a.addEventListener("click",()=>o(a.dataset.shareId))})}catch{L.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function o(e){try{const i=await fetch(`/api/v1/share/${encodeURIComponent(e)}`,{method:"DELETE",credentials:"same-origin"});if(!i.ok){const l=await i.json().catch(()=>({}));B(l&&l.error||`Revoke failed (HTTP ${i.status}).`);return}D("Share revoked."),u()}catch{B("Network error while revoking.")}}async function c(){if(!T)return;f(),z.disabled=!0;const e=z.textContent;z.textContent="Creating\u2026";try{const i=parseInt(k.value,10),l=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,ttlMs:i})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){g(l.status,s);return}const a=s.data.urlPath;N.value=S(a),$.textContent=`Public link \xB7 expires ${I(s.data.expiresAt)}`,v.style.display="block",D("Share link created."),u()}catch{B("Network error while creating share.")}finally{z.disabled=!1,z.textContent=e}}async function d(){if(!T)return;const e=(C.value||"").trim();if(!e||!e.includes("@")){B("A valid recipient email is required.");return}f(),x.disabled=!0;const i=x.textContent;x.textContent="Creating\u2026";try{const l=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:T.id,email:e})}),s=await l.json().catch(()=>({}));if(!l.ok||!s.success){g(l.status,s);return}const a=s.data&&s.data.urlPath;a?(N.value=S(a),$.textContent=`Tailscale invite \xB7 emailed to ${e} (or use this URL manually if delivery failed).`):(N.value="",$.textContent=`Tailscale invite sent to ${e}.`),v.style.display="block",D("Tailscale invite created."),C.value="",u()}catch{B("Network error while creating Tailscale invite.")}finally{x.disabled=!1,x.textContent=i}}function g(e,i){const l=i&&i.error||"";if(e===402){B("Share is a Pro feature. Activate a license to unlock.");return}if(e===400&&/tailscale/i.test(l)){B("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(e===403){B("You do not have permission to share services.");return}if(e===404){B("Service not found. It may have been deleted.");return}B(l||`Request failed (HTTP ${e}).`)}function p(e){!e||!e.id||(T={id:e.id,name:e.name||e.id},j.textContent=T.name,f(),E("public"),C.value="",k.value=String(b[1].ms),h.classList.add("show"),u())}h.querySelectorAll(".share-tab").forEach(e=>{e.addEventListener("click",()=>E(e.dataset.tab))}),z.addEventListener("click",c),x.addEventListener("click",d),w.addEventListener("click",()=>h.classList.remove("show")),wireModal(h,w),M.addEventListener("click",async()=>{const e=N.value;if(e)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(e):(N.select(),document.execCommand("copy"));const i=M.textContent;M.textContent="Copied!",setTimeout(()=>{M.textContent=i},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
|