[glm-grade=B] fix(notifications): repair UI/API contract drift — SMTP auth, event gate, test button (DC-092)
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:
1. SMTP auth never applied: the UI sent email.user/email.pass while the
manager read username/password. Route now normalizes aliases; legacy
config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
folds every known spelling (manager gate, route store, legacy files);
UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
deploy-failed/auto-restart were missing from DEFAULT events, and
send('test') was itself gated -> the Test button was a no-op. Defaults
added; 'test' bypasses the gate.
Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.
Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).
Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
This commit is contained in:
7 files changed
+620
-92
No files matched your search
Vendored
+60
-60
@@ -90,9 +90,9 @@
|
||||
<button id="logo-save" class="btn-accent">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),S=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),h=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),k=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),E=DC.NAME;let I=null,O=null,C=null,j="left",N=E;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",I=null,O=null):(P.style.display="flex",M.style.display="none",C=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{I=e,B.src=e,S.textContent="New dark logo ready to save"})}),h?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,S.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{C=e,B.src=e,A.src=e,S.textContent="New logo ready to save (both themes)"})});function u(t){k.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||E,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function x(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(S.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{I=null,O=null,C=null,D&&(D.value=""),h&&(h.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=f.src,A.src=H.src;const t=f.src.includes("custom-logo")||H.src.includes("custom-logo");S.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,w.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||E,e={position:j,dashboardTitle:t};z?.checked&&C?(e.dataDark=C,e.dataLight=C):(I&&(e.dataDark=I),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(f.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),w.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
</div>`);const k=document.getElementById("logo-modal"),B=document.getElementById("logo-preview-dark"),A=document.getElementById("logo-preview-light"),I=document.getElementById("logo-status"),z=document.getElementById("logo-same-both"),P=document.getElementById("logo-dual-uploads"),M=document.getElementById("logo-single-upload"),D=document.getElementById("logo-upload-dark"),h=document.getElementById("logo-upload-light"),T=document.getElementById("logo-upload-single"),f=document.querySelector("#brand .brand-logo-dark"),H=document.querySelector("#brand .brand-logo-light"),S=document.querySelector(".top-row"),$=document.getElementById("dashboard-title"),w=DC.NAME;let E=null,O=null,C=null,j="left",N=w;z?.addEventListener("change",()=>{z.checked?(P.style.display="none",M.style.display="",E=null,O=null):(P.style.display="flex",M.style.display="none",C=null)});function R(t,e){if(!t||!t.type.startsWith("image/")){showNotification("Please select an image file","warning");return}const a=new FileReader;a.onload=o=>e(o.target.result),a.readAsDataURL(t)}D?.addEventListener("change",t=>{R(t.target.files[0],e=>{E=e,B.src=e,I.textContent="New dark logo ready to save"})}),h?.addEventListener("change",t=>{R(t.target.files[0],e=>{O=e,A.src=e,I.textContent="New light logo ready to save"})}),T?.addEventListener("change",t=>{R(t.target.files[0],e=>{C=e,B.src=e,A.src=e,I.textContent="New logo ready to save (both themes)"})});function u(t){S.setAttribute("data-logo-pos",t),document.querySelectorAll(".logo-pos-btn").forEach(e=>{e.style.background=e.dataset.pos===t?"var(--accent)":"var(--card-bg)",e.style.color=e.dataset.pos===t?"white":"var(--fg)"})}function v(t){N=t||w,document.title=N;const e=document.querySelector(".dashboard-title");e&&(e.textContent=N)}async function x(){try{const t=await fetch("/api/v1/logo");if(t.ok){const e=await t.json();e.customLogoDark&&(f.src=e.customLogoDark,B.src=e.customLogoDark),e.customLogoLight&&(H.src=e.customLogoLight,A.src=e.customLogoLight),!e.customLogoDark&&!e.customLogoLight&&e.customLogo&&(f.src=e.customLogo,H.src=e.customLogo,B.src=e.customLogo,A.src=e.customLogo),e.isDefault||(I.textContent="Using custom logo"),e.position&&(j=e.position,u(e.position)),e.dashboardTitle&&v(e.dashboardTitle)}}catch(t){console.warn("Could not load custom logo:",t.message)}}document.querySelectorAll(".logo-pos-btn").forEach(t=>{t.addEventListener("click",()=>{j=t.dataset.pos,u(j)})}),document.getElementById("brand")?.addEventListener("click",()=>{E=null,O=null,C=null,D&&(D.value=""),h&&(h.value=""),T&&(T.value=""),z&&(z.checked=!1),P.style.display="flex",M.style.display="none",B.src=f.src,A.src=H.src;const t=f.src.includes("custom-logo")||H.src.includes("custom-logo");I.textContent=t?"Using custom logo":"Using default logos",u(j),$.value=N,k.classList.add("show")}),document.getElementById("logo-save")?.addEventListener("click",async()=>{try{const t=$.value.trim()||w,e={position:j,dashboardTitle:t};z?.checked&&C?(e.dataDark=C,e.dataLight=C):(E&&(e.dataDark=E),O&&(e.dataLight=O));const a=await secureFetch("/api/v1/logo",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(e)});if(a.ok){const o=await a.json(),i="?t="+Date.now();o.pathDark&&(f.src=o.pathDark+i,B.src=o.pathDark+i),o.pathLight&&(H.src=o.pathLight+i,A.src=o.pathLight+i),u(j),v(t),k.classList.remove("show")}else{const o=await a.json();showNotification("Failed to save: "+o.error,"error")}}catch(t){showNotification("Error saving: "+t.message,"error")}}),document.getElementById("logo-reset")?.addEventListener("click",async()=>{if(confirm(`Reset all branding to DashCaddy defaults?
|
||||
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",S.textContent="Using default logos",I=null,O=null,C=null,$.value=E,v(E),j="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(w,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),b=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function m(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),b.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,g.src=l,b.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),b.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),m(),x();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{w.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(w,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(w,B){const A=Intl.supportedValuesOf("timeZone"),S=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";w.innerHTML="";for(const z of A){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===S&&(P.selected=!0),w.appendChild(P)}},(function(){let w="homelab",B=null;async function A(){try{const v=await fetch("/api/v1/config");if(v.ok&&(B=await v.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(v){console.warn("Could not fetch server config, checking localStorage fallback:",v.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const S=document.getElementById("setup-timezone");S&&window.populateTimezoneSelect(S);function z(u){document.querySelectorAll(".setup-step").forEach(x=>{x.style.display="none"});const v=document.getElementById(u);v&&(v.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let v='<div style="display: grid; gap: 20px;">';if(w==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",s=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;v+=`
|
||||
This will reset the logo, favicon, title, and position.`))try{if((await secureFetch("/api/v1/logo",{method:"DELETE"})).ok&&(f.src="/assets/dashcaddy-logo-dark.png",H.src="/assets/dashcaddy-logo-light.png",B.src="/assets/dashcaddy-logo-dark.png",A.src="/assets/dashcaddy-logo-light.png",I.textContent="Using default logos",E=null,O=null,C=null,$.value=w,v(w),j="left",u("left")),(await secureFetch("/api/v1/favicon",{method:"DELETE"})).ok){const a=document.querySelector('link[rel="icon"]'),o=document.getElementById("favicon-preview"),i=document.getElementById("favicon-status");a&&(a.href="/assets/dashcaddy-favicon.ico?t="+Date.now()),o&&(o.src="/assets/dashcaddy-favicon.ico?t="+Date.now()),i&&(i.textContent="Using DashCaddy favicon"),l=null}}catch(t){showNotification("Error resetting branding: "+t.message,"error")}}),wireModal(k,document.getElementById("logo-cancel"));const g=document.getElementById("favicon-preview"),b=document.getElementById("favicon-status"),s=document.getElementById("favicon-upload"),p=document.querySelector('link[rel="icon"]')||document.createElement("link");let l=null;document.querySelector('link[rel="icon"]')||(p.rel="icon",p.href="/assets/dashcaddy-favicon.ico",document.head.appendChild(p));async function m(){try{const t=await fetch("/api/v1/favicon");if(t.ok){const e=await t.json();e.customFavicon&&(p.href=e.customFavicon+"?t="+Date.now(),g.src=e.customFavicon+"?t="+Date.now(),b.textContent="Using custom favicon")}}catch(t){console.warn("Could not load custom favicon:",t.message)}}s?.addEventListener("change",t=>{const e=t.target.files[0];if(!e)return;if(!e.type.match(/^image\/(png|svg\+xml)$/)){showNotification("Please select a PNG or SVG file","warning"),s.value="";return}const a=new FileReader;a.onload=o=>{l=o.target.result,g.src=l,b.textContent="New favicon ready to save"},a.readAsDataURL(e)}),document.getElementById("logo-save")?.addEventListener("click",async()=>{if(l)try{const t=await secureFetch("/api/v1/favicon",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:l})});if(t.ok){const e=await t.json();p.href=e.path+"?t="+Date.now(),g.src=e.path+"?t="+Date.now(),b.textContent="Using custom favicon",l=null}else{const e=await t.json();showNotification("Failed to save favicon: "+e.error,"error")}}catch(t){showNotification("Error saving favicon: "+t.message,"error")}}),m(),x();const c=document.getElementById("settings-timezone");c&&(new MutationObserver(()=>{k.classList.contains("show")&&c.options.length===0&&(async()=>{let e;try{const a=await fetch("/api/v1/config");a.ok&&(e=(await a.json()).timezone)}catch{}window.populateTimezoneSelect(c,e)})()}).observe(k,{attributes:!0,attributeFilter:["class"]}),document.getElementById("logo-save")?.addEventListener("click",async()=>{const e=c.value;if(e)try{const a=await fetch("/api/v1/config");if(!a.ok)return;const o=await a.json();o.timezone=e,o.updatedAt=new Date().toISOString(),await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})}catch(a){console.warn("Failed to save timezone:",a.message)}}))})(),window.populateTimezoneSelect=function(k,B){const A=Intl.supportedValuesOf("timeZone"),I=B||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC";k.innerHTML="";for(const z of A){const P=document.createElement("option");P.value=z,P.textContent=z.replace(/_/g," "),z===I&&(P.selected=!0),k.appendChild(P)}},(function(){let k="homelab",B=null;async function A(){try{const v=await fetch("/api/v1/config");if(v.ok&&(B=await v.json(),B&&B.setupComplete))return document.getElementById("setup-wizard").style.display="none",!0}catch(v){console.warn("Could not fetch server config, checking localStorage fallback:",v.message)}return safeGet("dashcaddy-setup")?(document.getElementById("setup-wizard").style.display="none",!0):(document.getElementById("setup-wizard").style.display="flex",!1)}A();const I=document.getElementById("setup-timezone");I&&window.populateTimezoneSelect(I);function z(u){document.querySelectorAll(".setup-step").forEach(x=>{x.style.display="none"});const v=document.getElementById(u);v&&(v.style.display="block")}function P(){const u=document.getElementById("setup-summary-content");if(!u)return;let v='<div style="display: grid; gap: 20px;">';if(k==="homelab"){const g=document.getElementById("setup-tld")?.value?.trim()||".home",b=document.getElementById("setup-ca-name")?.value?.trim()||"",s=document.getElementById("setup-dns-ip")?.value?.trim()||"",p=document.getElementById("setup-dns-port")?.value?.trim()||DC.DEFAULTS.DNS_PORT;v+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Home Lab Configuration</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
@@ -102,7 +102,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
<div><strong>Example URLs:</strong> https://uptime${g}, https://nextcloud${g}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(w==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";v+=`
|
||||
`}else if(k==="simple"){const g=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost";v+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Simple Setup</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
@@ -112,7 +112,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
<div><strong>Example URLs:</strong> http://${g}:8080, http://${g}:3000</div>
|
||||
</div>
|
||||
</div>
|
||||
`}else if(w==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",s=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=s==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;v+=`
|
||||
`}else if(k==="public"){const g=document.getElementById("setup-public-domain")?.value?.trim()||"",b=document.getElementById("setup-public-email")?.value?.trim()||"",s=document.querySelector('input[name="routing-mode"]:checked')?.value||"subdirectory",p=s==="subdirectory"?`https://${g}/sonarr, https://${g}/grafana`:`https://sonarr.${g}, https://grafana.${g}`;v+=`
|
||||
<div>
|
||||
<h3 style="margin: 0 0 12px; color: var(--accent);">Public Server</h3>
|
||||
<div style="display: grid; gap: 12px; font-size: 0.95rem;">
|
||||
@@ -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> ${x.replace(/_/g," ")}</div>
|
||||
</div>
|
||||
`,v+="</div>",u.innerHTML=v,z("setup-step-summary")}async function M(u){try{const v=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return v.ok?(await v.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${v.status}`),{function:"saveConfigToServer"}),!1)}catch(v){return errorHandler.logError("[SetupWizard] Save Config",v,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:w,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(w==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:b,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 w==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):w==="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 v=await M(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const x=w==="homelab"?"Professional Home Lab":w==="simple"?"Simple Setup":"Public Server",g=v?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${x}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const h=document.getElementById("setup-step-1-next");h&&(h.onclick=function(u){u.preventDefault();const v=document.querySelector('input[name="config-type"]:checked');v&&(w=v.value),z(w==="homelab"?"setup-step-homelab":w==="simple"?"setup-step-simple":w==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await M({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const f=document.getElementById("setup-tld");f&&(f.oninput=function(u){const v=u.target.value||".home",x=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");x&&(x.textContent=v),g&&(g.textContent=v)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),z("setup-step-1")});const k=document.getElementById("setup-homelab-next");k&&(k.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-tld")?.value?.trim()||"",x=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!v||!v.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!x){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(u){u.preventDefault(),z("setup-step-1")});const E=document.getElementById("setup-simple-next");E&&(E.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var v=document.getElementById("dns-requirement-note");v&&(v.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const I=document.getElementById("setup-public-back");I&&(I.onclick=function(u){u.preventDefault(),z("setup-step-1")});const O=document.getElementById("setup-public-next");O&&(O.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-public-domain")?.value?.trim()||"",x=document.getElementById("setup-public-email")?.value?.trim()||"";if(!v){showNotification("Please enter your domain name","warning");return}if(!x||!x.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),w==="homelab"?z("setup-step-homela
Line truncated
|
||||
`,v+="</div>",u.innerHTML=v,z("setup-step-summary")}async function M(u){try{const v=await secureFetch("/api/v1/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(u)});return v.ok?(await v.json(),!0):(errorHandler.logError("[SetupWizard] Save Config",new Error(`Server returned ${v.status}`),{function:"saveConfigToServer"}),!1)}catch(v){return errorHandler.logError("[SetupWizard] Save Config",v,{function:"saveConfigToServer"}),!1}}async function D(){const u={setupComplete:!0,configurationType:k,timestamp:new Date().toISOString(),timezone:document.getElementById("setup-timezone")?.value||Intl.DateTimeFormat().resolvedOptions().timeZone||"UTC"};if(k==="homelab"){u.tld=document.getElementById("setup-tld")?.value?.trim()||".home",u.caName=document.getElementById("setup-ca-name")?.value?.trim()||"";const b=document.getElementById("setup-dns-provider")?.value||"technitium";u.dns={provider:b,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 k==="simple"?(u.defaultIP=document.getElementById("setup-simple-ip")?.value?.trim()||"localhost",u.defaults={dnsType:"none",sslType:"none",targetIP:u.defaultIP}):k==="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 v=await M(u);safeSet("dashcaddy-config",JSON.stringify(u)),safeSet("dashcaddy-setup","completed"),document.getElementById("setup-wizard").style.display="none";const x=k==="homelab"?"Professional Home Lab":k==="simple"?"Simple Setup":"Public Server",g=v?"server (shared across all devices)":"locally (this browser only)";showNotification(`Setup Complete! Configured for: ${x}. Settings saved to: ${g}`,"success",5e3),setTimeout(()=>location.reload(),500)}const h=document.getElementById("setup-step-1-next");h&&(h.onclick=function(u){u.preventDefault();const v=document.querySelector('input[name="config-type"]:checked');v&&(k=v.value),z(k==="homelab"?"setup-step-homelab":k==="simple"?"setup-step-simple":k==="public"?"setup-step-public":"setup-step-homelab")});const T=document.getElementById("setup-skip");T&&(T.onclick=async function(u){u.preventDefault(),confirm("Skip setup? You can run it later from Settings.")&&(await M({setupComplete:!0,skipped:!0,timestamp:new Date().toISOString()}),safeSet("dashcaddy-setup","skipped"),document.getElementById("setup-wizard").style.display="none")});const f=document.getElementById("setup-tld");f&&(f.oninput=function(u){const v=u.target.value||".home",x=document.getElementById("tld-preview"),g=document.getElementById("tld-preview-2");x&&(x.textContent=v),g&&(g.textContent=v)});const H=document.getElementById("setup-homelab-back");H&&(H.onclick=function(u){u.preventDefault(),z("setup-step-1")});const S=document.getElementById("setup-homelab-next");S&&(S.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-tld")?.value?.trim()||"",x=document.getElementById("setup-ca-name")?.value?.trim()||"",g=document.getElementById("setup-dns-ip")?.value?.trim()||"";if(!v||!v.startsWith(".")){showNotification("Please enter a valid TLD starting with a dot (e.g., .home)","warning");return}if(!x){showNotification("Please enter a Certificate Authority name","warning");return}if(!g){showNotification("Please enter your DNS server IP address","warning");return}P()});const $=document.getElementById("setup-simple-back");$&&($.onclick=function(u){u.preventDefault(),z("setup-step-1")});const w=document.getElementById("setup-simple-next");w&&(w.onclick=function(u){u.preventDefault(),P()}),document.querySelectorAll('input[name="routing-mode"]').forEach(function(u){u.onchange=function(){var v=document.getElementById("dns-requirement-note");v&&(v.textContent=this.value==="subdirectory"?"Only one DNS record needed (for the main domain)":"You'll need to configure DNS manually for each subdomain")}});const E=document.getElementById("setup-public-back");E&&(E.onclick=function(u){u.preventDefault(),z("setup-step-1")});const O=document.getElementById("setup-public-next");O&&(O.onclick=function(u){u.preventDefault();const v=document.getElementById("setup-public-domain")?.value?.trim()||"",x=document.getElementById("setup-public-email")?.value?.trim()||"";if(!v){showNotification("Please enter your domain name","warning");return}if(!x||!x.includes("@")){showNotification("Please enter a valid email address","warning");return}P()});const C=document.getElementById("setup-summary-back");C&&(C.onclick=function(u){u.preventDefault(),k==="homelab"?z("setup-step-homela
Line truncated
|
||||
<div class="app-selector-content">
|
||||
<h2 style="margin: 0 0 24px; color: var(--fg); text-align: center;">Choose an App</h2>
|
||||
<div id="app-selector-grid" class="app-selector-grid"></div>
|
||||
@@ -333,7 +333,7 @@ This will reset the logo, favicon, title, and position.`))try{if((await secureFe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const B="custom-apps";let A=null,S=null;const z=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return A=p.templates,S=p.categories,!0}catch(s){w.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function D(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(p){return w.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function h(s){try{const l=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){w.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return s}async function T(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!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 s={};for(const[l,m]of Object.entries(A)){const c=m.category||"Other";s[c]||(s[c]=[]),s[c].push({id:l,...m})}const p=S?Object.keys(S):Object.keys(s).sort();for(const l of p){const m=s[l];if(!m||m.length===0)continue;m.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const c=document.createElement("div");c.className="app-category-header";const t=S?.[l]||{};c.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(c.style.borderBottomColor=t.color),P.appendChild(c),m.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",r=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
|
||||
</div>`);const B="custom-apps";let A=null,I=null;const z=document.getElementById("app-selector-modal"),P=document.getElementById("app-selector-grid");async function M(){try{const p=await(await fetch("/api/v1/apps/templates")).json();if(p.success)return A=p.templates,I=p.categories,!0}catch(s){k.logError("[AppSelector] Fetch Templates",s,{function:"fetchApiTemplates"})}return!1}async function D(s){try{return await(await fetch(`/api/v1/apps/ports/${s}/check`)).json()}catch(p){return k.logError("[AppSelector] Check Port",p,{function:"checkPortAvailability"}),{available:!0}}}async function h(s){try{const l=await(await fetch(`/api/v1/apps/ports/${s}/suggest`)).json();if(l.success)return l.suggestedPort}catch(p){k.logError("[AppSelector] Get Suggested Port",p,{function:"getSuggestedPort"})}return s}async function T(){if(P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">Loading app templates...</div>',!A&&!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 s={};for(const[l,m]of Object.entries(A)){const c=m.category||"Other";s[c]||(s[c]=[]),s[c].push({id:l,...m})}const p=I?Object.keys(I):Object.keys(s).sort();for(const l of p){const m=s[l];if(!m||m.length===0)continue;m.sort((e,a)=>(a.popularity||0)-(e.popularity||0));const c=document.createElement("div");c.className="app-category-header";const t=I?.[l]||{};c.innerHTML=`${escapeHtml(t.icon||"")} ${escapeHtml(l)}`,t.color&&(c.style.borderBottomColor=t.color),P.appendChild(c),m.forEach(e=>{const a=document.createElement("div");a.className="app-option";const o=e.isDashboardWidget,i=o&&safeGet("widget-"+e.id+"-enabled")!=="false",n=o?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${i?"#2ecc7130":"#e74c3c30"}; color: ${i?"#2ecc71":"#e74c3c"}; font-weight: 600;">${i?"ON":"OFF"}</div>`:"",r=!o&&e.difficulty?`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"}20; color: ${e.difficulty==="Easy"?"#2ecc71":e.difficulty==="Intermediate"?"#f39c12":"#e74c3c"};">${escapeHtml(e.difficulty)}</div>`:"";a.innerHTML=`
|
||||
<div class="app-option-icon">${escapeHtml(e.icon||"\u{1F4E6}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(e.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(e.description||"")}</div>
|
||||
@@ -358,15 +358,15 @@ Click Cancel to deploy a new container.`)&&(s._useExisting=!0,s._existingContain
|
||||
<span style="color: #4caf50;">Connected</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">${G.self?.hostname} (${G.self?.ip})</span>
|
||||
<span style="color: var(--muted); margin-left: 8px;">| ${G.deviceCount} devices</span>
|
||||
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=m.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}c.textContent=K}m.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),z.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(s)}async function k(s){const p=s.appTemplate,l=safeGetJSON(B,[]),m=p._useExisting&&p._existingContainer,c=l.find(t=>t.id===s.subdomain);if(!(c&&!m&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(c){const t=l.indexOf(c);l.splice(t,1),safeSet(B,JSON.stringify(l))}if(m)s.port=p._existingContainer.primaryPort;else{const t=s.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await h(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
`:G.installed?o.innerHTML='<span style="color: #ff9800;">Not connected</span>':(o.innerHTML='<span style="color: var(--muted);">Not available</span>',a.disabled=!0)}catch{o.innerHTML='<span style="color: var(--muted);">Could not check status</span>'}function ae(){const W=m.value||"subdomain",G=document.querySelector('input[name="dns-type"]:checked').value,V=document.querySelector('input[name="ssl-type"]:checked').value;let K="";if(SITE.routingMode==="subdirectory"&&SITE.domain)K=`https://${SITE.domain}/${W}`;else if(G==="private")K=`${V==="none"?"http":"https"}://${buildDomain(W)}`;else if(G==="public"){const te=V==="none"?"http":"https",ee=SITE.domain||W;K=SITE.domain?`${te}://${W}.${SITE.domain}`:`${te}://${W}`}else{const te=e.value||s.defaultPort||DC.DEFAULTS.SERVICE_PORT;K=`http://${t.value}:${te}`}c.textContent=K}m.oninput=ae,t.oninput=ae,e.oninput=ae,document.querySelectorAll('input[name="dns-type"]').forEach(W=>{W.onchange=ae}),document.querySelectorAll('input[name="ssl-type"]').forEach(W=>{W.onchange=ae}),ae(),z.classList.remove("show"),p.classList.add("show"),p.dataset.appTemplate=JSON.stringify(s)}async function S(s){const p=s.appTemplate,l=safeGetJSON(B,[]),m=p._useExisting&&p._existingContainer,c=l.find(t=>t.id===s.subdomain);if(!(c&&!m&&!confirm(`An app with subdomain "${s.subdomain}" already exists. Redeploy?`))){if(c){const t=l.indexOf(c);l.splice(t,1),safeSet(B,JSON.stringify(l))}if(m)s.port=p._existingContainer.primaryPort;else{const t=s.port||p.defaultPort||8080;showNotification(`Checking port ${t} availability...`,"info",0);const e=await D(t);if(!e.available){const a=await h(p.defaultPort||8080);if(confirm(`Port ${t} is already in use by ${e.conflict?.usedBy||"another container"}.
|
||||
|
||||
Would you like to use port ${a} instead?`))s.port=a;else{showNotification("Deployment cancelled - port conflict","error",5e3);return}}}showNotification(m?`Configuring ${p.name} with existing container...`:`Deploying ${p.name}...`,"info",0);try{const t={appId:p.id,config:{subdomain:s.subdomain,ip:s.ip,createDns:s.dnsType==="private",port:s.port||p.defaultPort||null,sslType:s.sslType,dnsType:s.dnsType,tailscaleOnly:s.tailscaleOnly||!1,mediaPath:s.mediaPath||null,plexClaimToken:s.plexClaimToken||null,customVolumes:s.customVolumes||null}};m&&(t.config.useExisting=!0,t.config.existingContainerId=p._existingContainer.id,t.config.existingPort=p._existingContainer.primaryPort,!s.port&&p._existingContainer.primaryPort&&(t.config.port=p._existingContainer.primaryPort));const a=await(await secureFetch("/api/v1/apps/deploy",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})).json();if(a.success){const o={id:s.subdomain,name:p.name,logo:`/assets/${p.id}.png`,containerId:a.containerId,url:a.url,ip:s.ip,appTemplate:p.id,tailscaleOnly:s.tailscaleOnly||!1};l.push(o),safeSet(B,JSON.stringify(l)),window.APPS&&!window.APPS.some(n=>n.id===p.id)&&(window.APPS.push(o),typeof window.buildGrid=="function"&&window.buildGrid(),typeof window.refreshAll=="function"&&setTimeout(()=>window.refreshAll(),500));let i=a.usedExisting?`${p.name} configured with existing container!
|
||||
URL: ${a.url}`:`${p.name} deployed successfully!
|
||||
URL: ${a.url}`;a.warning&&(i+=`
|
||||
|
||||
\u26A0 Warning: ${a.warning}`),showNotification(i,"success",8e3),delete p._useExisting,delete p._existingContainer,a.url&&a.url.startsWith("https://")&&$(a.url,p.name),a.setupInstructions&&a.setupInstructions.length>0&&setTimeout(()=>{const n=a.setupInstructions.join(`
|
||||
`);showNotification(`Setup Instructions for ${p.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){w.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function $(s,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const m=12,c=async()=>{l++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<m?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function E(){safeGetJSON(B,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),z.classList.add("show")}),wireModal(z,document.getElementById("app-selector-cancel"));const I=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{I.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(I.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const m={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!m.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}I.classList.remove("show"),k(m)}),wireModal(I);const O=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),j=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),R=document.getElementById("folder-browser-selected-list");let u="",v=[],x=null;window.openFolderBrowser=function(s){x=s,v=s.value.split(",").map(p=>p.trim()).filter(p=>p),u="",b(),g(""),O.classList.add("show")};async function g(s){C.textContent=s||"Select a drive...",j.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!l.success){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}u=l.path||"",C.textContent=u||"Select a drive...";let m="";l.parent&&l.parent!==l.path&&(m+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
`);showNotification(`Setup Instructions for ${p.name}: ${n}`,"info",1e4)},1e3)}else throw new Error(a.error||"Deployment failed")}catch(t){k.logError("[AppSelector] Deployment",t,{function:"deploy"}),showNotification(`Failed to deploy ${p.name}: ${t.message}`,"error",8e3)}}}async function $(s,p){showNotification(`\u23F3 Generating SSL certificate for ${p}...`,"warning",6e4);let l=0;const m=12,c=async()=>{l++;try{const t=await fetch(s,{method:"HEAD",mode:"no-cors"});return showNotification(`\u2705 ${p} is ready! SSL certificate generated.`,"success",5e3),!0}catch{return l<m?setTimeout(c,5e3):showNotification(`\u26A0\uFE0F ${p} deployed but SSL certificate may still be generating.
|
||||
Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};setTimeout(c,3e3)}function w(){safeGetJSON(B,[]).forEach(p=>{window.APPS.some(l=>l.id===p.id)||window.APPS.push(p)})}document.getElementById("add-service-btn")?.addEventListener("click",()=>{T(),z.classList.add("show")}),wireModal(z,document.getElementById("app-selector-cancel"));const E=document.getElementById("app-deploy-modal");document.getElementById("app-deploy-cancel")?.addEventListener("click",()=>{E.classList.remove("show")}),document.getElementById("app-deploy-confirm")?.addEventListener("click",()=>{const s=JSON.parse(E.dataset.appTemplate),p=document.getElementById("deploy-media-path").value.trim(),l=[];document.querySelectorAll("#volume-mounts-list .vol-host-path").forEach(c=>{l.push({hostPath:c.value.trim(),containerPath:c.dataset.containerPath})});const m={appTemplate:s,subdomain:document.getElementById("deploy-subdomain").value.trim(),dnsType:document.querySelector('input[name="dns-type"]:checked').value,sslType:document.querySelector('input[name="ssl-type"]:checked').value,ip:document.getElementById("deploy-ip").value.trim(),port:document.getElementById("deploy-port").value.trim(),tailscaleOnly:document.getElementById("deploy-tailscale-only").checked,mediaPath:p||null,plexClaimToken:document.getElementById("deploy-plex-claim")?.value.trim()||null,customVolumes:l.length>0?l:null,resources:{cpus:parseFloat(document.getElementById("deploy-cpu-limit").value)||0,memory:parseFloat(document.getElementById("deploy-memory-limit").value)||0}};if(!m.subdomain){showNotification("Please enter a subdomain or domain name","warning");return}if(s.mediaMount?.required&&!p){showNotification("Please enter a media library path for this application","warning");return}E.classList.remove("show"),S(m)}),wireModal(E);const O=document.getElementById("folder-browser-modal"),C=document.getElementById("folder-browser-path"),j=document.getElementById("folder-browser-list"),N=document.getElementById("folder-browser-selected"),R=document.getElementById("folder-browser-selected-list");let u="",v=[],x=null;window.openFolderBrowser=function(s){x=s,v=s.value.split(",").map(p=>p.trim()).filter(p=>p),u="",b(),g(""),O.classList.add("show")};async function g(s){C.textContent=s||"Select a drive...",j.innerHTML='<div style="padding: 20px; text-align: center; color: var(--muted);">Loading...</div>';try{const l=await(await fetch(`/api/v1/browse/directories?path=${encodeURIComponent(s)}`)).json();if(!l.success){j.innerHTML=`<div style="padding: 20px; text-align: center; color: var(--error);">Error: ${escapeHtml(l.error)}</div>`;return}u=l.path||"",C.textContent=u||"Select a drive...";let m="";l.parent&&l.parent!==l.path&&(m+=`<div class="folder-item" data-path="${escapeHtml(l.parent)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px;">
|
||||
<span style="font-size: 1.2rem;">\u2B06\uFE0F</span>
|
||||
<span style="color: var(--muted);">.. Parent Directory</span>
|
||||
</div>`),l.items.length===0&&!l.parent?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No browseable drives configured. Check your docker-compose.yml volume mounts.</div>':l.items.length===0?m+='<div style="padding: 20px; text-align: center; color: var(--muted);">No subfolders found</div>':l.items.forEach(c=>{const t=c.type==="drive"?"\u{1F4BE}":"\u{1F4C1}",e=v.includes(c.path),a=e?"background: color-mix(in srgb, var(--success) 20%, transparent);":"";m+=`<div class="folder-item" data-path="${escapeHtml(c.path)}" style="padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; align-items: center; gap: 10px; ${a}">
|
||||
@@ -378,7 +378,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
${escapeHtml(s)}
|
||||
<button type="button" onclick="removeSelectedFolder('${escapeHtml(s)}')" style="background: none; border: none; cursor: pointer; color: var(--error); font-size: 1rem; padding: 0;">\xD7</button>
|
||||
</span>
|
||||
`).join("")}window.removeSelectedFolder=function(s){v=v.filter(p=>p!==s),b(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!v.includes(u)&&(v.push(u),b(),g(u))}),wireModal(O,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{x&&(x.value=v.join(", ")),O.classList.remove("show")}),E()})(),(function(){injectModal("recipe-deploy-modal",`<div id="recipe-deploy-modal" class="weather-modal">
|
||||
`).join("")}window.removeSelectedFolder=function(s){v=v.filter(p=>p!==s),b(),g(u)},document.getElementById("folder-browser-select-current").addEventListener("click",()=>{u&&!v.includes(u)&&(v.push(u),b(),g(u))}),wireModal(O,document.getElementById("folder-browser-cancel")),document.getElementById("folder-browser-done").addEventListener("click",()=>{x&&(x.value=v.join(", ")),O.classList.remove("show")}),w()})(),(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,13 +445,13 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<button id="recipe-next" class="btn-accent-solid">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);let w=null,B=null,A=null,S=1,z=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),h=document.getElementById("recipe-next");wireModal(P,M);async function T(){try{const u=await fetch("/api/v1/recipes/templates"),v=await u.json();if(v.success)return w=v.templates,B=v.categories,!0;if(u.status===403)return z=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function f(){try{z=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{z=!1}return z}window.renderRecipeCards=async function(u){await f();let v;if(z&&w?v=w:v=H(),!v||v.length===0)return;const x=document.createElement("div");x.className="app-category-header",x.innerHTML="\u{1F9EA} Recipes",x.style.borderBottomColor="#8e44ad",u.appendChild(x);const g=Array.isArray(v)?v:Object.values(v);g.sort((b,s)=>(s.popularity||0)-(b.popularity||0));for(const b of g){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${b.componentCount||b.components?.length||"?"} apps</div>`,l=z?"":'<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>';s.innerHTML=`
|
||||
</div>`);let k=null,B=null,A=null,I=1,z=!1;const P=document.getElementById("recipe-deploy-modal"),M=document.getElementById("recipe-cancel"),D=document.getElementById("recipe-prev"),h=document.getElementById("recipe-next");wireModal(P,M);async function T(){try{const u=await fetch("/api/v1/recipes/templates"),v=await u.json();if(v.success)return k=v.templates,B=v.categories,!0;if(u.status===403)return z=!1,!1}catch(u){console.warn("Failed to fetch recipe templates:",u.message)}return!1}async function f(){try{z=(await(await fetch("/api/v1/license/feature/recipes")).json()).available}catch{z=!1}return z}window.renderRecipeCards=async function(u){await f();let v;if(z&&k?v=k:v=H(),!v||v.length===0)return;const x=document.createElement("div");x.className="app-category-header",x.innerHTML="\u{1F9EA} Recipes",x.style.borderBottomColor="#8e44ad",u.appendChild(x);const g=Array.isArray(v)?v:Object.values(v);g.sort((b,s)=>(s.popularity||0)-(b.popularity||0));for(const b of g){const s=document.createElement("div");s.className="app-option",s.style.position="relative";const p=`<div style="font-size: 0.65rem; padding: 2px 6px; border-radius: 4px; margin-top: 4px; background: rgba(142,68,173,0.2); color: #a855f7;">${b.componentCount||b.components?.length||"?"} apps</div>`,l=z?"":'<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>';s.innerHTML=`
|
||||
${l}
|
||||
<div class="app-option-icon">${escapeHtml(b.icon||"\u{1F9EA}")}</div>
|
||||
<div class="app-option-name">${escapeHtml(b.name)}</div>
|
||||
<div class="app-option-desc">${escapeHtml(b.description||"")}</div>
|
||||
${p}
|
||||
`,s.onclick=()=>{if(!z){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}k(b)},u.appendChild(s)}};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 k(u){A=u,S=1;const v=document.getElementById("app-selector-modal");v&&v.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,$(),E(),P.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const v=parseInt(u.dataset.step);u.classList.toggle("active",v===S),u.classList.toggle("completed",v<S)});for(let u=1;u<=4;u++){const v=document.getElementById(`recipe-step-${u}`);v&&(v.style.display=u===S?"":"none")}D.style.display=S>1&&S<4?"":"none",S===4?(h.style.display="none",M.textContent="Close"):S===3?(h.textContent="\u{1F680} Deploy",h.style.display="",M.textContent="Cancel"):(h.textContent="Next",h.style.display="",M.textContent="Cancel")}function E(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const v=A.components||[];for(const x of v){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const b=x.required,s=x.internal;g.innerHTML=`
|
||||
`,s.onclick=()=>{if(!z){showNotification("Recipes require a DashCaddy Premium license. Click the License button to activate.","warning",5e3),window.openLicenseModal&&window.openLicenseModal();return}S(b)},u.appendChild(s)}};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 S(u){A=u,I=1;const v=document.getElementById("app-selector-modal");v&&v.classList.remove("show"),document.getElementById("recipe-deploy-title").textContent=`Deploy ${u.name}`,$(),w(),P.classList.add("show")}function $(){document.querySelectorAll("#recipe-steps .recipe-step").forEach(u=>{const v=parseInt(u.dataset.step);u.classList.toggle("active",v===I),u.classList.toggle("completed",v<I)});for(let u=1;u<=4;u++){const v=document.getElementById(`recipe-step-${u}`);v&&(v.style.display=u===I?"":"none")}D.style.display=I>1&&I<4?"":"none",I===4?(h.style.display="none",M.textContent="Close"):I===3?(h.textContent="\u{1F680} Deploy",h.style.display="",M.textContent="Cancel"):(h.textContent="Next",h.style.display="",M.textContent="Cancel")}function w(){const u=document.getElementById("recipe-component-list");u.innerHTML="";const v=A.components||[];for(const x of v){const g=document.createElement("div");g.style.cssText="display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 8px; background: var(--card-bg); border: 1px solid var(--border);";const b=x.required,s=x.internal;g.innerHTML=`
|
||||
<input type="checkbox" ${b?"checked disabled":"checked"} data-component-id="${x.id}"
|
||||
style="width: 18px; height: 18px; accent-color: var(--accent);" />
|
||||
<div style="flex: 1;">
|
||||
@@ -463,7 +463,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
</div>
|
||||
${x.note?`<div style="font-size: 0.75rem; color: var(--warn-fg); margin-top: 4px;">\u26A0 ${escapeHtml(x.note)}</div>`:""}
|
||||
</div>
|
||||
`,u.appendChild(g)}}function I(){const u=document.getElementById("recipe-volumes-section"),v=document.getElementById("recipe-volume-list"),x=A.sharedVolumes;if(x&&Object.keys(x).length>0){u.style.display="",v.innerHTML="";for(const[g,b]of Object.entries(x)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
||||
`,u.appendChild(g)}}function E(){const u=document.getElementById("recipe-volumes-section"),v=document.getElementById("recipe-volume-list"),x=A.sharedVolumes;if(x&&Object.keys(x).length>0){u.style.display="",v.innerHTML="";for(const[g,b]of Object.entries(x)){const s=document.createElement("div");s.style.cssText="display: grid; gap: 4px;",s.innerHTML=`
|
||||
<label style="font-weight: 500; font-size: 0.85rem;">${escapeHtml(b.label||g)}</label>
|
||||
<input type="text" data-volume-key="${g}" value="${escapeHtml(b.defaultPath||"")}"
|
||||
placeholder="${escapeHtml(b.defaultPath||"/path")}"
|
||||
@@ -508,7 +508,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<strong>Deployment failed:</strong> ${escapeHtml(l.error||"Unknown error")}
|
||||
</div>`,showNotification(`Recipe deployment failed: ${l.error}`,"error",5e3)}catch(p){v.style.display="",v.innerHTML=`<div style="padding: 14px; border-radius: 8px; background: rgba(231,76,60,0.1); border: 1px solid rgba(231,76,60,0.3); color: var(--bad-fg);">
|
||||
<strong>Network error:</strong> ${escapeHtml(p.message)}
|
||||
</div>`}}function N(u,v,x){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const b=g.querySelector(".recipe-progress-icon"),s=g.querySelector(".recipe-progress-status");v==="deploying"?(b.textContent="\u23F3",s.style.color="var(--accent)"):v==="success"?(b.textContent="\u2705",s.style.color="var(--ok-fg)"):v==="error"&&(b.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=x}h.addEventListener("click",()=>{if(S===3){S=4,$(),j();return}S<3&&(S++,$(),S===2&&I(),S===3&&O())}),D.addEventListener("click",()=>{S>1&&S<4&&(S--,$())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const v={};u.forEach(x=>{const g=x.dataset.recipeId;v[g]||(v[g]=[]),v[g].push(x)});for(const[x,g]of Object.entries(v))g.length<2||g.forEach((b,s)=>{if(b.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let p=b.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=x.replace(/-/g," "),b.style.position="relative",b.appendChild(p))}})},window.manageRecipe=async function(u,v){const x=`/api/v1/recipes/${u}/${v}`,g=v==="remove"?"DELETE":"POST",b=v==="remove"?`/api/v1/recipes/${u}`:x;if(!(v==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(b,{method:g})).json();p.success?(showNotification(`Recipe ${v}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${v} failed: ${p.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const R=document.createElement("style");R.textContent=`
|
||||
</div>`}}function N(u,v,x){const g=document.getElementById(`recipe-progress-${u}`);if(!g)return;const b=g.querySelector(".recipe-progress-icon"),s=g.querySelector(".recipe-progress-status");v==="deploying"?(b.textContent="\u23F3",s.style.color="var(--accent)"):v==="success"?(b.textContent="\u2705",s.style.color="var(--ok-fg)"):v==="error"&&(b.textContent="\u274C",s.style.color="var(--bad-fg)"),s.textContent=x}h.addEventListener("click",()=>{if(I===3){I=4,$(),j();return}I<3&&(I++,$(),I===2&&E(),I===3&&O())}),D.addEventListener("click",()=>{I>1&&I<4&&(I--,$())}),window.groupRecipeCards=function(){const u=document.querySelectorAll(".service-card[data-recipe-id]");if(u.length===0)return;const v={};u.forEach(x=>{const g=x.dataset.recipeId;v[g]||(v[g]=[]),v[g].push(x)});for(const[x,g]of Object.entries(v))g.length<2||g.forEach((b,s)=>{if(b.style.borderLeft="3px solid rgba(142,68,173,0.5)",s===0){let p=b.querySelector(".recipe-group-label");p||(p=document.createElement("div"),p.className="recipe-group-label",p.style.cssText="position: absolute; top: -8px; left: 12px; font-size: 0.6rem; padding: 1px 8px; border-radius: 8px; background: rgba(142,68,173,0.3); color: #d4a5ff; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;",p.textContent=x.replace(/-/g," "),b.style.position="relative",b.appendChild(p))}})},window.manageRecipe=async function(u,v){const x=`/api/v1/recipes/${u}/${v}`,g=v==="remove"?"DELETE":"POST",b=v==="remove"?`/api/v1/recipes/${u}`:x;if(!(v==="remove"&&!confirm(`Remove the entire ${u} recipe? This will delete all containers and configuration.`)))try{const p=await(await secureFetch(b,{method:g})).json();p.success?(showNotification(`Recipe ${v}: ${p.results?.filter(l=>l.status!=="failed").length||0} components processed`,"success",4e3),window.loadServices&&window.loadServices()):showNotification(`Recipe ${v} failed: ${p.error}`,"error",5e3)}catch(s){showNotification(`Network error: ${s.message}`,"error",5e3)}};const R=document.createElement("style");R.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(R),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const w=document.getElementById("reload-caddy-top"),B=w.textContent;try{w.textContent="\u23F3 Reloading...",w.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),S=await A.json();if(A.ok&&S.success)w.textContent="\u2705 Reloaded!",setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3);else throw new Error(S.error||"Reload failed")}catch(A){w.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{w.textContent=B,w.disabled=!1},2e3)}})})(),(function(){injectModal("error-log-modal",`<div id="error-log-modal" class="weather-modal">
|
||||
`,document.head.appendChild(R),f()})(),(function(){document.getElementById("reload-caddy-top")?.addEventListener("click",async()=>{const k=document.getElementById("reload-caddy-top"),B=k.textContent;try{k.textContent="\u23F3 Reloading...",k.disabled=!0;const A=await secureFetch("/api/v1/caddy/reload",{method:"POST",headers:{"Content-Type":"application/json"}}),I=await A.json();if(A.ok&&I.success)k.textContent="\u2705 Reloaded!",setTimeout(()=>{k.textContent=B,k.disabled=!1},2e3);else throw new Error(I.error||"Reload failed")}catch(A){k.textContent="\u274C Failed",showNotification(`Failed to reload Caddy: ${A.message}`,"error"),setTimeout(()=>{k.textContent=B,k.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 w=document.getElementById("error-log-modal"),B=document.getElementById("view-error-logs"),A=document.getElementById("error-log-refresh"),S=document.getElementById("error-log-clear"),z=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),D=document.getElementById("error-log-search"),h=document.getElementById("error-log-since"),T=document.getElementById("error-log-until"),f=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),k=document.getElementById("error-log-total"),$=50;let E=0,I=null,O=0,C=[];function j(b){if(!b)return null;const s=new Date(b);return isNaN(s.getTime())?null:s.toISOString()}async function N(){try{const b=await fetch("/api/v1/error-logs/contexts");if(!b.ok)return;const s=await b.json();if(!s.success||!Array.isArray(s.contexts))return;C=s.contexts;const p=M.value;M.innerHTML='<option value="">All</option>';for(const l of s.contexts){const m=document.createElement("option");m.value=l.name,m.textContent=`${l.name} (${l.count})`,M.appendChild(m)}p&&s.contexts.some(l=>l.name===p)&&(M.value=p)}catch{}}function R(){const b=new URLSearchParams;b.set("limit",String($)),b.set("offset",String(E)),P.value&&b.set("level",P.value),M.value&&b.set("context",M.value);const s=j(h.value),p=j(T.value);s&&b.set("since",s),p&&b.set("until",p);const l=(D.value||"").trim();return l&&b.set("search",l),b}async function u(b){try{b?(I&&I.abort(),I=new AbortController):(I&&I.abort(),I=new AbortController,E=0,O++,f.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const s=O,p=R(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:I.signal});if(!l.ok){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,H.style.display="none",k.textContent="";return}const m=await l.json();if(!m.success){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.error||"unknown")}</div>`,H.style.display="none",k.textContent="";return}if(!b&&s!==O)return;const c=Array.isArray(m.logs)?m.logs:[];if(c.length===0&&!b){const e=m.filters&&(m.filters.level||m.filters.context||m.filters.search||m.filters.since||m.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";f.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,H.style.display="none",k.textContent=m.total?`${m.total} total`:"";return}let t="";b||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of c){const a=(e.level||"?").toUpperCase(),o=a==="ERR"?"var(--bad-fg)":a==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",i=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",n=e.context||"\u2014",r=(e.error||"").split(`
|
||||
`)[0],d=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(a)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(n)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!b)t+="</table>",f.innerHTML=t;else{const e=f.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}E+=c.length,H.style.display=m.hasMore?"":"none",k.textContent=`${m.total} total${m.hasMore?" (showing "+E+")":""}`,f.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const a=e.nextElementSibling;a&&a.classList.contains("error-log-detail")&&(a.style.display=a.style.display==="none"?"":"none")}))})}catch(s){if(s&&s.name==="AbortError")return;f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`,k.textContent=""}}async function v(){if(confirm("Clear the entire error log? This cannot be undone."))try{const s=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();s.success?(await N(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(s.error||"Clear failed"),"error",4e3)}catch(b){showNotification("\u274C "+b.message,"error",4e3)}}let x;function g(){P?.addEventListener("change",()=>u(!1)),M?.addEventListener("change",()=>u(!1)),D?.addEventListener("input",()=>{clearTimeout(x),x=setTimeout(()=>u(!1),250)});let b;[h,T].forEach(s=>{s?.addEventListener("change",()=>{clearTimeout(b),b=setTimeout(()=>u(!1),250)})}),A?.addEventListener("click",()=>u(!1)),H?.addEventListener("click",()=>u(!0)),S?.addEventListener("click",v),wireModal(w,z)}B?.addEventListener("click",async()=>{w?.classList.add("show"),await N(),u(!1)}),g()})(),(function(){injectModal("container-logs-modal",`<div id="container-logs-modal" class="weather-modal" style="z-index: 1001;">
|
||||
</div>`);const k=document.getElementById("error-log-modal"),B=document.getElementById("view-error-logs"),A=document.getElementById("error-log-refresh"),I=document.getElementById("error-log-clear"),z=document.getElementById("error-log-close"),P=document.getElementById("error-log-level"),M=document.getElementById("error-log-context"),D=document.getElementById("error-log-search"),h=document.getElementById("error-log-since"),T=document.getElementById("error-log-until"),f=document.getElementById("error-log-container"),H=document.getElementById("error-log-load-more"),S=document.getElementById("error-log-total"),$=50;let w=0,E=null,O=0,C=[];function j(b){if(!b)return null;const s=new Date(b);return isNaN(s.getTime())?null:s.toISOString()}async function N(){try{const b=await fetch("/api/v1/error-logs/contexts");if(!b.ok)return;const s=await b.json();if(!s.success||!Array.isArray(s.contexts))return;C=s.contexts;const p=M.value;M.innerHTML='<option value="">All</option>';for(const l of s.contexts){const m=document.createElement("option");m.value=l.name,m.textContent=`${l.name} (${l.count})`,M.appendChild(m)}p&&s.contexts.some(l=>l.name===p)&&(M.value=p)}catch{}}function R(){const b=new URLSearchParams;b.set("limit",String($)),b.set("offset",String(w)),P.value&&b.set("level",P.value),M.value&&b.set("context",M.value);const s=j(h.value),p=j(T.value);s&&b.set("since",s),p&&b.set("until",p);const l=(D.value||"").trim();return l&&b.set("search",l),b}async function u(b){try{b?(E&&E.abort(),E=new AbortController):(E&&E.abort(),E=new AbortController,w=0,O++,f.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const s=O,p=R(),l=await fetch("/api/v1/error-logs?"+p.toString(),{signal:E.signal});if(!l.ok){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${l.status}</div>`,H.style.display="none",S.textContent="";return}const m=await l.json();if(!m.success){f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(m.error||"unknown")}</div>`,H.style.display="none",S.textContent="";return}if(!b&&s!==O)return;const c=Array.isArray(m.logs)?m.logs:[];if(c.length===0&&!b){const e=m.filters&&(m.filters.level||m.filters.context||m.filters.search||m.filters.since||m.filters.until)?"No error log entries match your filters.":"\u2705 No errors logged! Everything is working smoothly.";f.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4CB}</span>${escapeHtml(e)}</div>`,H.style.display="none",S.textContent=m.total?`${m.total} total`:"";return}let t="";b||(t='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',t+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',t+='<th style="padding: 6px; text-align: left; width: 160px;">When</th>',t+='<th style="padding: 6px; text-align: left; width: 80px;">Level</th>',t+='<th style="padding: 6px; text-align: left; width: 140px;">Context</th>',t+='<th style="padding: 6px; text-align: left;">Message</th>',t+='<th style="padding: 6px; text-align: left; width: 110px;">IP</th>',t+="</tr>");for(const e of c){const a=(e.level||"?").toUpperCase(),o=a==="ERR"?"var(--bad-fg)":a==="WARN"?"var(--warn-fg, #f0c674)":"var(--muted)",i=e.timestamp?new Date(e.timestamp).toLocaleString():"\u2014",n=e.context||"\u2014",r=(e.error||"").split(`
|
||||
`)[0],d=e.request&&e.request.ip||"";t+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="error-log-row">',t+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(e.timestamp||"")}">${escapeHtml(i)}</td>`,t+=`<td style="padding: 6px;"><span style="color: ${o}; font-weight: 600;">${escapeHtml(a)}</span></td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(n)}</td>`,t+=`<td style="padding: 6px;">${escapeHtml(r)}</td>`,t+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(d)}</td>`,t+="</tr>",e.detail&&(t+=`<tr class="error-log-detail" style="display: none;"><td colspan="5" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace; max-height: 320px; overflow: auto;">${escapeHtml(e.detail)}</pre></td></tr>`)}if(!b)t+="</table>",f.innerHTML=t;else{const e=f.querySelector("table");e&&e.insertAdjacentHTML("beforeend",t)}w+=c.length,H.style.display=m.hasMore?"":"none",S.textContent=`${m.total} total${m.hasMore?" (showing "+w+")":""}`,f.querySelectorAll(".error-log-row").forEach(e=>{e.dataset.wired||(e.dataset.wired="true",e.addEventListener("click",()=>{const a=e.nextElementSibling;a&&a.classList.contains("error-log-detail")&&(a.style.display=a.style.display==="none"?"":"none")}))})}catch(s){if(s&&s.name==="AbortError")return;f.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(s.message)}</div>`,S.textContent=""}}async function v(){if(confirm("Clear the entire error log? This cannot be undone."))try{const s=await(await secureFetch("/api/v1/error-logs",{method:"DELETE",headers:{"content-type":"application/json"},body:JSON.stringify({confirm:"CLEAR"})})).json();s.success?(await N(),u(!1),showNotification("\u2705 Error logs cleared","success",3e3)):showNotification("\u274C "+(s.error||"Clear failed"),"error",4e3)}catch(b){showNotification("\u274C "+b.message,"error",4e3)}}let x;function g(){P?.addEventListener("change",()=>u(!1)),M?.addEventListener("change",()=>u(!1)),D?.addEventListener("input",()=>{clearTimeout(x),x=setTimeout(()=>u(!1),250)});let b;[h,T].forEach(s=>{s?.addEventListener("change",()=>{clearTimeout(b),b=setTimeout(()=>u(!1),250)})}),A?.addEventListener("click",()=>u(!1)),H?.addEventListener("click",()=>u(!0)),I?.addEventListener("click",v),wireModal(k,z)}B?.addEventListener("click",async()=>{k?.classList.add("show"),await N(),u(!1)}),g()})(),(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 w=document.getElementById("container-logs-modal"),B=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),S=document.getElementById("cl-log-search"),z=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),h=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),f=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),k=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),E=document.getElementById("cl-line-count"),I=document.getElementById("cl-filter-count"),O=document.getElementById("cl-image"),C=document.getElementById("cl-status"),j=document.getElementById("cl-created");let N=null,R=[],u=[],v=null,x=!1,g=null;function b(r){if(!r)return"-";const d=new Date(r);return isNaN(d.getTime())?r:d.toLocaleString()}function s(r){if(!r)return"";const d=document.createElement("div");return d.textContent=r,d.innerHTML}function p(r,d){const y=r.stream==="stderr"?"log-stderr":"log-stdout",L=r.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
</div>`);const k=document.getElementById("container-logs-modal"),B=document.getElementById("cl-container-select"),A=document.getElementById("cl-log-content"),I=document.getElementById("cl-log-search"),z=document.getElementById("cl-log-tail"),P=document.getElementById("cl-refresh"),M=document.getElementById("cl-stream"),D=document.getElementById("cl-download"),h=document.getElementById("cl-clear-search"),T=document.getElementById("cl-close"),f=document.getElementById("cl-close-btn"),H=document.getElementById("cl-stream-status"),S=document.getElementById("cl-stream-indicator"),$=document.getElementById("cl-stream-text"),w=document.getElementById("cl-line-count"),E=document.getElementById("cl-filter-count"),O=document.getElementById("cl-image"),C=document.getElementById("cl-status"),j=document.getElementById("cl-created");let N=null,R=[],u=[],v=null,x=!1,g=null;function b(r){if(!r)return"-";const d=new Date(r);return isNaN(d.getTime())?r:d.toLocaleString()}function s(r){if(!r)return"";const d=document.createElement("div");return d.textContent=r,d.innerHTML}function p(r,d){const y=r.stream==="stderr"?"log-stderr":"log-stdout",L=r.stream==="stderr"?"\u26A0\uFE0F":"\u{1F4E4}";return`
|
||||
<div class="log-entry ${y}" data-index="${d}" style="padding: 4px 8px; border-bottom: 1px solid #333; display: flex; gap: 8px;">
|
||||
<span class="log-line-num" style="color: #666; min-width: 40px; text-align: right; user-select: none;">${d+1}</span>
|
||||
<span class="log-stream-icon" style="color: ${r.stream==="stderr"?"#f59e0b":"#22c55e"};">${L}</span>
|
||||
<span class="log-text" style="flex: 1; white-space: pre-wrap; word-break: break-all; color: #e5e5e5;">${s(r.text)}</span>
|
||||
</div>
|
||||
`}function l(r,d=""){if(!r||r.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',E.textContent="0 lines",I.textContent="0 filtered";return}if(R=r,u=d?r.filter(y=>y.text&&y.text.toLowerCase().includes(d.toLowerCase())):r,E.textContent=`${r.length} lines`,I.textContent=d?`${u.length} of ${r.length} shown`:`${r.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(d)}"</div>`;return}A.innerHTML=u.map((y,L)=>p(y,L)).join(""),A.scrollTop=A.scrollHeight}async function m(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],y=B.value;B.innerHTML='<option value="">Select a container...</option>',d.forEach(L=>{const U=document.createElement("option");U.value=L.id,U.textContent=`${L.name} (${L.image.split(":")[0]}) - ${L.status}`,U.dataset.name=L.name,U.dataset.image=L.image,U.dataset.status=L.status,U.dataset.created=L.created,B.appendChild(U)}),y&&B.querySelector(`option[value="${y}"]`)&&(B.value=y,c(y))}catch(r){console.error("Failed to load containers:",r)}}function c(r){const d=B.querySelector(`option[value="${r}"]`);d&&(O.textContent=d.dataset.image||"-",C.textContent=d.dataset.status||"-",C.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",j.textContent=b(d.dataset.created))}async function t(){const r=B.value;if(!r){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=r,c(r);const d=z.value,y=S.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const L=`/api/v1/logs/container/${r}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(L);U.logs&&U.logs.length>0?l(U.logs,y):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',E.textContent="0 lines",I.textContent="0 filtered")}catch(L){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(L.message)}</div>`}}function e(){const r=B.value;if(!r)return;a(),x=!0,M.textContent="\u23F9 Stop",H.style.display="flex",k.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${r}`;v=new EventSource(d),v.onopen=()=>{k.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},v.onmessage=y=>{try{const L=JSON.parse(y.data);if(L.error){k.textContent="\u{1F534}",$.textContent=`Error: ${L.error}`;return}R.push(L),u.push(L),E.textContent=`${R.length} lines`,I.textContent=`${u.length} shown`;const U=S.value.trim();if(!U||L.text&&L.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=p(L,u.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(L){console.error("Error parsing log:",L)}},v.onerror=()=>{k.textContent="\u{1F534}",$.textContent="Disconnected",x=!1,M.textContent="\u25B6 Stream"},w._eventSource=v}function a(){v&&(v.close(),v=null),w._eventSource&&(w._eventSource.close(),w._eventSource=null),x=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function o(){if(!R||R.length===0){showNotification("No logs to download","error");return}const r=B.querySelector(`option[value="${N}"]`)?.dataset.name||N,d=new Date().toISOString().replace(/[:.]/g,"-"),y=`${r}-logs-${d}.txt`,L=R.map(_=>{const J=_.timestamp||"",X=_.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${_.text}`}).join(`
|
||||
`),U=new Blob([L],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=y,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${R.length} log lines`,"success")}B?.addEventListener("change",()=>{t()}),z?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),M?.addEventListener("click",()=>{x?a():e()}),D?.addEventListener("click",()=>{o()}),h?.addEventListener("click",()=>{S.value="",l(R,"")}),S?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{l(R,S.value.trim())},300)}),S?.addEventListener("keydown",r=>{r.key==="Escape"&&(S.value="",l(R,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{w.classList.add("show"),m()});function n(){a(),w.classList.remove("show")}T?.addEventListener("click",n),f?.addEventListener("click",n),document.addEventListener("keydown",r=>{r.key==="Escape"&&w.classList.contains("show")&&n()}),w.addEventListener("click",r=>{r.target===w&&n()}),window.openContainerLogsModal=function(r,d){w.classList.add("show"),m().then(()=>{const y=Array.from(B.options).find(L=>L.value===r||L.dataset.name===d);y?(B.value=y.value,c(y.value),t()):r?(N=r,O.textContent=d||r,C.textContent="-",j.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const w=[{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 l(r,d=""){if(!r||r.length===0){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs available</div>',w.textContent="0 lines",E.textContent="0 filtered";return}if(R=r,u=d?r.filter(y=>y.text&&y.text.toLowerCase().includes(d.toLowerCase())):r,w.textContent=`${r.length} lines`,E.textContent=d?`${u.length} of ${r.length} shown`:`${r.length} shown`,u.length===0){A.innerHTML=`<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs match "${s(d)}"</div>`;return}A.innerHTML=u.map((y,L)=>p(y,L)).join(""),A.scrollTop=A.scrollHeight}async function m(){try{const d=(await getJSON("/api/v1/logs/containers")).containers||[],y=B.value;B.innerHTML='<option value="">Select a container...</option>',d.forEach(L=>{const U=document.createElement("option");U.value=L.id,U.textContent=`${L.name} (${L.image.split(":")[0]}) - ${L.status}`,U.dataset.name=L.name,U.dataset.image=L.image,U.dataset.status=L.status,U.dataset.created=L.created,B.appendChild(U)}),y&&B.querySelector(`option[value="${y}"]`)&&(B.value=y,c(y))}catch(r){console.error("Failed to load containers:",r)}}function c(r){const d=B.querySelector(`option[value="${r}"]`);d&&(O.textContent=d.dataset.image||"-",C.textContent=d.dataset.status||"-",C.style.color=d.dataset.status==="running"?"var(--ok-fg, #4ade80)":"var(--bad-fg, #ef4444)",j.textContent=b(d.dataset.created))}async function t(){const r=B.value;if(!r){A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>';return}a(),N=r,c(r);const d=z.value,y=I.value.trim();A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading logs...</div>';try{const L=`/api/v1/logs/container/${r}${d!=="all"?`?tail=${d}`:""}`,U=await getJSON(L);U.logs&&U.logs.length>0?l(U.logs,y):(A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No logs found for this container</div>',w.textContent="0 lines",E.textContent="0 filtered")}catch(L){A.innerHTML=`<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error loading logs: ${s(L.message)}</div>`}}function e(){const r=B.value;if(!r)return;a(),x=!0,M.textContent="\u23F9 Stop",H.style.display="flex",S.textContent="\u{1F7E2}",$.textContent="Connecting...";const d=`/api/v1/logs/stream/${r}`;v=new EventSource(d),v.onopen=()=>{S.textContent="\u{1F7E2}",$.textContent="Connected - streaming logs"},v.onmessage=y=>{try{const L=JSON.parse(y.data);if(L.error){S.textContent="\u{1F534}",$.textContent=`Error: ${L.error}`;return}R.push(L),u.push(L),w.textContent=`${R.length} lines`,E.textContent=`${u.length} shown`;const U=I.value.trim();if(!U||L.text&&L.text.toLowerCase().includes(U.toLowerCase())){const q=document.createElement("div");q.innerHTML=p(L,u.length-1);const F=q.firstElementChild;F.style.background="#1a3a1a",A.appendChild(F),A.scrollTop=A.scrollHeight}}catch(L){console.error("Error parsing log:",L)}},v.onerror=()=>{S.textContent="\u{1F534}",$.textContent="Disconnected",x=!1,M.textContent="\u25B6 Stream"},k._eventSource=v}function a(){v&&(v.close(),v=null),k._eventSource&&(k._eventSource.close(),k._eventSource=null),x=!1,M.textContent="\u25B6 Stream",H.style.display="none"}function o(){if(!R||R.length===0){showNotification("No logs to download","error");return}const r=B.querySelector(`option[value="${N}"]`)?.dataset.name||N,d=new Date().toISOString().replace(/[:.]/g,"-"),y=`${r}-logs-${d}.txt`,L=R.map(_=>{const J=_.timestamp||"",X=_.stream==="stderr"?"[ERR]":"[OUT]";return`${J?J+" ":""}${X} ${_.text}`}).join(`
|
||||
`),U=new Blob([L],{type:"text/plain"}),q=URL.createObjectURL(U),F=document.createElement("a");F.href=q,F.download=y,document.body.appendChild(F),F.click(),document.body.removeChild(F),URL.revokeObjectURL(q),showNotification(`Downloaded ${R.length} log lines`,"success")}B?.addEventListener("change",()=>{t()}),z?.addEventListener("change",()=>{t()}),P?.addEventListener("click",()=>{t()}),M?.addEventListener("click",()=>{x?a():e()}),D?.addEventListener("click",()=>{o()}),h?.addEventListener("click",()=>{I.value="",l(R,"")}),I?.addEventListener("input",()=>{clearTimeout(g),g=setTimeout(()=>{l(R,I.value.trim())},300)}),I?.addEventListener("keydown",r=>{r.key==="Escape"&&(I.value="",l(R,""))}),document.getElementById("view-container-logs")?.addEventListener("click",()=>{k.classList.add("show"),m()});function n(){a(),k.classList.remove("show")}T?.addEventListener("click",n),f?.addEventListener("click",n),document.addEventListener("keydown",r=>{r.key==="Escape"&&k.classList.contains("show")&&n()}),k.addEventListener("click",r=>{r.target===k&&n()}),window.openContainerLogsModal=function(r,d){k.classList.add("show"),m().then(()=>{const y=Array.from(B.options).find(L=>L.value===r||L.dataset.name===d);y?(B.value=y.value,c(y.value),t()):r?(N=r,O.textContent=d||r,C.textContent="-",j.textContent="-",t()):A.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a container to view logs</div>'})}})(),(function(){"use strict";const k=[{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 B=document.getElementById("journald-modal"),A=document.getElementById("jd-unit-select"),S=document.getElementById("jd-search"),z=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),D=document.getElementById("jd-clear-search"),h=document.getElementById("jd-close"),T=document.getElementById("jd-close-btn"),f=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),k=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),E=document.getElementById("jd-unit-display"),I=document.getElementById("jd-stream-state");let O=!1,C=[],j=!1,N=null,R=null;function u(c){const t=document.createElement("div");return t.textContent=String(c),t.innerHTML}function v(c){O=c,A.innerHTML="",w.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",A.appendChild(e)}),A.disabled=!c,c?(P.disabled=!1,M.disabled=!1):(f.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 x(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){v(!1);return}const t=await c.json();v(!!t.available)}catch{v(!1)}}function g(){const c=(S.value||"").trim().toLowerCase(),t=c?C.filter(e=>(e.textContent||"").toLowerCase().includes(c)):C;if(t.length===0)f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching "${u(c)}"`:"")+"</div>";else{const e=t.map(o=>{const i=o.timestamp?u(o.timestamp):"\u2014",n=u(o.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${i}</span>${n}</div>`}).join("");f.innerHTML=e,f.scrollHeight-f.scrollTop-f.clientHeight<80&&(f.scrollTop=f.scrollHeight)}H.textContent=`${C.length} entries`,k.textContent=c?`${t.length} of ${C.length} shown`:`${C.length} shown`}async function b(){if(!O)return;p();const c=A.value;if(!c)return;const t=Math.max(1,Math.min(5e3,Number(z.value)||200)),e=(S.value||"").trim();f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const a=new URL("/api/v1/logs/journal",window.location.origin);a.searchParams.set("unit",c),a.searchParams.set("tail",String(t)),e&&a.searchParams.set("search",e);const o=await fetch(a.toString()),i=await o.json();if(!o.ok||!i.success){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(i&&i.error||"HTTP "+o.status)+"</div>";return}E.textContent=c,C=(i.entries||[]).map(n=>({timestamp:n.timestamp,unit:n.unit,textContent:n.text||""})),$.style.display="none",g()}catch(a){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(a.message)+"</div>"}}function s(){if(!O)return;p();const c=A.value;if(!c)return;const t=(S.value||"").trim();E.textContent=c,M.textContent="\u23F8 Stop",M.classList.add("streaming"),I.textContent="streaming",I.style.color="var(--ok-fg, #4ade80)",C=[],g(),$.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",c),t&&e.searchParams.set("search",t),N=new EventSource(e.toString()),N.onmessage=a=>{try{const o=JSON.parse(a.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&($.style.display="",p()),f.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(o.error)+"</div>",f.scrollTop=f.scrollHeight;return}C.push({timestamp:o.timestamp,unit:o.unit||c,textContent:o.text||""}),C.length>5e3&&(C=C.slice(C.length-5e3),$.style.display=""),g()}catch{}},N.onerror=()=>{},j=!0}function p(){if(j=!1,N){try{N.close()}catch{}N=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),I.textContent="disconnected",I.style.color="var(--muted)"}function l(){p(),B.classList.remove("show")}P.addEventListener("click",b),M.addEventListener("click",()=>j?p():s()),D.addEventListener("click",()=>{S.value="",g()}),S.addEventListener("input",()=>{clearTimeout(R),R=setTimeout(g,200)}),S.addEventListener("keydown",c=>{c.key==="Escape"&&(S.value="",g())}),h.addEventListener("click",l),T.addEventListener("click",l),B.addEventListener("click",c=>{c.target===B&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&B.classList.contains("show")&&l()}),A.addEventListener("change",()=>{C.length>0&&b()});function m(){B.classList.add("show"),x()}window.openJournaldModal=m,document.getElementById("view-journald-logs")?.addEventListener("click",m)})(),(function(){injectModal("snapshot-modal",`<div id="snapshot-modal" class="weather-modal">
|
||||
`);const B=document.getElementById("journald-modal"),A=document.getElementById("jd-unit-select"),I=document.getElementById("jd-search"),z=document.getElementById("jd-tail"),P=document.getElementById("jd-refresh"),M=document.getElementById("jd-stream"),D=document.getElementById("jd-clear-search"),h=document.getElementById("jd-close"),T=document.getElementById("jd-close-btn"),f=document.getElementById("jd-content"),H=document.getElementById("jd-line-count"),S=document.getElementById("jd-filter-count"),$=document.getElementById("jd-overflow"),w=document.getElementById("jd-unit-display"),E=document.getElementById("jd-stream-state");let O=!1,C=[],j=!1,N=null,R=null;function u(c){const t=document.createElement("div");return t.textContent=String(c),t.innerHTML}function v(c){O=c,A.innerHTML="",k.forEach(t=>{const e=document.createElement("option");e.value=t.unit,e.textContent=t.label+" ("+t.unit+")",A.appendChild(e)}),A.disabled=!c,c?(P.disabled=!1,M.disabled=!1):(f.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 x(){try{const c=await fetch("/api/v1/logs/journal/units");if(!c.ok){v(!1);return}const t=await c.json();v(!!t.available)}catch{v(!1)}}function g(){const c=(I.value||"").trim().toLowerCase(),t=c?C.filter(e=>(e.textContent||"").toLowerCase().includes(c)):C;if(t.length===0)f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries'+(c?` matching "${u(c)}"`:"")+"</div>";else{const e=t.map(o=>{const i=o.timestamp?u(o.timestamp):"\u2014",n=u(o.textContent);return`<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${i}</span>${n}</div>`}).join("");f.innerHTML=e,f.scrollHeight-f.scrollTop-f.clientHeight<80&&(f.scrollTop=f.scrollHeight)}H.textContent=`${C.length} entries`,S.textContent=c?`${t.length} of ${C.length} shown`:`${C.length} shown`}async function b(){if(!O)return;p();const c=A.value;if(!c)return;const t=Math.max(1,Math.min(5e3,Number(z.value)||200)),e=(I.value||"").trim();f.innerHTML='<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading\u2026</div>';try{const a=new URL("/api/v1/logs/journal",window.location.origin);a.searchParams.set("unit",c),a.searchParams.set("tail",String(t)),e&&a.searchParams.set("search",e);const o=await fetch(a.toString()),i=await o.json();if(!o.ok||!i.success){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: '+u(i&&i.error||"HTTP "+o.status)+"</div>";return}w.textContent=c,C=(i.entries||[]).map(n=>({timestamp:n.timestamp,unit:n.unit,textContent:n.text||""})),$.style.display="none",g()}catch(a){f.innerHTML='<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: '+u(a.message)+"</div>"}}function s(){if(!O)return;p();const c=A.value;if(!c)return;const t=(I.value||"").trim();w.textContent=c,M.textContent="\u23F8 Stop",M.classList.add("streaming"),E.textContent="streaming",E.style.color="var(--ok-fg, #4ade80)",C=[],g(),$.style.display="none";const e=new URL("/api/v1/logs/journal/stream",window.location.origin);e.searchParams.set("unit",c),t&&e.searchParams.set("search",t),N=new EventSource(e.toString()),N.onmessage=a=>{try{const o=JSON.parse(a.data);if(o.error){/stream (exceeded|line cap)/.test(o.error)&&($.style.display="",p()),f.innerHTML+='<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">\u26A0 '+u(o.error)+"</div>",f.scrollTop=f.scrollHeight;return}C.push({timestamp:o.timestamp,unit:o.unit||c,textContent:o.text||""}),C.length>5e3&&(C=C.slice(C.length-5e3),$.style.display=""),g()}catch{}},N.onerror=()=>{},j=!0}function p(){if(j=!1,N){try{N.close()}catch{}N=null}M.textContent="\u25B6 Stream",M.classList.remove("streaming"),E.textContent="disconnected",E.style.color="var(--muted)"}function l(){p(),B.classList.remove("show")}P.addEventListener("click",b),M.addEventListener("click",()=>j?p():s()),D.addEventListener("click",()=>{I.value="",g()}),I.addEventListener("input",()=>{clearTimeout(R),R=setTimeout(g,200)}),I.addEventListener("keydown",c=>{c.key==="Escape"&&(I.value="",g())}),h.addEventListener("click",l),T.addEventListener("click",l),B.addEventListener("click",c=>{c.target===B&&l()}),document.addEventListener("keydown",c=>{c.key==="Escape"&&B.classList.contains("show")&&l()}),A.addEventListener("change",()=>{C.length>0&&b()});function m(){B.classList.add("show"),x()}window.openJournaldModal=m,document.getElementById("view-journald-logs")?.addEventListener("click",m)})(),(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 w=document.getElementById("snapshot-modal"),B=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),S=document.getElementById("snapshot-container-select"),z=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let D=null;async function h(){try{const E=await(await fetch("/api/v1/containers")).json();if(!E.success||!E.containers)return;S.innerHTML='<option value="">-- Select a container --</option>';for(const I of E.containers){const O=document.createElement("option");O.value=I.id,O.textContent=`${I.name||I.id} (${I.image||"unknown"})`,O.dataset.name=I.name,O.dataset.image=I.image,O.dataset.status=I.status,O.dataset.created=I.created,S.appendChild(O)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){z.style.display="none",D=null;return}D=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),z.style.display=""}async function f(){if(!D){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const E=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const O=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:E})})).json();O.success?(M.textContent=`\u2713 Snapshot "${$}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${O.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(I){M.textContent=`\u2717 Error: ${I.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){w.classList.add("show"),h()}function k(){w.classList.remove("show"),z.style.display="none",D=null,S.selectedIndex=0}B?.addEventListener("click",H),A?.addEventListener("click",k),wireModal(w,A),S?.addEventListener("change",$=>{const E=S.options[S.selectedIndex];T(E)}),P?.addEventListener("click",f),w?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{w.querySelectorAll(".panel-tab").forEach(E=>E.classList.remove("active")),w.querySelectorAll(".panel-section").forEach(E=>E.classList.remove("active")),$.classList.add("active"),w.querySelector(`#${$.dataset.panel}`).classList.add("active")})})})(),(function(){injectModal("arr-setup-modal",`<div id="arr-setup-modal" class="weather-modal">
|
||||
</div>`);const k=document.getElementById("snapshot-modal"),B=document.getElementById("snapshot-btn"),A=document.getElementById("snapshot-close"),I=document.getElementById("snapshot-container-select"),z=document.getElementById("snapshot-details"),P=document.getElementById("snapshot-create-btn"),M=document.getElementById("snapshot-create-status");let D=null;async function h(){try{const w=await(await fetch("/api/v1/containers")).json();if(!w.success||!w.containers)return;I.innerHTML='<option value="">-- Select a container --</option>';for(const E of w.containers){const O=document.createElement("option");O.value=E.id,O.textContent=`${E.name||E.id} (${E.image||"unknown"})`,O.dataset.name=E.name,O.dataset.image=E.image,O.dataset.status=E.status,O.dataset.created=E.created,I.appendChild(O)}}catch($){console.error("Failed to load containers:",$)}}function T($){if(!$||!$.value){z.style.display="none",D=null;return}D=$.value,document.getElementById("snapshot-image").textContent=$.dataset.image||"-",document.getElementById("snapshot-status").textContent=$.dataset.status||"-",document.getElementById("snapshot-created").textContent=$.dataset.created?new Date($.dataset.created*1e3).toLocaleString():"-",document.getElementById("snapshot-id").textContent=$.value.substring(0,12),z.style.display=""}async function f(){if(!D){M.textContent="Please select a container first",M.style.color="var(--bad-fg)";return}const $=document.getElementById("snapshot-name").value.trim();if(!$){M.textContent="Please enter a snapshot name",M.style.color="var(--bad-fg)";return}const w=document.getElementById("snapshot-leave-running").checked;P.disabled=!0,P.textContent="Creating...",M.textContent="";try{const O=await(await fetch(`/api/v1/containers/${encodeURIComponent(D)}/checkpoint`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:$,leaveRunning:w})})).json();O.success?(M.textContent=`\u2713 Snapshot "${$}" created successfully`,M.style.color="var(--ok-fg)",document.getElementById("snapshot-name").value=""):(M.textContent=`\u2717 Failed: ${O.error||"Unknown error"}`,M.style.color="var(--bad-fg)")}catch(E){M.textContent=`\u2717 Error: ${E.message}`,M.style.color="var(--bad-fg)"}finally{P.disabled=!1,P.textContent="\u{1F4BE} Create Snapshot"}}function H(){k.classList.add("show"),h()}function S(){k.classList.remove("show"),z.style.display="none",D=null,I.selectedIndex=0}B?.addEventListener("click",H),A?.addEventListener("click",S),wireModal(k,A),I?.addEventListener("change",$=>{const w=I.options[I.selectedIndex];T(w)}),P?.addEventListener("click",f),k?.querySelectorAll(".panel-tab").forEach($=>{$.addEventListener("click",()=>{k.querySelectorAll(".panel-tab").forEach(w=>w.classList.remove("active")),k.querySelectorAll(".panel-section").forEach(w=>w.classList.remove("active")),$.classList.add("active"),k.querySelector(`#${$.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,7 +831,7 @@ 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 w=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),S=document.getElementById("smart-connect-btn"),z=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),h=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),f=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),k=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let E=null;const I={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},O={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){z.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",M.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function j(g){const b={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"}},s=b[g]||b.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, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function N(){C("detect"),h.style.display="none";try{if(E=await(await fetch("/api/v1/arr/smart-detect")).json(),!E.success){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(E.error)}</div>`,h.style.display="block";return}let b='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(E.services)){const m=I[p]||"\u{1F4E6}",c=O[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",a=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";b+=`<div style="display: flex; align-items: center; gap: 10px; padding: 10px 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
</div>`);const k=document.getElementById("arr-setup-modal"),B=document.getElementById("arr-setup-btn"),A=document.getElementById("arr-setup-cancel"),I=document.getElementById("smart-connect-btn"),z=document.getElementById("smart-phase-detect"),P=document.getElementById("smart-phase-credentials"),M=document.getElementById("smart-phase-progress"),D=document.getElementById("smart-phase-results"),h=document.getElementById("smart-detect-results"),T=document.getElementById("smart-credential-inputs"),f=document.getElementById("smart-progress-steps"),H=document.getElementById("smart-results-content"),S=document.getElementById("smart-plex-libraries"),$=document.getElementById("smart-retry-btn");let w=null;const E={plex:"\u{1F3AC}",radarr:"\u{1F3AC}",sonarr:"\u{1F4FA}",prowlarr:"\u{1F50D}",seerr:"\u{1F4CB}"},O={plex:"Plex",radarr:"Radarr (Movies)",sonarr:"Sonarr (TV)",prowlarr:"Prowlarr (Indexers)",seerr:"Seerr"};function C(g){z.style.display=g==="detect"?"block":"none",P.style.display=g==="credentials"?"block":"none",M.style.display=g==="progress"?"block":"none",D.style.display=g==="results"?"block":"none"}function j(g){const b={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"}},s=b[g]||b.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, ${s.bg} 20%, transparent); color: ${s.bg};">${s.icon} ${s.text}</span>`}async function N(){C("detect"),h.style.display="none";try{if(w=await(await fetch("/api/v1/arr/smart-detect")).json(),!w.success){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Detection failed: ${escapeHtml(w.error)}</div>`,h.style.display="block";return}let b='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[p,l]of Object.entries(w.services)){const m=E[p]||"\u{1F4E6}",c=O[p]||p,t=l.source?`<span style="font-size: 0.7rem; color: var(--muted); padding: 1px 6px; background: var(--card-bg); border-radius: 3px;">${escapeHtml(l.source)}</span>`:"",e=l.version?`<span style="font-size: 0.7rem; color: var(--muted);">v${escapeHtml(l.version)}</span>`:"",a=(l.hasApiKey||l.hasToken)&&l.status==="connected"?'<span style="font-size: 0.7rem; color: var(--ok-fg);">Key saved</span>':"";b+=`<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;">${m}</span>
|
||||
<div style="flex: 1;">
|
||||
<div style="font-weight: 500; font-size: 0.9rem;">${c}</div>
|
||||
@@ -840,10 +840,10 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
</div>
|
||||
</div>
|
||||
${j(l.status)}
|
||||
</div>`}b+="</div>";const s=E.summary;b+=`<div style="margin-top: 12px; padding: 10px; background: color-mix(in srgb, var(--accent) 8%, transparent); border-radius: 8px; font-size: 0.85rem;">
|
||||
</div>`}b+="</div>";const s=w.summary;b+=`<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(s.fullyConnected))}/${escapeHtml(String(s.totalDetected+(5-s.totalDetected)))} services detected ·
|
||||
${escapeHtml(String(s.fullyConnected))} connected${s.needsApiKey>0?` · <strong>${escapeHtml(String(s.needsApiKey))} needs API key</strong>`:""}
|
||||
</div>`,h.innerHTML=b,h.style.display="block",R(E),setTimeout(()=>{C("credentials")},800)}catch(g){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,h.style.display="block"}}function R(g){let b="";const s=g.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const t=s[c];if(!t||t.status==="not_found"&&!t.url)continue;const e=I[c],a=O[c],o=t.status==="connected";b+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
|
||||
</div>`,h.innerHTML=b,h.style.display="block",R(w),setTimeout(()=>{C("credentials")},800)}catch(g){h.innerHTML=`<div style="padding: 12px; color: var(--bad-fg);">Error: ${escapeHtml(g.message)}</div>`,h.style.display="block"}}function R(g){let b="";const s=g.services,p=["radarr","sonarr","prowlarr"];for(const c of p){const t=s[c];if(!t||t.status==="not_found"&&!t.url)continue;const e=E[c],a=O[c],o=t.status==="connected";b+=`<div style="margin-bottom: 10px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid ${o?"var(--ok-fg)":"var(--border)"};">
|
||||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
|
||||
<span style="font-size: 1.1rem;">${e}</span>
|
||||
<span style="font-weight: 500;">${a}</span>
|
||||
@@ -897,7 +897,7 @@ Try refreshing in a moment if you see a certificate error.`,"warning",1e4),!1}};
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 8px;">`;for(const p of b.libraries){const l=p.type==="movie"?"\u{1F3AC}":p.type==="show"?"\u{1F4FA}":"\u{1F3B5}";s+=`<div style="padding: 8px 12px; background: var(--card-bg); border-radius: 6px; font-size: 0.85rem;">
|
||||
${l} <strong>${escapeHtml(p.title)}</strong>
|
||||
<span style="color: var(--muted); font-size: 0.75rem;">${escapeHtml(String(p.count))} items</span>
|
||||
</div>`}s+="</div></div>",k.innerHTML=s,k.style.display="block"}}catch{}}B?.addEventListener("click",()=>{w.classList.add("show"),k.style.display="none",N()}),wireModal(w,A),S?.addEventListener("click",u),$?.addEventListener("click",u)})(),(function(){const w=new ErrorHandler;injectModal("notifications-modal",`<div id="notifications-modal" class="weather-modal">
|
||||
</div>`}s+="</div></div>",S.innerHTML=s,S.style.display="block"}}catch{}}B?.addEventListener("click",()=>{k.classList.add("show"),S.style.display="none",N()}),wireModal(k,A),I?.addEventListener("click",u),$?.addEventListener("click",u)})(),(function(){const k=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>
|
||||
|
||||
@@ -1082,15 +1082,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 B=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),S=document.getElementById("notifications-save"),z=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(k=>{const $=document.getElementById(`${k}-enabled`),E=document.getElementById(`${k}-config`);$?.addEventListener("change",()=>{E.style.display=$.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 D(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const E=$.config;document.getElementById("notifications-enabled").checked=E.enabled,document.getElementById("discord-enabled").checked=E.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=E.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=E.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=E.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=E.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=E.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=E.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=E.providers?.email?.enabled?"block":"none",E.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=E.providers.ntfy.serverUrl),E.providers?.email?.host&&(document.getElementById("email-host").value=E.providers.email.host),E.providers?.email?.from&&(document.getElementById("email-from").value=E.providers.email.from),document.getElementById("health-check-enabled").checked=E.healthCheck?.enabled||!1,E.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=E.healthCheck.intervalMinutes),E.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(E.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=E.events?.containerDown!==!1,document.getElementById("event-container-up").checked=E.events?.containerUp!==!1,document.getElementById("event-deploy-success").checked=E.events?.deploymentSuccess!==!1,document.getElementById("event-deploy-failed").checked=E.events?.deploymentFailed!==!1,document.getElementById("event-resource-alert").checked=E.events?.resourceAlert!==!1}}catch(k){w.logError("[Notifications] Load Config",k,{function:"loadConfig"})}}async function h(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),E=document.getElementById("notification-history");$.success&&$.history?.length>0?E.innerHTML=$.history.map(I=>{const O=new Date(I.timestamp).toLocaleString();return`
|
||||
</div>`);const B=document.getElementById("notifications-modal"),A=document.getElementById("manage-notifications"),I=document.getElementById("notifications-save"),z=document.getElementById("notifications-cancel");["discord","telegram","ntfy","email"].forEach(S=>{const $=document.getElementById(`${S}-enabled`),w=document.getElementById(`${S}-config`);$?.addEventListener("change",()=>{w.style.display=$.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 D(){try{const $=await(await fetch("/api/v1/notifications/config")).json();if($.success){const w=$.config;document.getElementById("notifications-enabled").checked=w.enabled,document.getElementById("discord-enabled").checked=w.providers?.discord?.enabled||!1,document.getElementById("telegram-enabled").checked=w.providers?.telegram?.enabled||!1,document.getElementById("ntfy-enabled").checked=w.providers?.ntfy?.enabled||!1,document.getElementById("email-enabled").checked=w.providers?.email?.enabled||!1,document.getElementById("discord-config").style.display=w.providers?.discord?.enabled?"block":"none",document.getElementById("telegram-config").style.display=w.providers?.telegram?.enabled?"block":"none",document.getElementById("ntfy-config").style.display=w.providers?.ntfy?.enabled?"block":"none",document.getElementById("email-config").style.display=w.providers?.email?.enabled?"block":"none",w.providers?.ntfy?.serverUrl&&(document.getElementById("ntfy-server").value=w.providers.ntfy.serverUrl),w.providers?.email?.host&&(document.getElementById("email-host").value=w.providers.email.host),w.providers?.email?.from&&(document.getElementById("email-from").value=w.providers.email.from),w.providers?.email?.to&&(document.getElementById("email-to").value=w.providers.email.to),w.providers?.email?.port&&(document.getElementById("email-port").value=w.providers.email.port),w.providers?.email?.secure!==void 0&&(document.getElementById("email-secure").checked=w.providers.email.secure===!0),w.providers?.email?.username&&(document.getElementById("email-user").value=w.providers.email.username);const E=document.getElementById("email-pass");w.providers?.email?.hasPassword?(E.value="",E.placeholder="saved \u2014 leave blank to keep"):E.placeholder="app password",document.getElementById("health-check-enabled").checked=w.healthCheck?.enabled||!1,w.healthCheck?.intervalMinutes&&(document.getElementById("health-check-interval").value=w.healthCheck.intervalMinutes),w.healthCheck?.lastCheck&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date(w.healthCheck.lastCheck).toLocaleString()}`),document.getElementById("event-container-down").checked=w.events?.["container-down"]!==!1,document.getElementById("event-container-up").checked=w.events?.["container-up"]===!0,document.getElementById("event-deploy-success").checked=w.events?.["deploy-success"]!==!1,document.getElementById("event-deploy-failed").checked=w.events?.["deploy-failed"]!==!1,document.getElementById("event-resource-alert").checked=w.events?.alert!==!1}}catch(S){k.logError("[Notifications] Load Config",S,{function:"loadConfig"})}}async function h(){try{const $=await(await fetch("/api/v1/notifications/history?limit=10")).json(),w=document.getElementById("notification-history");$.success&&$.history?.length>0?w.innerHTML=$.history.map(E=>{const O=new Date(E.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)"}[I.type]||"var(--muted)"}">${I.type==="success"?"\u2713":I.type==="error"?"\u2717":"\u2139"}</span>
|
||||
<span style="color: ${{success:"var(--ok-fg)",error:"var(--bad-fg)",warning:"#f39c12",info:"var(--accent)"}[E.type]||"var(--muted)"}">${E.type==="success"?"\u2713":E.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(I.title)}</div>
|
||||
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(E.title)}</div>
|
||||
<div style="font-size: 0.7rem; color: var(--muted);">${O}</div>
|
||||
</div>
|
||||
</div>
|
||||
`}).join(""):E.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(k){w.logError("[Notifications] Load History",k,{function:"loadHistory"})}}async function T(){try{const k={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,user:document.getElementById("email-user").value.trim(),pass:document.getElementById("email-pass").value.trim(),from:document.getElementById("email-from").value.trim(),to:document.getElementById("email-to").value.trim()}},events:{containerDown:document.getElementById("event-container-down").checked,containerUp:document.getElementById("event-container-up").checked,deploymentSuccess:document.getElementById("event-deploy-success").checked,deploymentFailed:document.getElementById("event-deploy-failed").checked,resourceAlert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},E=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(k)})).json();E.success?(showNotification("Notification settings saved","success",3e3),B.classList.remove("show")):showNotification(`Failed to save: ${E.error}`,"error",3e3)}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}async function f(k){try{const E=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:k})})).json();E.success?showNotification(`Test ${k} notification sent!`,"success",3e3):showNotification(`Test failed: ${E.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>f("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>f("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>f("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>f("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(k){showNotification(`Error: ${k.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{B.classList.add("show"),D(),h()}),S?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const k=document.getElementById("notifications-send-test"),$=k.textContent;k.textContent="Sending...",k.disabled=!0;try{const I=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();I.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${I.results?.map(O=>`${O.provider}: ${O.error||"ok"}`).join(", ")}`,"error",5e3)}catch(E){showNotification(`Error: ${E.message}`,"error",3e3)}finally{k.textContent=$,k.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const E=document.getElementById("last-notification-sent");E&&(E.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(B,z)})(),(function(){document.addEventListener("click",w=>{const B=w.target.closest(".panel-tab");if(!B)return;const A=B.dataset.panel;if(!A)return;const S=B.closest(".panel-tabs"),z=S.closest(".weather-modal-content");S.querySelectorAll(".panel-tab").forEach(M=>M.classList.remove("active")),B.classList.add("active"),z.querySelectorAll(".panel-section").forEach(M=>M.classList.remove("active"));const P=z.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var w=["dashcaddy_site_config","dashca
Line truncated
|
||||
`}).join(""):w.innerHTML='<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>'}catch(S){k.logError("[Notifications] Load History",S,{function:"loadHistory"})}}async function T(){try{const S={enabled:document.getElementById("notifications-enabled").checked,providers:{discord:{enabled:document.getElementById("discord-enabled").checked,webhookUrl:document.getElementById("discord-webhook").value.trim()},telegram:{enabled:document.getElementById("telegram-enabled").checked,botToken:document.getElementById("telegram-bot-token").value.trim(),chatId:document.getElementById("telegram-chat-id").value.trim()},ntfy:{enabled:document.getElementById("ntfy-enabled").checked,serverUrl:document.getElementById("ntfy-server").value.trim()||"https://ntfy.sh",topic:document.getElementById("ntfy-topic").value.trim()},email:{enabled:document.getElementById("email-enabled").checked,host:document.getElementById("email-host").value.trim(),port:parseInt(document.getElementById("email-port").value)||587,secure:document.getElementById("email-secure").checked,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,alert:document.getElementById("event-resource-alert").checked},healthCheck:{enabled:document.getElementById("health-check-enabled").checked,intervalMinutes:parseInt(document.getElementById("health-check-interval").value)||5}},w=await(await secureFetch("/api/v1/notifications/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(S)})).json();w.success?(showNotification("Notification settings saved","success",3e3),B.classList.remove("show")):showNotification(`Failed to save: ${w.error}`,"error",3e3)}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}async function f(S){try{const w=await(await secureFetch("/api/v1/notifications/test",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({provider:S})})).json();w.success?showNotification(`Test ${S} notification sent!`,"success",3e3):showNotification(`Test failed: ${w.error}`,"error",3e3)}catch($){showNotification(`Error: ${$.message}`,"error",3e3)}}document.getElementById("discord-test")?.addEventListener("click",()=>f("discord")),document.getElementById("telegram-test")?.addEventListener("click",()=>f("telegram")),document.getElementById("ntfy-test")?.addEventListener("click",()=>f("ntfy")),document.getElementById("email-test")?.addEventListener("click",()=>f("email")),document.getElementById("health-check-now")?.addEventListener("click",async()=>{try{const $=await(await secureFetch("/api/v1/notifications/health-check",{method:"POST"})).json();$.success&&(document.getElementById("health-check-status").textContent=`Last check: ${new Date($.lastCheck).toLocaleString()} (${$.containersMonitored} containers)`,showNotification("Health check completed","success",2e3))}catch(S){showNotification(`Error: ${S.message}`,"error",3e3)}}),A?.addEventListener("click",()=>{B.classList.add("show"),D(),h()}),I?.addEventListener("click",T),document.getElementById("notifications-send-test")?.addEventListener("click",async()=>{const S=document.getElementById("notifications-send-test"),$=S.textContent;S.textContent="Sending...",S.disabled=!0;try{const E=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();E.success?(showNotification("Test notification sent!","success",3e3),H()):showNotification(`Test failed: ${E.results?.map(O=>`${O.provider}: ${O.error||"ok"}`).join(", ")}`,"error",5e3)}catch(w){showNotification(`Error: ${w.message}`,"error",3e3)}finally{S.textContent=$,S.disabled=!1}});async function H(){try{const $=await(await fetch("/api/v1/notifications/status")).json();if($.success&&$.lastSent){const w=document.getElementById("last-notification-sent");w&&(w.textContent=`Last sent: ${new Date($.lastSent).toLocaleString()}`)}}catch{}}wireModal(B,z)})(),(function(){document.addEventListener("click",k=>{const B=k.target.closest(".panel-tab");if(!B)return;const A=B.dataset.panel;if(!A)return;const I=B.closest(".panel-tabs"),z=I.closest(".weather-modal-content");I.querySelectorAll(".panel-tab").forEach(M=>M.classList.remove("active")),B.classList.add("active"),z.querySelectorAll(".panel-section").forEach(M=>M.classList.remove("active"));const P=z.querySelector("#"+A);P&&P.classList.add("active")})})(),(function(){var k=["dashcaddy_site_config","da
Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
|
||||
<h3>\u{1F4BE} Backup & Restore</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1196,7 +1196,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"),D=document.getElementById("backup-cancel"),h=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),k=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),E=document.getElementById("backup-do-restore-btn"),I=document.getElementById("backup-result"),O=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),j=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),R=null;M?.addEventListener("click",function(){P.classList.add("show"),I&&(I.style.display="none"),k&&(k.style.display="none"),H&&(H.style.display="none"),R=null}),wireModal(P,D),h?.addEventListener("click",async function(){h.disabled=!0,h.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=B();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var r=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;I.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(d?" + "+d+" themes":""),I.style.display="block",I.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",I.style.border="1px solid var(--ok-fg)"}catch(y){I.innerHTML="\u274C Export failed: "+escapeHtml(y.message),I.style.display="block",I.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",I.style.border="1px solid var(--bad-fg)"}h.disabled=!1,h.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){H.textContent="\u{1F4C4} "+a.name,H.style.display="block",I.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(S(i)){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,k.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),d=await r.json();if(d.success){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var y in d.preview.files){var L=d.preview.files[y],U=L.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(L.description)+"</span>"}n+="</div>",d.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.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 '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,k.style.display="block"}else I.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),I.style.display="block",I.style.background="color-mix(in srgb, #f39c12 15%, transparent)",I.style.border="1px solid #f39c12",k.style.display="none"}catch(q){I.innerHTML="\u274CLine truncated
|
||||
</div>`);var P=document.getElementById("backup-modal"),M=document.getElementById("backup-restore-btn"),D=document.getElementById("backup-cancel"),h=document.getElementById("backup-export-btn"),T=document.getElementById("backup-select-file"),f=document.getElementById("backup-file-input"),H=document.getElementById("backup-file-name"),S=document.getElementById("backup-preview"),$=document.getElementById("backup-preview-content"),w=document.getElementById("backup-do-restore-btn"),E=document.getElementById("backup-result"),O=document.getElementById("backup-schedules-container"),C=document.getElementById("backup-history-container"),j=document.getElementById("backup-disk-container"),N=document.getElementById("pointintime-container"),R=null;M?.addEventListener("click",function(){P.classList.add("show"),E&&(E.style.display="none"),S&&(S.style.display="none"),H&&(H.style.display="none"),R=null}),wireModal(P,D),h?.addEventListener("click",async function(){h.disabled=!0,h.innerHTML='<span class="brand-spinner"></span> Exporting...';try{var e=await fetch("/api/v1/backup/export"),a=await e.json();a.browserState=B();var o=new Blob([JSON.stringify(a,null,2)],{type:"application/json"}),i=URL.createObjectURL(o),n=document.createElement("a");n.href=i,n.download="dashcaddy-backup-"+new Date().toISOString().split("T")[0]+".json",document.body.appendChild(n),n.click(),document.body.removeChild(n),URL.revokeObjectURL(i);var r=Object.keys(a.browserState).length,d=a.themes?Object.keys(a.themes).length:0;E.innerHTML="\u2705 Full backup downloaded \u2014 server config + "+r+" browser settings"+(d?" + "+d+" themes":""),E.style.display="block",E.style.background="color-mix(in srgb, var(--ok-fg) 15%, transparent)",E.style.border="1px solid var(--ok-fg)"}catch(y){E.innerHTML="\u274C Export failed: "+escapeHtml(y.message),E.style.display="block",E.style.background="color-mix(in srgb, var(--bad-fg) 15%, transparent)",E.style.border="1px solid var(--bad-fg)"}h.disabled=!1,h.innerHTML="\u2B07\uFE0F Download Full Backup"}),T?.addEventListener("click",function(){f.click()}),f?.addEventListener("change",async function(e){var a=e.target.files[0];if(a){H.textContent="\u{1F4C4} "+a.name,H.style.display="block",E.style.display="none";try{var o=await a.text(),i=JSON.parse(o);if(I(i)){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Legacy format (v'+escapeHtml(i.version)+")</div>";n+='<div style="display: flex; flex-wrap: wrap; gap: 6px;">',i.services?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4CB} '+i.services.length+" services</span>"),i.customApps?.length&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F4E6} '+i.customApps.length+" custom apps</span>"),i.theme&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} Theme: '+escapeHtml(i.theme)+"</span>"),i.userThemes&&(n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">\u{1F3A8} '+Object.keys(i.userThemes).length+" custom themes</span>"),n+="</div>",$.innerHTML=n,S.style.display="block";return}var r=await secureFetch("/api/v1/backup/preview",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(i)}),d=await r.json();if(d.success){R=i;var n='<div style="margin-bottom: 8px; color: var(--muted); font-size: 0.8rem;">Exported: '+new Date(i.exportedAt).toLocaleString()+" (v"+escapeHtml(i.version)+")</div>";n+='<div style="margin-bottom: 6px; font-size: 0.75rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px;">Server Config</div>',n+='<div style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px;">';for(var y in d.preview.files){var L=d.preview.files[y],U=L.action==="create"?"\u{1F195}":"\u{1F4DD}";n+='<span style="padding: 4px 8px; background: var(--base); border-radius: 4px; font-size: 0.8rem;">'+U+" "+escapeHtml(L.description)+"</span>"}n+="</div>",d.preview.serviceCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">'+d.preview.serviceCount+" services</div>"),d.preview.themeCount&&(n+='<div style="font-size: 0.8rem; color: var(--accent); margin-bottom: 6px;">\u{1F3A8} '+d.preview.themeCount+" custom themes</div>"),d.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 '+d.preview.browserStateCount+" saved settings (theme, weather, clock, widgets, etc.)</div>"),$.innerHTML=n,S.style.display="block"}else E.innerHTML="\u26A0\uFE0F Invalid backup file: "+escapeHtml(d.error),E.style.display="block",E.style.background="color-mix(in srgb, #f39c12 15%, transparent)",E.style.border="1px solid #f39c12",S.style.display="none"}catch(q){E.innerHTML="\u274CLine truncated
|
||||
|
||||
This will replace current configuration, credentials, and data. Containers will be restarted.`))try{var o=await secureFetch("/api/v1/apps/"+encodeURIComponent(e)+"/revert/"+encodeURIComponent(a),{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({restartContainers:!0})}),i=await o.json();i.success?(showNotification(e+" restored to "+a,"success"),setTimeout(function(){location.reload()},1500)):showNotification("Restore failed: "+(i.error||"Unknown"),"error")}catch(n){showNotification("Restore error: "+n.message,"error")}}})(),(function(){injectModal("stats-modal",`<div id="stats-modal" class="weather-modal">
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
@@ -1278,7 +1278,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="stats-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),S=document.getElementById("stats-refresh-btn"),z=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),h=document.getElementById("stats-last-update");let T=null,f=null;function H(l){if(l===0||!l)return"0 B";const m=1024,c=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(m));return parseFloat((l/Math.pow(m,t)).toFixed(1))+" "+c[t]}function k(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function E(){try{let l=null,m=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,m=!0,f=e.stats)}catch{}if(!m){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const a of e.stats)l[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};f=l}}if(!l||Object.keys(l).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=k(o),r=$(i),d=a.memory?.usage||a.memory?.used||0,y=a.memory?.limit||0,L=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,q=e.aggregated;c+=`
|
||||
</div>`);const k=document.getElementById("stats-modal"),B=document.getElementById("container-stats-btn"),A=document.getElementById("stats-cancel"),I=document.getElementById("stats-refresh-btn"),z=document.getElementById("stats-auto-refresh"),P=document.getElementById("stats-container"),M=document.getElementById("stats-aggregated-container"),D=document.getElementById("stats-alerts-container"),h=document.getElementById("stats-last-update");let T=null,f=null;function H(l){if(l===0||!l)return"0 B";const m=1024,c=["B","KB","MB","GB"],t=Math.floor(Math.log(l)/Math.log(m));return parseFloat((l/Math.pow(m,t)).toFixed(1))+" "+c[t]}function S(l){return l<30?"#2ecc71":l<70?"#f39c12":"#e74c3c"}function $(l){return l<50?"#2ecc71":l<80?"#f39c12":"#e74c3c"}async function w(){try{let l=null,m=!1;try{const e=await(await fetch("/api/v1/monitoring/stats")).json();e.success&&e.stats&&(l=e.stats,m=!0,f=e.stats)}catch{}if(!m){const e=await(await fetch("/api/v1/stats/containers")).json();if(e.success&&e.stats){l={};for(const a of e.stats)l[a.name]={name:a.name,current:{cpu:a.cpu,memory:{percent:a.memory.percent,usage:a.memory.used,limit:a.memory.limit,usageMB:Math.round(a.memory.used/1048576),limitMB:Math.round(a.memory.limit/1048576)},network:{rxBytes:a.network.rx,txBytes:a.network.tx,rxMB:(a.network.rx/1048576).toFixed(1),txMB:(a.network.tx/1048576).toFixed(1)},disk:{readMB:0,writeMB:0}},status:a.status};f=l}}if(!l||Object.keys(l).length===0){P.innerHTML='<div style="text-align: center; padding: 40px; color: var(--muted);">No running containers found</div>';return}let c='<div style="display: flex; flex-direction: column; gap: 8px;">';for(const[t,e]of Object.entries(l)){const a=e.current||e,o=a.cpu?.percent||0,i=a.memory?.percent||0,n=S(o),r=$(i),d=a.memory?.usage||a.memory?.used||0,y=a.memory?.limit||0,L=a.network?.rxBytes||a.network?.rx||0,U=a.network?.txBytes||a.network?.tx||0,q=e.aggregated;c+=`
|
||||
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="display: flex; align-items: center; gap: 12px; margin-bottom: 10px;">
|
||||
<span style="font-weight: 600; flex: 1;">${e.name||t}</span>
|
||||
@@ -1314,7 +1314,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`}c+="</div>",P.innerHTML=c,h.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function I(){if(!M)return;const l=f;if(!l||Object.keys(l).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 m='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,t]of Object.entries(l)){const e=t.aggregated;e&&(m+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
</div>`}c+="</div>",P.innerHTML=c,h.textContent="Updated: "+new Date().toLocaleTimeString()}catch(l){P.innerHTML=`<div style="text-align: center; padding: 40px; color: var(--bad-fg);">\u274C Failed to load stats: ${escapeHtml(l.message)}</div>`}}async function E(){if(!M)return;const l=f;if(!l||Object.keys(l).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 m='<div style="display: flex; flex-direction: column; gap: 12px;">';for(const[c,t]of Object.entries(l)){const e=t.aggregated;e&&(m+=`<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
|
||||
<div style="font-weight: 600; margin-bottom: 10px;">${t.name||c}</div>
|
||||
<div style="display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;">
|
||||
<div class="stat-mini-card"><span class="stat-val">${e.cpu?.avg?.toFixed(1)||0}%</span><span class="stat-lbl">Avg CPU</span></div>
|
||||
@@ -1396,7 +1396,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 n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(r=>{const d=r.dataset.container;n[d]={cpuThreshold:parseInt(r.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(r.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(r.querySelector(".alert-disk")?.value)||50,autoRestart:!!r.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),y=document.getElementById("save-all-alerts");y.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{y.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),w.classList.remove("show"),j(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=r},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{w.classList.remove("show"),j(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){T&&clearInterval(T),z?.checked&&(T=setInterval(E,DC.POLL.STATS))}function j(){T&&(clearInterval(T),T=null)}B?.addEventListener("click",()=>{w.classList.add("show"),E(),C()}),A?.addEventListener("click",()=>{w.classList.remove("show"),j()}),w?.addEventListener("click",l=>{l.target===w&&(w.classList.remove("show"),j())}),S?.addEventListener("click",E),z?.addEventListener("change",()=>{z.checked?C():j()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",I),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",O);const N=document.getElementById("stats-history-container"),R=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let v="1h";function x(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function b(l,m,c,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=l.map(m).filter(_=>_!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,r=600,d=80,y=4,L=(r-y*2)/Math.max(a.length-1,1),U=a.map((_,J)=>{const X=y+J*L,Q=d-y-(_-i)/n*(d-y*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=a[a.length-1],F=a.reduce((_,J)=>_+J,0)/a.length;return`
|
||||
`,document.getElementById("save-all-alerts")?.addEventListener("click",async()=>{const n={};document.querySelectorAll("#stats-alerts-container tr[data-container]").forEach(r=>{const d=r.dataset.container;n[d]={cpuThreshold:parseInt(r.querySelector(".alert-cpu")?.value)||80,memoryThreshold:parseInt(r.querySelector(".alert-mem")?.value)||90,diskIOThreshold:parseInt(r.querySelector(".alert-disk")?.value)||50,autoRestart:!!r.querySelector(".alert-autorestart")?.checked,enabled:!0}});try{const d=await(await secureFetch("/api/v1/monitoring/alerts/config",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({configs:n})})).json(),y=document.getElementById("save-all-alerts");y.textContent=d.success?"\u2705 Saved":"\u274C Failed",setTimeout(()=>{y.textContent="Save All"},2e3)}catch{const d=document.getElementById("save-all-alerts");d.textContent="\u274C Error",setTimeout(()=>{d.textContent="Save All"},2e3)}}),document.getElementById("go-to-notifications")?.addEventListener("click",n=>{n.preventDefault(),k.classList.remove("show"),j(),document.getElementById("manage-notifications")?.click()}),document.querySelectorAll(".alert-test-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.textContent;n.textContent="...";try{await secureFetch(`/api/v1/monitoring/alerts/${n.dataset.container}/test`,{method:"POST"}),n.textContent="\u2705",showNotification("Test alert sent for "+n.dataset.name,"success",3e3)}catch{n.textContent="\u274C"}setTimeout(()=>{n.textContent=r},2e3)})}),document.getElementById("upgrade-for-alerts")?.addEventListener("click",()=>{k.classList.remove("show"),j(),typeof openLicenseModal=="function"&&openLicenseModal()})}function C(){T&&clearInterval(T),z?.checked&&(T=setInterval(w,DC.POLL.STATS))}function j(){T&&(clearInterval(T),T=null)}B?.addEventListener("click",()=>{k.classList.add("show"),w(),C()}),A?.addEventListener("click",()=>{k.classList.remove("show"),j()}),k?.addEventListener("click",l=>{l.target===k&&(k.classList.remove("show"),j())}),I?.addEventListener("click",w),z?.addEventListener("change",()=>{z.checked?C():j()}),document.querySelector('[data-panel="stats-aggregated"]')?.addEventListener("click",E),document.querySelector('[data-panel="stats-alerts"]')?.addEventListener("click",O);const N=document.getElementById("stats-history-container"),R=document.getElementById("stats-history-container-area"),u=document.querySelectorAll(".stats-range-btn");let v="1h";function x(l){switch(l){case"1h":return 3600*1e3;case"24h":return 1440*60*1e3;case"7d":return 10080*60*1e3;case"30d":return 720*60*60*1e3;case"1y":return 365*24*60*60*1e3;default:return 3600*1e3}}function g(l){return l==="raw"?"live (10s samples)":l==="hourly"?"hourly average":l==="daily"?"daily average":l}function b(l,m,c,t,e){if(!l||l.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const a=l.map(m).filter(_=>_!=null);if(a.length===0)return`<div style="text-align: center; color: var(--muted); padding: 20px;">No data for ${escapeHtml(t)}</div>`;const o=Math.max(...a,1),i=Math.min(...a,0),n=o-i||1,r=600,d=80,y=4,L=(r-y*2)/Math.max(a.length-1,1),U=a.map((_,J)=>{const X=y+J*L,Q=d-y-(_-i)/n*(d-y*2);return`${X.toFixed(1)},${Q.toFixed(1)}`}).join(" "),q=a[a.length-1],F=a.reduce((_,J)=>_+J,0)/a.length;return`
|
||||
<div style="margin-bottom: 14px;">
|
||||
<div style="display: flex; align-items: baseline; gap: 12px; margin-bottom: 4px;">
|
||||
<span style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(t)}</span>
|
||||
@@ -1534,7 +1534,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="health-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),S=document.getElementById("health-refresh-btn"),z=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),h=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),f=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),k=document.getElementById("health-form-save"),$="dashcaddy-health-settings",E={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},I=document.getElementById("health-global-save"),O=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),j=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),R=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),v=document.getElementById("health-setting-disk-threshold");function x(){try{const i=safeGet($),n=i?JSON.parse(i):{};return Object.assign({},E,n)}catch{return Object.assign({},E)}}function g(){const i=x();j&&(j.value=i.retentionDays),N&&(N.value=i.pollingInterval),R&&(R.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),v&&(v.value=i.diskUsageThreshold)}function b(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(j?.value)||E.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||E.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||E.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||E.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(v?.value)||E.diskUsageThreshold))};try{safeSet($,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function s(){try{safeSet($,JSON.stringify(E))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),I?.addEventListener("click",b),O?.addEventListener("click",s);let p=null;function l(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function m(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function c(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){z.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 r=Object.values(n.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const y of r){const L=y.status==="up",U=L?"var(--dot-ok)":"var(--dot-bad)",q=y.uptime?.["24h"]??"-",F=y.uptime?.["7d"]??"-",_=y.avgResponseTime!=null?Math.round(y.avgResponseTime)+"ms":"-",J=y.timestamp?timeAgo(y.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(y.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof q=="number"?l(q):"var(--muted)"};">${typeof q=="number"?q.toFixed(1)+"%":q}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${_}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(y.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 k=document.getElementById("health-modal"),B=document.getElementById("health-check-btn"),A=document.getElementById("health-cancel"),I=document.getElementById("health-refresh-btn"),z=document.getElementById("health-status-container"),P=document.getElementById("health-incidents-container"),M=document.getElementById("health-config-container"),D=document.getElementById("health-last-update"),h=document.getElementById("health-add-btn"),T=document.getElementById("health-config-form"),f=document.getElementById("health-form-title"),H=document.getElementById("health-form-cancel"),S=document.getElementById("health-form-save"),$="dashcaddy-health-settings",w={retentionDays:30,pollingInterval:60,statsPollingInterval:30,maxEntriesPerService:500,diskUsageThreshold:80},E=document.getElementById("health-global-save"),O=document.getElementById("health-global-reset"),C=document.getElementById("health-global-status"),j=document.getElementById("health-setting-retention"),N=document.getElementById("health-setting-interval"),R=document.getElementById("health-setting-stats-interval"),u=document.getElementById("health-setting-max-entries"),v=document.getElementById("health-setting-disk-threshold");function x(){try{const i=safeGet($),n=i?JSON.parse(i):{};return Object.assign({},w,n)}catch{return Object.assign({},w)}}function g(){const i=x();j&&(j.value=i.retentionDays),N&&(N.value=i.pollingInterval),R&&(R.value=i.statsPollingInterval),u&&(u.value=i.maxEntriesPerService),v&&(v.value=i.diskUsageThreshold)}function b(){const i={retentionDays:Math.max(1,Math.min(3650,parseInt(j?.value)||w.retentionDays)),pollingInterval:Math.max(5,Math.min(3600,parseInt(N?.value)||w.pollingInterval)),statsPollingInterval:Math.max(5,Math.min(3600,parseInt(R?.value)||w.statsPollingInterval)),maxEntriesPerService:Math.max(10,Math.min(1e5,parseInt(u?.value)||w.maxEntriesPerService)),diskUsageThreshold:Math.max(50,Math.min(99,parseInt(v?.value)||w.diskUsageThreshold))};try{safeSet($,JSON.stringify(i)),g(),C&&(C.textContent="Saved \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500)),typeof showNotification=="function"&&showNotification("Global health settings saved","success")}catch(n){C&&(C.textContent="Save failed",C.style.color="var(--bad-fg)"),typeof showNotification=="function"&&showNotification("Failed to save settings: "+n.message,"error")}}function s(){try{safeSet($,JSON.stringify(w))}catch{}g(),C&&(C.textContent="Reset to defaults \u2713",C.style.color="var(--ok-fg)",setTimeout(()=>{C&&(C.textContent="")},2500))}g(),E?.addEventListener("click",b),O?.addEventListener("click",s);let p=null;function l(i){return i>=99.9?"var(--ok-fg)":i>=95?"#f39c12":"var(--bad-fg)"}function m(i){const n={critical:"var(--bad-fg)",high:"#ff6b6b",medium:"#f39c12",low:"var(--muted)"};return`<span style="padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; background: ${n[i]||"var(--muted)"}20; color: ${n[i]||"var(--muted)"};">${i}</span>`}async function c(){try{const n=await(await fetch("/api/v1/health-checks/status")).json();if(!n.success||!n.status||Object.keys(n.status).length===0){z.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 r=Object.values(n.status);let d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted); text-align: left;">',d+='<th style="padding: 8px;">Service</th><th style="padding: 8px;">Status</th>',d+='<th style="padding: 8px;">Uptime 24h</th><th style="padding: 8px;">Uptime 7d</th>',d+='<th style="padding: 8px;">Avg Response</th><th style="padding: 8px;">Last Check</th></tr>';for(const y of r){const L=y.status==="up",U=L?"var(--dot-ok)":"var(--dot-bad)",q=y.uptime?.["24h"]??"-",F=y.uptime?.["7d"]??"-",_=y.avgResponseTime!=null?Math.round(y.avgResponseTime)+"ms":"-",J=y.timestamp?timeAgo(y.timestamp):"-";d+=`<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" data-health-id="${escapeHtml(y.serviceId)}">`,d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px;"><span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: ${U}; margin-right: 6px;"></span>${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px; color: ${typeof q=="number"?l(q):"var(--muted)"};">${typeof q=="number"?q.toFixed(1)+"%":q}</td>`,d+=`<td style="padding: 8px; color: ${typeof F=="number"?l(F):"var(--muted)"};">${typeof F=="number"?F.toFixed(1)+"%":F}</td>`,d+=`<td style="padding: 8px;">${_}</td>`,d+=`<td style="padding: 8px; color: var(--muted);">${J}</td>`,d+="</tr>",d+=`<tr id="health-detail-${escapeHtml(y.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>${_.totalChecks||0}</strong></div>
|
||||
<div><span style="color: var(--muted);">Uptime</span><br><strong style="color: ${l(_.uptime||0)};">${(_.uptime||0).toFixed(2)}%</strong></div>
|
||||
@@ -1551,7 +1551,7 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
</div>
|
||||
<div style="font-size: 0.82rem; color: var(--muted); margin-top: 4px;">${escapeHtml(q.message)}</div>
|
||||
<div style="font-size: 0.75rem; color: var(--muted); margin-top: 4px;">Started ${timeAgo(q.createdAt)} \xB7 ${q.occurrences||1} occurrence(s)</div>
|
||||
</div>`;y+="</div>"}else y+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){y+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',y+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<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 q of U){const F=q.status==="resolved",_=F&&q.duration?q.duration<6e4?Math.round(q.duration/1e3)+"s":Math.round(q.duration/6e4)+"m":"-";y+='<tr style="border-bottom: 1px solid var(--border);">',y+=`<td style="padding: 6px;">${escapeHtml(q.serviceId)}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(q.type)}</td>`,y+=`<td style="padding: 6px;">${m(q.severity)}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${q.status}</span></td>`,y+=`<td style="padding: 6px;">${_}</td>`,y+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(q.createdAt)}</td>`,y+="</tr>"}y+="</table>"}P.innerHTML=y||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),r=n.success&&n.status?Object.values(n.status):[];if(r.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 d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const y of r){const L=y.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${L?"var(--ok-fg)":"var(--bad-fg)"};">${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${y.sla?.target?y.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",M.innerHTML=d}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,r,d,y,L,U){p=i||null,f.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=y||"200",document.getElementById("health-form-sla").value=L||99.9,document.getElementById("health-form-slow").value=U||5e3,T.style.display="",h.style.display="none"}function o(){T.style.display="none",h.style.display="",p=null}h?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",o),k?.addEventListener("click",async()=>{const i=p||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(y=>parseInt(y.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
</div>`;y+="</div>"}else y+='<div style="padding: 12px; margin-bottom: 16px; border: 1px solid var(--ok-fg)30; border-radius: 8px; background: var(--ok-bg); text-align: center; color: var(--ok-fg); font-size: 0.9rem;">All services operational \u2014 no open incidents</div>';const U=d.success&&d.history?d.history:[];if(U.length>0){y+='<h4 style="margin: 0 0 8px; color: var(--muted);">Incident History</h4>',y+='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',y+='<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 q of U){const F=q.status==="resolved",_=F&&q.duration?q.duration<6e4?Math.round(q.duration/1e3)+"s":Math.round(q.duration/6e4)+"m":"-";y+='<tr style="border-bottom: 1px solid var(--border);">',y+=`<td style="padding: 6px;">${escapeHtml(q.serviceId)}</td>`,y+=`<td style="padding: 6px;">${escapeHtml(q.type)}</td>`,y+=`<td style="padding: 6px;">${m(q.severity)}</td>`,y+=`<td style="padding: 6px;"><span style="color: ${F?"var(--ok-fg)":"var(--bad-fg)"};">${q.status}</span></td>`,y+=`<td style="padding: 6px;">${_}</td>`,y+=`<td style="padding: 6px; color: var(--muted);">${timeAgo(q.createdAt)}</td>`,y+="</tr>"}y+="</table>"}P.innerHTML=y||'<div class="panel-empty"><span class="empty-icon">\u{1F6A8}</span>No incidents recorded yet.</div>'}catch(i){P.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}async function e(){try{const n=await(await fetch("/api/v1/health-checks/status")).json(),r=n.success&&n.status?Object.values(n.status):[];if(r.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 d='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';d+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Service</th><th style="padding: 8px; text-align: left;">Status</th><th style="padding: 8px; text-align: left;">SLA Target</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const y of r){const L=y.status==="up";d+='<tr style="border-bottom: 1px solid var(--border);">',d+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(y.name||y.serviceId)}</td>`,d+=`<td style="padding: 8px; color: ${L?"var(--ok-fg)":"var(--bad-fg)"};">${L?"Up":"Down"}</td>`,d+=`<td style="padding: 8px;">${y.sla?.target?y.sla.target+"%":"-"}</td>`,d+='<td style="padding: 8px; text-align: right;">',d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-edit', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; margin-right: 4px;">Edit</button>`,d+=`<button onclick="document.dispatchEvent(new CustomEvent('health-delete', {detail:'${escapeHtml(y.serviceId)}'}))" style="padding: 4px 10px; font-size: 0.78rem; color: var(--bad-fg); border-color: var(--bad-fg);">Delete</button>`,d+="</td></tr>"}d+="</table>",M.innerHTML=d}catch(i){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(i.message)}</div>`}}function a(i,n,r,d,y,L,U){p=i||null,f.textContent=i?"Edit Health Check":"Add Health Check",document.getElementById("health-form-id").value=i||"",document.getElementById("health-form-id").disabled=!!i,document.getElementById("health-form-name").value=n||"",document.getElementById("health-form-url").value=r||"",document.getElementById("health-form-timeout").value=d||1e4,document.getElementById("health-form-codes").value=y||"200",document.getElementById("health-form-sla").value=L||99.9,document.getElementById("health-form-slow").value=U||5e3,T.style.display="",h.style.display="none"}function o(){T.style.display="none",h.style.display="",p=null}h?.addEventListener("click",()=>a("","","",1e4,"200",99.9,5e3)),H?.addEventListener("click",o),S?.addEventListener("click",async()=>{const i=p||document.getElementById("health-form-id").value.trim();if(!i)return showNotification("Service ID is required","warning");const n=document.getElementById("health-form-url").value.trim();if(!n)return showNotification("URL is required","warning");const r=document.getElementById("health-form-codes").value.split(",").map(y=>parseInt(y.trim())).filter(Boolean),d={name:document.getElementById("health-form-name").value.trim()||i,url:n,timeout:parseInt(document.getElementById("health-form-timeout").value)||1e4,expectedStatusCodes:r.length?r:[200],sla:{target:parseFloat(document.getElementById("health-form-sla").value)||99.9},slowResponseThreshold:parseInt(document.getElementById("health-form-slow").value)|Line truncated
|
||||
<div class="weather-modal-content" style="min-width: 750px; max-width: 950px;">
|
||||
<h3>\u2B06\uFE0F Update Management</h3>
|
||||
<p class="modal-subtitle">
|
||||
@@ -1625,17 +1625,17 @@ This will replace current configuration, credentials, and data. Containers will
|
||||
<button id="updates-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),S=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function h(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const r=(()=>{const d=window.APPS||[];for(const y of d)if(y.containerId===n.containerId||y.name===n.containerName||y.id===n.containerName)return y.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(r)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",z.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,z.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(L.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Update failed")}catch(y){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+y.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),z.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(r)}`,{method:"POST"})).json();if(L.success)n.textContent="Rolled back!",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Rollback failed")}catch(y){n.textContent="Failed",showNotification("Rollback error: "+y.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(c){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function T(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
|
||||
</div>`);const k=document.getElementById("updates-modal"),B=document.getElementById("updates-btn"),A=document.getElementById("updates-cancel"),I=document.getElementById("updates-check-btn"),z=document.getElementById("updates-available-container"),P=document.getElementById("updates-history-container"),M=document.getElementById("updates-auto-container"),D=document.getElementById("updates-last-check");async function h(){try{const t=await(await fetch("/api/v1/updates/available")).json();if(!t.success)throw new Error(t.error);const e=t.updates||[];if(e.length===0){z.innerHTML='<div class="panel-empty"><span class="empty-icon">\u2705</span>All containers are up to date.</div>',D.textContent="",document.getElementById("updates-update-all-btn").style.display="none",document.getElementById("updates-count-badge").style.display="none",window._pendingUpdates=[];return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.85rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);"><th style="padding: 8px; text-align: left;">Container</th><th style="padding: 8px; text-align: left;">Image</th><th style="padding: 8px; text-align: left;">Current</th><th style="padding: 8px; text-align: left;">Latest</th><th style="padding: 8px; text-align: right;">Actions</th></tr>';for(const n of e){const r=(()=>{const d=window.APPS||[];for(const y of d)if(y.containerId===n.containerId||y.name===n.containerName||y.id===n.containerName)return y.id;return n.containerName})();a+=`<tr data-app-id="${escapeHtml(r)}" style="border-bottom: 1px solid var(--border);">`,a+=`<td style="padding: 8px; font-weight: 500;">${escapeHtml(n.containerName)}</td>`,a+=`<td style="padding: 8px; color: var(--muted);">${escapeHtml(n.imageName)}</td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--bg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.currentDigest)}</code></td>`,a+=`<td style="padding: 8px;"><code style="font-size: 0.78rem; background: var(--ok-bg); color: var(--ok-fg); padding: 2px 6px; border-radius: 4px;">${escapeHtml(n.latestDigest)}</code></td>`,a+='<td style="padding: 8px; text-align: right;">',a+=`<button class="update-now-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem; background: var(--accent); color: var(--bg); border-color: var(--accent); margin-right: 4px;">Update</button>`,a+=`<button class="rollback-btn" data-id="${escapeHtml(n.containerId)}" data-name="${escapeHtml(n.containerName)}" style="padding: 4px 10px; font-size: 0.78rem;">Rollback</button>`,a+="</td></tr>"}a+="</table>",z.innerHTML=a,D.textContent=e.length+" update(s) available";const o=document.getElementById("updates-count-badge"),i=document.getElementById("updates-update-all-btn");o&&(o.textContent=e.length+" pending",o.style.display=""),i&&e.length>0&&(i.style.display=""),window._pendingUpdates=e,z.querySelectorAll(".update-now-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Update "${d}" to the latest version? The container will restart.`)){n.textContent="Updating...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(r)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json();if(L.success)n.textContent="Done!",n.style.background="var(--ok-fg)",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Update failed")}catch(y){n.textContent="Failed",n.style.color="var(--bad-fg)",showNotification("Update error: "+y.message,"error"),setTimeout(()=>{n.textContent="Update",n.disabled=!1,n.style.color="",n.style.background=""},3e3)}}})}),z.querySelectorAll(".rollback-btn").forEach(n=>{n.addEventListener("click",async()=>{const r=n.dataset.id,d=n.dataset.name;if(confirm(`Rollback "${d}" to its previous version?`)){n.textContent="Rolling back...",n.disabled=!0;try{const L=await(await secureFetch(`/api/v1/updates/rollback/${encodeURIComponent(r)}`,{method:"POST"})).json();if(L.success)n.textContent="Rolled back!",setTimeout(()=>h(),2e3);else throw new Error(L.error||"Rollback failed")}catch(y){n.textContent="Failed",showNotification("Rollback error: "+y.message,"error"),setTimeout(()=>{n.textContent="Rollback",n.disabled=!1},3e3)}}})})}catch(c){z.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}async function T(){const c=window._pendingUpdates||[];if(!c.length)return;const t=document.getElementById("updates-update-all-btn");if(!confirm(`Update all ${c.length} containers? Each will restart.`))return;t.textContent="\u23F3 Updating...",t.disabled=!0;let e=0,a=0;for(const o of c)try{(await(await secureFetch(`/api/v1/updates/update/${encodeURIComponent(o.containerId)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({autoRollback:!0})})).json()).success?e++:a++}catch{a++}t.textCLine truncated
|
||||
<select class="auto-schedule" data-id="${escapeHtml(y)}" style="padding: 4px 8px; border-radius: 4px; border: 1px solid var(--border); background: var(--bg); color: var(--fg); font-size: 0.82rem;">
|
||||
<option value=""${U?"":" selected"}>Disabled</option>
|
||||
<option value="daily"${U==="daily"?" selected":""}>Daily</option>
|
||||
<option value="weekly"${U==="weekly"?" selected":""}>Weekly</option>
|
||||
<option value="monthly"${U==="monthly"?" selected":""}>Monthly</option>
|
||||
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(y)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(y)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${_}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(y)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",M.innerHTML=n,M.querySelectorAll(".save-auto-btn").forEach(r=>{r.addEventListener("click",async()=>{const d=r.dataset.id,y=r.closest("tr"),L=y.querySelector(".auto-schedule").value,U=y.querySelector(".auto-rollback").checked,q=y.querySelector(".auto-window").value.trim();r.textContent="Saving...",r.disabled=!0;try{const _=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!L,schedule:L||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(_.success)r.textContent="\u2713 Saved";else throw new Error(_.error)}catch(F){r.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{r.textContent="Save",r.disabled=!1},2e3)})})}catch(c){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),E=document.getElementById("dashcaddy-update-badge"),I=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),j=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),R=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let x=null;function g(c,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function b(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(x=e,e.success&&e.available&&e.remote){E.style.display="",I.style.display="",O.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else E.style.display="none",I.style.display="none",await b(),c||g("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;j.textContent="Updating...",j.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),j.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw g("Update failed: "+c.message,"error"),j.textContent="Update Now",j.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){v.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
</select></td>`,n+=`<td style="padding: 8px;"><input type="text" class="auto-window" data-id="${escapeHtml(y)}" value="${escapeHtml(F)}" placeholder="02:00-04:00" style="width: 90px; padding: 3px 6px; font-size: 0.78rem; border: 1px solid var(--border); border-radius: 4px; background: var(--bg); color: var(--fg);" /></td>`,n+=`<td style="padding: 8px;"><input type="checkbox" class="auto-rollback" data-id="${escapeHtml(y)}"${q?" checked":""} /></td>`,n+=`<td style="padding: 8px; font-size: 0.78rem; color: var(--muted);">${_}</td>`,n+=`<td style="padding: 8px; text-align: right;"><button class="save-auto-btn" data-id="${escapeHtml(y)}" data-name="${escapeHtml(d)}" style="padding: 4px 10px; font-size: 0.78rem;">Save</button></td>`,n+="</tr>"}n+="</table>",M.innerHTML=n,M.querySelectorAll(".save-auto-btn").forEach(r=>{r.addEventListener("click",async()=>{const d=r.dataset.id,y=r.closest("tr"),L=y.querySelector(".auto-schedule").value,U=y.querySelector(".auto-rollback").checked,q=y.querySelector(".auto-window").value.trim();r.textContent="Saving...",r.disabled=!0;try{const _=await(await secureFetch(`/api/v1/updates/auto-update/${encodeURIComponent(d)}`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({enabled:!!L,schedule:L||"weekly",autoRollback:U,maintenanceWindow:q||void 0})})).json();if(_.success)r.textContent="\u2713 Saved";else throw new Error(_.error)}catch(F){r.textContent="\u2717 Error",showNotification("Save error: "+F.message,"error")}setTimeout(()=>{r.textContent="Save",r.disabled=!1},2e3)})})}catch(c){M.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(c.message)}</div>`}}const $=document.getElementById("dashcaddy-current-version"),w=document.getElementById("dashcaddy-update-badge"),E=document.getElementById("dashcaddy-update-details"),O=document.getElementById("dashcaddy-new-version"),C=document.getElementById("dashcaddy-changelog"),j=document.getElementById("dashcaddy-apply-btn"),N=document.getElementById("dashcaddy-check-btn"),R=document.getElementById("dashcaddy-rollback-btn"),u=document.getElementById("dashcaddy-status-bar"),v=document.getElementById("dashcaddy-history-container");let x=null;function g(c,t){u&&(u.style.display="block",u.style.background=t==="error"?"var(--bad-bg)":t==="success"?"var(--ok-bg)":"var(--bg)",u.style.color=t==="error"?"var(--bad-fg)":t==="success"?"var(--ok-fg)":"var(--fg)",u.textContent=c)}async function b(){try{const t=await(await fetch("/api/v1/system/version")).json();if(t.success){const e=t.commit&&t.commit!=="unknown"?t.commit:null;$.textContent="v"+t.version+(e?" ("+e.substring(0,7)+")":"")}}catch{$.textContent="Unable to fetch version"}}async function s(c){c||(N.textContent="Checking...",N.disabled=!0);try{const e=await(await fetch("/api/v1/system/update-check")).json();if(x=e,e.success&&e.available&&e.remote){w.style.display="",E.style.display="",O.textContent="v"+e.remote.version,C.textContent=e.remote.changelog||"No changelog available.";const a=document.getElementById("updates-btn");if(a&&!a.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="position:absolute;top:2px;right:2px;width:8px;height:8px;border-radius:50%;background:var(--accent);",a.style.position="relative",a.appendChild(i)}const o=document.getElementById("updates-dashcaddy-tab");if(o&&!o.querySelector(".update-dot")){const i=document.createElement("span");i.className="update-dot",i.style.cssText="display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--accent);margin-left:4px;vertical-align:middle;",o.appendChild(i)}}else w.style.display="none",E.style.display="none",await b(),c||g("You are running the latest version.","success");c||(N.textContent="Check for Updates",N.disabled=!1)}catch(t){c||(g("Failed to check: "+t.message,"error"),N.textContent="Check for Updates",N.disabled=!1)}}async function p(){if(!confirm("Apply DashCaddy update? The API container will restart."))return!1;j.textContent="Updating...",j.disabled=!0,g("Downloading and applying update...","info");try{const t=await(await secureFetch("/api/v1/system/update-apply",{method:"POST"})).json();if(t.success)return g("Update initiated: v"+(t.fromVersion||"?")+" \u2192 v"+(t.toVersion||"?")+". The container will restart shortly.","success"),j.textContent="Applied!",document.querySelectorAll(".update-dot").forEach(e=>e.remove()),!0;throw new Error(t.error||"Update failed")}catch(c){throw g("Update failed: "+c.message,"error"),j.textContent="Update Now",j.disabled=!1,c}}async function l(){try{const t=await(await fetch("/api/v1/system/update-history")).json(),e=t.success&&t.history?t.history:[];if(e.length===0){v.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No self-update history.</div>';return}let a='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';a+='<tr style="border-bottom: 1px solid var(--border); color: var(--mutLine truncated
|
||||
`+e.join(`
|
||||
`)+`
|
||||
|
||||
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(c){g("Rollback failed: "+c.message,"error")}}N?.addEventListener("click",()=>s(!1)),j?.addEventListener("click",()=>p().catch(()=>{})),R?.addEventListener("click",m),S?.addEventListener("click",f),B?.addEventListener("click",()=>{w?.classList.add("show"),h()}),wireModal(w,A),window.openUpdateModal=function(c){w?.classList.add("show"),h().then(()=>{if(!c)return;const t=z.querySelector(`[data-app-id="${c}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",k),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{b(),l(),x||s(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=s,setTimeout(()=>s(!0),5e3)})(),(function(){injectModal("docker-resources-modal",`<div id="docker-resources-modal" class="weather-modal">
|
||||
Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification("Invalid version: "+a,"error");return}if(!confirm("Rollback DashCaddy to v"+a+"? The container will restart."))return;g("Rolling back to v"+a+"...","info");const i=await(await secureFetch("/api/v1/system/rollback",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({version:a})})).json();if(i.success)g("Rollback to v"+a+" initiated. Container will restart.","success");else throw new Error(i.error||"Rollback failed")}catch(c){g("Rollback failed: "+c.message,"error")}}N?.addEventListener("click",()=>s(!1)),j?.addEventListener("click",()=>p().catch(()=>{})),R?.addEventListener("click",m),I?.addEventListener("click",f),B?.addEventListener("click",()=>{k?.classList.add("show"),h()}),wireModal(k,A),window.openUpdateModal=function(c){k?.classList.add("show"),h().then(()=>{if(!c)return;const t=z.querySelector(`[data-app-id="${c}"]`);t&&(t.scrollIntoView({behavior:"smooth",block:"center"}),t.style.background="rgba(249,115,22,0.15)",setTimeout(()=>{t.style.background=""},3e3))})},document.querySelector('[data-panel="updates-history"]')?.addEventListener("click",H),document.querySelector('[data-panel="updates-auto"]')?.addEventListener("click",S),document.querySelector('[data-panel="updates-dashcaddy"]')?.addEventListener("click",()=>{b(),l(),x||s(!0)}),window.dcApplyUpdate=p,window.dcCheckForUpdate=s,setTimeout(()=>s(!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>
|
||||
@@ -1684,7 +1684,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="dr-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function S(D){if(!D||D===0)return"0 B";const h=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,T)).toFixed(1)+" "+h[T]}async function z(){const D=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 T){const k=H.name==="buildkit"||H.name.length===64;f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<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>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+='<td style="padding: 6px; text-align: right;">',k||(f+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.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`),z()}catch(k){showNotification("Delete failed: "+k.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),h=D.value.trim();if(!h){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:h}),D.value="",showNotification(`Volume "${h}" created`,"success"),z()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 T){const k=["bridge","host","none"].includes(H.name);f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,f+='<td style="padding: 6px; text-align: right;">',k||(f+=`<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>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.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(k){showNotification("Delete failed: "+k.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),h=document.getElementById("dr-net-driver"),T=D.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:h.value}),D.value="",showNotification(`Network "${T}" created`,"success"),P()}catch(f){showNotification("Create failed: "+f.message,"error")}});async fuLine truncated
|
||||
</div>`);const k=document.getElementById("docker-resources-modal"),B=document.getElementById("docker-resources-btn"),A=document.getElementById("dr-close");function I(D){if(!D||D===0)return"0 B";const h=["B","KB","MB","GB","TB"],T=Math.floor(Math.log(Math.abs(D))/Math.log(1024));return(D/Math.pow(1024,T)).toFixed(1)+" "+h[T]}async function z(){const D=document.getElementById("dr-vol-list");try{const T=(await getJSON("/api/v1/docker/volumes")).volumes||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F4E6}</span>No volumes found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 T){const S=H.name==="buildkit"||H.name.length===64;f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<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>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+='<td style="padding: 6px; text-align: right;">',S||(f+=`<button class="dr-vol-del" data-name="${escapeHtml(H.name)}" style="padding: 3px 8px; font-size: 0.75rem; color: var(--bad-fg);">Delete</button>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.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`),z()}catch(S){showNotification("Delete failed: "+S.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-vol-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-vol-name"),h=D.value.trim();if(!h){showNotification("Enter a volume name","warning");return}try{await postJSON("/api/v1/docker/volumes",{name:h}),D.value="",showNotification(`Volume "${h}" created`,"success"),z()}catch(T){showNotification("Create failed: "+T.message,"error")}});async function P(){const D=document.getElementById("dr-net-list");try{const T=(await getJSON("/api/v1/docker/networks")).networks||[];if(T.length===0){D.innerHTML='<div class="panel-empty"><span class="empty-icon">\u{1F310}</span>No networks found.</div>';return}let f='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">';f+='<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 T){const S=["bridge","host","none"].includes(H.name);f+='<tr style="border-bottom: 1px solid var(--border);">',f+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(H.name)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.driver)}</td>`,f+=`<td style="padding: 6px; text-align: center; color: var(--muted);">${escapeHtml(H.scope)}</td>`,f+=`<td style="padding: 6px; text-align: center;">${H.containers}</td>`,f+='<td style="padding: 6px; text-align: right;">',S||(f+=`<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>`),f+="</td></tr>"}f+="</table>",D.innerHTML=f,D.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(S){showNotification("Delete failed: "+S.message,"error"),H.textContent="Delete",H.disabled=!1}}})})}catch(h){D.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(h.message)}</div>`}}document.getElementById("dr-net-create")?.addEventListener("click",async()=>{const D=document.getElementById("dr-net-name"),h=document.getElementById("dr-net-driver"),T=D.value.trim();if(!T){showNotification("Enter a network name","warning");return}try{await postJSON("/api/v1/docker/networks",{name:T,driver:h.value}),D.value="",showNotification(`Network "${T}" created`,"success"),P()}catch(f){showNotification("Create failed: "+f.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>
|
||||
@@ -1725,7 +1725,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<div id="compose-progress-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(w,A);let S=null;function z(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"}B?.addEventListener("click",()=>{z("paste"),S=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",w?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const D=M.target.files[0];if(!D)return;const h=new FileReader;h.onload=()=>{document.getElementById("compose-yaml").value=h.result},h.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const h=document.getElementById("compose-parse-btn"),T=h.textContent;h.textContent="Parsing...",h.disabled=!0;try{const f=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:D});S=f,S.stackName=D,P(f),z("preview")}catch(f){showNotification("Parse failed: "+f.message,"error")}finally{h.textContent=T,h.disabled=!1}});function P(M){const D=document.getElementById("compose-preview-content");let h="";M.networks&&M.networks.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),h+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,h+='<div class="scroll-container" style="max-height: 350px;">';for(const T of M.services){const f=T.skip?"var(--bad-fg)":"var(--border)";if(h+=`<div style="padding: 10px 14px; border: 1px solid ${f}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,h+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(h+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),h+="</div>",!T.skip&&(h+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(h+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),h+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}h+="</div>"}h+="</div>",D.innerHTML=h}document.getElementById("compose-back-btn")?.addEventListener("click",()=>z("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!S)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,z("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const h=await postJSON("/api/v1/apps/deploy-compose",{services:S.services,networks:S.networks,stackName:S.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(h.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const f of h.results){const H=f.status==="deployed"||f.status==="created"?"\u2705":f.status==="exists"?"\u26A1":f.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(f.name)}</strong> (${f.type}) \u2014 ${escapeHtml(f.status)}`,f.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(f.error)}</span>`),f.subdomain&&(T+=` \u2192 <code>${escapeHtml(f.subdomain)}</code>`),f.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(f.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
</div>`);const k=document.getElementById("compose-import-modal"),B=document.getElementById("compose-import-btn"),A=document.getElementById("compose-cancel");wireModal(k,A);let I=null;function z(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"}B?.addEventListener("click",()=>{z("paste"),I=null,document.getElementById("compose-yaml").value="",document.getElementById("compose-stack-name").value="",k?.classList.add("show")}),document.getElementById("compose-file-upload")?.addEventListener("change",M=>{const D=M.target.files[0];if(!D)return;const h=new FileReader;h.onload=()=>{document.getElementById("compose-yaml").value=h.result},h.readAsText(D)}),document.getElementById("compose-parse-btn")?.addEventListener("click",async()=>{const M=document.getElementById("compose-yaml").value.trim(),D=document.getElementById("compose-stack-name").value.trim()||"stack";if(!M){showNotification("Paste a docker-compose.yml","warning");return}const h=document.getElementById("compose-parse-btn"),T=h.textContent;h.textContent="Parsing...",h.disabled=!0;try{const f=await postJSON("/api/v1/apps/import-compose",{yaml:M,stackName:D});I=f,I.stackName=D,P(f),z("preview")}catch(f){showNotification("Parse failed: "+f.message,"error")}finally{h.textContent=T,h.disabled=!1}});function P(M){const D=document.getElementById("compose-preview-content");let h="";M.networks&&M.networks.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Networks: ${M.networks.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),M.volumes&&M.volumes.length>0&&(h+=`<div style="margin-bottom: 12px; font-size: 0.82rem; color: var(--muted);">Volumes: ${M.volumes.map(T=>`<code>${escapeHtml(T)}</code>`).join(", ")}</div>`),h+=`<div style="font-weight: 600; margin-bottom: 8px;">${M.services.length} service(s)</div>`,h+='<div class="scroll-container" style="max-height: 350px;">';for(const T of M.services){const f=T.skip?"var(--bad-fg)":"var(--border)";if(h+=`<div style="padding: 10px 14px; border: 1px solid ${f}; border-radius: 8px; margin-bottom: 8px; background: var(--bg);">`,h+=`<div style="font-weight: 600; font-size: 0.9rem;">${escapeHtml(T.name)}`,T.skip&&(h+=` <span style="color: var(--bad-fg); font-weight: 400; font-size: 0.78rem;">\u2014 skipped: ${escapeHtml(T.reason)}</span>`),h+="</div>",!T.skip&&(h+=`<div style="font-size: 0.8rem; color: var(--muted); margin-top: 4px;">Image: <code>${escapeHtml(T.image)}</code></div>`,T.ports?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Ports: ${T.ports.map(H=>`${H.host}:${H.container}`).join(", ")}</div>`),T.volumes?.length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Volumes: ${T.volumes.length}</div>`),Object.keys(T.environment||{}).length&&(h+=`<div style="font-size: 0.8rem; color: var(--muted);">Env vars: ${Object.keys(T.environment).length}</div>`),T.envFileWarning&&(h+=`<div style="font-size: 0.78rem; color: var(--bad-fg);">\u26A0 ${escapeHtml(T.envFileWarning)}</div>`),T.resources?.cpus||T.resources?.memory)){const H=[];T.resources.cpus&&H.push(`CPU: ${T.resources.cpus}`),T.resources.memory&&H.push(`Mem: ${T.resources.memory}MB`),h+=`<div style="font-size: 0.8rem; color: var(--muted);">Limits: ${H.join(", ")}</div>`}h+="</div>"}h+="</div>",D.innerHTML=h}document.getElementById("compose-back-btn")?.addEventListener("click",()=>z("paste")),document.getElementById("compose-deploy-btn")?.addEventListener("click",async()=>{if(!I)return;const M=document.getElementById("compose-deploy-btn");M.textContent="Deploying...",M.disabled=!0,z("progress");const D=document.getElementById("compose-progress-content");D.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Deploying services...</div>';try{const h=await postJSON("/api/v1/apps/deploy-compose",{services:I.services,networks:I.networks,stackName:I.stackName});let T=`<div style="font-weight: 600; margin-bottom: 12px;">Stack "${escapeHtml(h.stackName)}" \u2014 Deployment Complete</div>`;T+='<div class="scroll-container" style="max-height: 350px;">';for(const f of h.results){const H=f.status==="deployed"||f.status==="created"?"\u2705":f.status==="exists"?"\u26A1":f.status==="skipped"?"\u23ED":"\u274C";T+='<div style="padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 0.85rem;">',T+=`${H} <strong>${escapeHtml(f.name)}</strong> (${f.type}) \u2014 ${escapeHtml(f.status)}`,f.error&&(T+=` <span style="color: var(--bad-fg);">${escapeHtml(f.error)}</span>`),f.subdomain&&(T+=` \u2192 <code>${escapeHtml(f.subdomain)}</code>`),f.reason&&(T+=` <span style="color: var(--muted);">(${escapeHtml(f.reason)})</span>`),T+="</div>"}T+="</div>",T+='<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-done-btn">Done</button></div>Line truncated
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 16px;"><button id="compose-retry-btn">Back</button></div>`,document.getElementById("compose-retry-btn")?.addEventListener("click",()=>z("paste"))}finally{M.textContent="Deploy All",M.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>
|
||||
@@ -1734,11 +1734,11 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="exec-close">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let S=null,z=null,P=null;function M(){if(z){try{z.close()}catch{}z=null}if(S){try{S.dispose()}catch{}S=null}P=null,B.innerHTML=""}function D(h,T){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||h}`,w?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}S=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,S.loadAddon(P)),S.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";z=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(h)}`),z.binaryType="arraybuffer",z.onopen=()=>{if(S.writeln("\x1B[32mConnecting...\x1B[0m"),P){const k=P.proposeDimensions();k&&z.send(JSON.stringify({type:"resize",cols:k.cols,rows:k.rows}))}},z.onmessage=k=>{if(typeof k.data=="string"){try{const $=JSON.parse(k.data);if($.type==="connected"){S.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
|
||||
`);return}if($.type==="error"){S.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){S.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}S.write(k.data)}else S.write(new Uint8Array(k.data))},z.onclose=()=>{S&&S.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},z.onerror=()=>{S&&S.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},S.onData(k=>{z&&z.readyState===WebSocket.OPEN&&z.send(k)}),S.onResize(({cols:k,rows:$})=>{z&&z.readyState===WebSocket.OPEN&&z.send(JSON.stringify({type:"resize",cols:k,rows:$}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),w._resizeHandler=H}A?.addEventListener("click",()=>{M(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show")}),w?.addEventListener("click",h=>{h.target===w&&(M(),w._resizeHandler&&window.removeEventListener("resize",w._resizeHandler),w?.classList.remove("show"))}),window.openExecModal=D})(),(function(){injectModal("audit-modal",`<div id="audit-modal" class="weather-modal">
|
||||
</div>`);const k=document.getElementById("exec-modal"),B=document.getElementById("exec-terminal"),A=document.getElementById("exec-close");let I=null,z=null,P=null;function M(){if(z){try{z.close()}catch{}z=null}if(I){try{I.dispose()}catch{}I=null}P=null,B.innerHTML=""}function D(h,T){if(M(),document.getElementById("exec-title").textContent=`Terminal \u2014 ${T||h}`,k?.classList.add("show"),typeof Terminal>"u"){B.innerHTML='<div style="color: #f44; padding: 20px; font-family: monospace;">xterm.js not loaded</div>';return}I=new Terminal({cursorBlink:!0,fontSize:14,fontFamily:"'Cascadia Code', 'Fira Code', 'Consolas', monospace",theme:{background:"#1e1e1e",foreground:"#d4d4d4",cursor:"#aeafad",selectionBackground:"#264f78"},scrollback:5e3}),typeof FitAddon<"u"&&(P=new FitAddon.FitAddon,I.loadAddon(P)),I.open(B),P&&setTimeout(()=>P.fit(),50);const f=location.protocol==="https:"?"wss:":"ws:";z=new WebSocket(`${f}//${location.host}/ws/exec/${encodeURIComponent(h)}`),z.binaryType="arraybuffer",z.onopen=()=>{if(I.writeln("\x1B[32mConnecting...\x1B[0m"),P){const S=P.proposeDimensions();S&&z.send(JSON.stringify({type:"resize",cols:S.cols,rows:S.rows}))}},z.onmessage=S=>{if(typeof S.data=="string"){try{const $=JSON.parse(S.data);if($.type==="connected"){I.writeln(`\x1B[32mConnected (${$.shell})\x1B[0m\r
|
||||
`);return}if($.type==="error"){I.writeln(`\x1B[31mError: ${$.message}\x1B[0m`);return}if($.type==="exit"){I.writeln(`\r
|
||||
\x1B[33mSession ended.\x1B[0m`);return}}catch{}I.write(S.data)}else I.write(new Uint8Array(S.data))},z.onclose=()=>{I&&I.writeln(`\r
|
||||
\x1B[33mDisconnected.\x1B[0m`)},z.onerror=()=>{I&&I.writeln(`\r
|
||||
\x1B[31mConnection error.\x1B[0m`)},I.onData(S=>{z&&z.readyState===WebSocket.OPEN&&z.send(S)}),I.onResize(({cols:S,rows:$})=>{z&&z.readyState===WebSocket.OPEN&&z.send(JSON.stringify({type:"resize",cols:S,rows:$}))});const H=()=>{P&&P.fit()};window.addEventListener("resize",H),k._resizeHandler=H}A?.addEventListener("click",()=>{M(),k._resizeHandler&&window.removeEventListener("resize",k._resizeHandler),k?.classList.remove("show")}),k?.addEventListener("click",h=>{h.target===k&&(M(),k._resizeHandler&&window.removeEventListener("resize",k._resizeHandler),k?.classList.remove("show"))}),window.openExecModal=D})(),(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">
|
||||
@@ -1784,7 +1784,7 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="audit-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),S=document.getElementById("audit-refresh-btn"),z=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),D=document.getElementById("audit-since"),h=document.getElementById("audit-until"),T=document.getElementById("audit-log-container"),f=document.getElementById("audit-load-more");let H=0,k=null,$=0;const E=50;function I(N){if(!N)return null;const R=new Date(N);return isNaN(R.getTime())?null:R.toISOString()}async function O(N){try{N?(k&&k.abort(),k=new AbortController):(k&&k.abort(),k=new AbortController,H=0,$++,T.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const R=$,u=new URLSearchParams;u.set("limit",String(E)),u.set("offset",String(H));const v=P.value,x=M.value,g=I(D.value),b=I(h.value);v&&u.set("action",v),x&&u.set("outcome",x),g&&u.set("since",g),b&&u.set("until",b);const s=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:k.signal});if(!s.ok){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${s.status}</div>`,f.style.display="none";return}const p=await s.json();if(!p.success){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,f.style.display="none";return}if(!N&&R!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!N){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,f.style.display="none";return}let m="";N||(m='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',m+='<th style="padding: 6px; text-align: left;">When</th>',m+='<th style="padding: 6px; text-align: left;">Actor</th>',m+='<th style="padding: 6px; text-align: left;">IP</th>',m+='<th style="padding: 6px; text-align: left;">Action</th>',m+='<th style="padding: 6px; text-align: left;">Resource</th>',m+='<th style="padding: 6px; text-align: left;">Result</th>',m+="</tr>");for(const c of l){const t=c.outcome==="success",e=C(c);m+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',m+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,m+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,m+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,m+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,m+="</tr>",c.details&&Object.keys(c.details).length>0&&(m+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(c.details,null,2))}</pre></td></tr>`)}if(!N)m+="</table>",T.innerHTML=m;else{const c=T.querySelector("table");c&&c.insertAdjacentHTML("beforeend",m)}H+=l.length,f.style.display=p.hasMore?"":"none",T.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const t=c.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(R){if(R&&R.name==="AbortError")return;T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(R.message)}</div>`}}function C(N){const R=N.details||{},u=R.userEmail,v=R.userId,x=R.userRole,g=R.viaProvider;if(u){const b=x?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(x)}${g?"/"+escapeHtml(g):""}]</span>`:"";return`${escapeHtml(u)}${b}`}return v?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(v)}</span>`:N.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}B?.addEventListener("click",()=>{w?.classList.add("show"),O(!1)}),wireModal(w,A),S?.addEventListener("click",()=>O(!1)),P?.addEventListener("change",()=>O(!1)),M?.addEventListener("change",()=>O(!1));let j;[D,h].forEach(N=>{N?.addEventListener("change",()=>{clearTimeout(j),j=setTimeout(()=>O(!1),250)})}),f?.addEventListener("click",()=>O(!0)),z?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const R=await(await secureFetch("/api/v1/audit-logs",{method:"DELETE"Line truncated
|
||||
</div>`);const k=document.getElementById("audit-modal"),B=document.getElementById("audit-log-btn"),A=document.getElementById("audit-cancel"),I=document.getElementById("audit-refresh-btn"),z=document.getElementById("audit-clear-btn"),P=document.getElementById("audit-filter"),M=document.getElementById("audit-outcome-filter"),D=document.getElementById("audit-since"),h=document.getElementById("audit-until"),T=document.getElementById("audit-log-container"),f=document.getElementById("audit-load-more");let H=0,S=null,$=0;const w=50;function E(N){if(!N)return null;const R=new Date(N);return isNaN(R.getTime())?null:R.toISOString()}async function O(N){try{N?(S&&S.abort(),S=new AbortController):(S&&S.abort(),S=new AbortController,H=0,$++,T.innerHTML='<div class="panel-empty"><span class="brand-spinner"></span> Loading...</div>');const R=$,u=new URLSearchParams;u.set("limit",String(w)),u.set("offset",String(H));const v=P.value,x=M.value,g=E(D.value),b=E(h.value);v&&u.set("action",v),x&&u.set("outcome",x),g&&u.set("since",g),b&&u.set("until",b);const s=await fetch("/api/v1/audit-logs?"+u.toString(),{signal:S.signal});if(!s.ok){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: HTTP ${s.status}</div>`,f.style.display="none";return}const p=await s.json();if(!p.success){T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(p.error||"unknown")}</div>`,f.style.display="none";return}if(!N&&R!==$)return;const l=Array.isArray(p.entries)?p.entries:[];if(l.length===0&&!N){const c=p.filters&&(p.filters.action||p.filters.outcome||p.filters.since||p.filters.until)?"No entries match your filters.":"No audit log entries yet. Actions will be logged automatically.";T.innerHTML=`<div class="panel-empty"><span class="empty-icon">\u{1F4DC}</span>${escapeHtml(c)}</div>`,f.style.display="none";return}let m="";N||(m='<table style="width: 100%; border-collapse: collapse; font-size: 0.82rem;">',m+='<tr style="border-bottom: 1px solid var(--border); color: var(--muted);">',m+='<th style="padding: 6px; text-align: left;">When</th>',m+='<th style="padding: 6px; text-align: left;">Actor</th>',m+='<th style="padding: 6px; text-align: left;">IP</th>',m+='<th style="padding: 6px; text-align: left;">Action</th>',m+='<th style="padding: 6px; text-align: left;">Resource</th>',m+='<th style="padding: 6px; text-align: left;">Result</th>',m+="</tr>");for(const c of l){const t=c.outcome==="success",e=C(c);m+='<tr style="border-bottom: 1px solid var(--border); cursor: pointer;" class="audit-row">',m+=`<td style="padding: 6px; color: var(--muted);" title="${escapeHtml(c.timestamp||"")}">${timeAgo(c.timestamp)}</td>`,m+=`<td style="padding: 6px; font-size: 0.78rem;">${e}</td>`,m+=`<td style="padding: 6px; font-family: monospace; font-size: 0.78rem;">${escapeHtml(c.ip||"-")}</td>`,m+=`<td style="padding: 6px; font-weight: 500;">${escapeHtml(c.action||"-")}</td>`,m+=`<td style="padding: 6px;">${escapeHtml(c.resource||"-")}</td>`,m+=`<td style="padding: 6px;"><span style="color: ${t?"var(--ok-fg)":"var(--bad-fg)"};">${t?"\u2713":"\u2717"} ${escapeHtml(c.outcome||"")}</span></td>`,m+="</tr>",c.details&&Object.keys(c.details).length>0&&(m+=`<tr class="audit-detail" style="display: none;"><td colspan="6" style="padding: 6px 6px 10px; font-size: 0.78rem; color: var(--muted);"><pre style="margin: 0; white-space: pre-wrap; font-family: monospace;">${escapeHtml(JSON.stringify(c.details,null,2))}</pre></td></tr>`)}if(!N)m+="</table>",T.innerHTML=m;else{const c=T.querySelector("table");c&&c.insertAdjacentHTML("beforeend",m)}H+=l.length,f.style.display=p.hasMore?"":"none",T.querySelectorAll(".audit-row").forEach(c=>{c.dataset.wired||(c.dataset.wired="true",c.addEventListener("click",()=>{const t=c.nextElementSibling;t&&t.classList.contains("audit-detail")&&(t.style.display=t.style.display==="none"?"":"none")}))})}catch(R){if(R&&R.name==="AbortError")return;T.innerHTML=`<div class="panel-empty" style="color: var(--bad-fg);">Failed: ${escapeHtml(R.message)}</div>`}}function C(N){const R=N.details||{},u=R.userEmail,v=R.userId,x=R.userRole,g=R.viaProvider;if(u){const b=x?` <span style="color: var(--muted); font-size: 0.72rem;">[${escapeHtml(x)}${g?"/"+escapeHtml(g):""}]</span>`:"";return`${escapeHtml(u)}${b}`}return v?`<span style="font-family: monospace; color: var(--muted);">${escapeHtml(v)}</span>`:N.ip?'<span style="color: var(--muted);">anon</span>':'<span style="color: var(--muted);">system</span>'}B?.addEventListener("click",()=>{k?.classList.add("show"),O(!1)}),wireModal(k,A),I?.addEventListener("click",()=>O(!1)),P?.addEventListener("change",()=>O(!1)),M?.addEventListener("change",()=>O(!1));let j;[D,h].forEach(N=>{N?.addEventListener("change",()=>{clearTimeout(j),j=setTimeout(()=>O(!1),250)})}),f?.addEventListener("click",()=>O(!0)),z?.addEventListener("click",async()=>{if(confirm("Clear the entire audit log? This cannot be undone."))try{const R=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">
|
||||
@@ -1863,14 +1863,14 @@ Enter version to rollback to:`);if(!a)return;if(!e.includes(a)){showNotification
|
||||
<button id="sec-cancel">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("security-modal"),B=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),S=w.querySelectorAll(".sec-tab"),z=w.querySelectorAll(".sec-panel");let P=[],M=[],D=null;S.forEach(s=>{s.addEventListener("click",()=>{S.forEach(p=>p.classList.toggle("active",p===s)),z.forEach(p=>p.style.display=p.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&j(),s.dataset.tab==="hosts"&&v()})}),B&&B.addEventListener("click",()=>{w.classList.add("show"),H(),T()}),A.addEventListener("click",h),w.addEventListener("click",s=>{s.target===w&&h()});function h(){w.classList.remove("show"),f()}function T(){if(f(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",s=>{try{P=JSON.parse(s.data).events||[],N()}catch{}}),D.addEventListener("security",s=>{try{const p=JSON.parse(s.data);P.unshift(p),P.length>500&&(P.length=500);const l=w.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?N():l==="overview"&&H()}catch{}}),D.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function f(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{w.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[p,l,m]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),c=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await m.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,k("sec-top-actors",c.top_actors||[]),k("sec-top-targets",c.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function k(s,p){const l=document.getElementById(s);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(m=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(m.key))}</td><td style="text-align:right;color:var(--muted);">${m.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),E=document.getElementById("sec-filter-severity"),I=document.getElementById("sec-filter-host"),O=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[$,E,I].forEach(s=>s.addEventListener("change",j)),O.addEventListener("input",b(j,250)),C.addEventListener("click",j);async function j(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),E.value&&s.set("severity",E.value),I.value&&s.set("source_host",I.value),O.value&&s.set("actor_prefix",O.value),P=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],N(),(!I.options.length||I.options.length===1)&&await u()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}function N(){const s=document.getElementById("sec-events-container");if(!P.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=P.slice(0,200).map(R).join("")}function R(s){const p=s.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",m=s.ts?new Date(s.ts).toLocaleTimeString():"",c=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",a=s.action||"",o=s.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 k=document.getElementById("security-modal"),B=document.getElementById("security-center-btn"),A=document.getElementById("sec-cancel"),I=k.querySelectorAll(".sec-tab"),z=k.querySelectorAll(".sec-panel");let P=[],M=[],D=null;I.forEach(s=>{s.addEventListener("click",()=>{I.forEach(p=>p.classList.toggle("active",p===s)),z.forEach(p=>p.style.display=p.dataset.panel===s.dataset.tab?"":"none"),s.dataset.tab==="overview"&&H(),s.dataset.tab==="events"&&j(),s.dataset.tab==="hosts"&&v()})}),B&&B.addEventListener("click",()=>{k.classList.add("show"),H(),T()}),A.addEventListener("click",h),k.addEventListener("click",s=>{s.target===k&&h()});function h(){k.classList.remove("show"),f()}function T(){if(f(),!!document.getElementById("sec-live-tail").checked&&!(typeof EventSource>"u"))try{D=new EventSource("/api/v1/security/events/stream"),D.addEventListener("init",s=>{try{P=JSON.parse(s.data).events||[],N()}catch{}}),D.addEventListener("security",s=>{try{const p=JSON.parse(s.data);P.unshift(p),P.length>500&&(P.length=500);const l=k.querySelector(".sec-tab.active")?.dataset?.tab;l==="events"?N():l==="overview"&&H()}catch{}}),D.onerror=()=>{}}catch(s){console.warn("[security] SSE failed:",s.message)}}function f(){if(D){try{D.close()}catch{}D=null}}document.getElementById("sec-live-tail").addEventListener("change",()=>{k.classList.contains("show")&&T()});async function H(){try{const s=new Date(Date.now()-864e5).toISOString(),[p,l,m]=await Promise.all([fetch(`/api/v1/security/events/stats?since=${encodeURIComponent(s)}`),fetch("/api/v1/security/hosts"),fetch(`/api/v1/security/events?limit=1&since=${encodeURIComponent(s)}`)]),c=(await p.json()).data||{},t=(await l.json()).data?.hosts||[],e=(await m.json()).data?.total||0;document.querySelector('#sec-stats [data-key="total"]').textContent=`${e} events (24h)`,document.querySelector('#sec-stats [data-key="warn"]').textContent=`${c.by_severity?.warn||0} warnings`,document.querySelector('#sec-stats [data-key="error"]').textContent=`${c.by_severity?.error||0} errors`,document.querySelector('#sec-stats [data-key="denied"]').textContent=`${c.by_outcome?.denied||0} denied`,document.querySelector('#sec-stats [data-key="hosts"]').textContent=`${t.length} hosts`,S("sec-top-actors",c.top_actors||[]),S("sec-top-targets",c.top_targets||[])}catch(s){console.warn("[security] refreshOverview failed:",s.message)}}function S(s,p){const l=document.getElementById(s);if(!p.length){l.innerHTML='<div class="panel-empty">No data</div>';return}l.innerHTML='<table style="width:100%;font-size:0.85rem;">'+p.map(m=>`<tr><td style="padding:3px 0;word-break:break-all;">${g(String(m.key))}</td><td style="text-align:right;color:var(--muted);">${m.count}</td></tr>`).join("")+"</table>"}const $=document.getElementById("sec-filter-source"),w=document.getElementById("sec-filter-severity"),E=document.getElementById("sec-filter-host"),O=document.getElementById("sec-filter-actor"),C=document.getElementById("sec-refresh-btn");[$,w,E].forEach(s=>s.addEventListener("change",j)),O.addEventListener("input",b(j,250)),C.addEventListener("click",j);async function j(){try{const s=new URLSearchParams;s.set("limit","200"),$.value&&s.set("source_type",$.value),w.value&&s.set("severity",w.value),E.value&&s.set("source_host",E.value),O.value&&s.set("actor_prefix",O.value),P=(await(await fetch(`/api/v1/security/events?${s}`)).json()).data.events||[],N(),(!E.options.length||E.options.length===1)&&await u()}catch(s){document.getElementById("sec-events-container").innerHTML='<div class="panel-empty">Load failed: '+g(s.message)+"</div>"}}function N(){const s=document.getElementById("sec-events-container");if(!P.length){s.innerHTML='<div class="panel-empty">No events</div>';return}s.innerHTML=P.slice(0,200).map(R).join("")}function R(s){const p=s.severity||"info",l={critical:"#c0392b",error:"#e74c3c",warn:"#f39c12",notice:"#3498db",info:"#7f8c8d"}[p]||"#7f8c8d",m=s.ts?new Date(s.ts).toLocaleTimeString():"",c=s.source_type||"",t=s.actor||"\u2014",e=s.target||"",a=s.action||"",o=s.outcome||"";return`<div class="sec-event-row" style="display:grid;grid-template-columns:84px 80px 1fr 1fr 100px 90px;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:0.82rem;align-items:center;">
|
||||
<span style="color:${l};font-weight:600;">${g(p)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(c)}</span>
|
||||
<span title="${g(t)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(t)}</span>
|
||||
<span title="${g(e)}" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${g(a)} ${g(e)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;">${g(o)}</span>
|
||||
<span style="color:var(--muted);font-size:0.75rem;text-align:right;">${g(m)}</span>
|
||||
</div>`}async function u(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=I.value;I.innerHTML='<option value="">All hosts</option>'+p.map(m=>`<option value="${g(m.id)}">${g(m.label||m.id)}</option>`).join(""),l&&(I.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",x),document.getElementById("sec-hosts-refresh").addEventListener("click",v);async function v(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(m=>{const c=m.enabled?m.last_seen_at?Date.now()-Date.parse(m.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>`}async function u(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[],l=E.value;E.innerHTML='<option value="">All hosts</option>'+p.map(m=>`<option value="${g(m.id)}">${g(m.label||m.id)}</option>`).join(""),l&&(E.value=l)}catch{}}document.getElementById("sec-host-register-btn").addEventListener("click",x),document.getElementById("sec-hosts-refresh").addEventListener("click",v);async function v(){try{const p=(await(await fetch("/api/v1/security/hosts")).json()).data?.hosts||[];M=p;const l=document.getElementById("sec-hosts-container");if(!p.length){l.innerHTML='<div class="panel-empty">No hosts registered. Click \u2795 Register Host to add one.</div>';return}l.innerHTML=p.map(m=>{const c=m.enabled?m.last_seen_at?Date.now()-Date.parse(m.last_seen_at)>18e5?"\u{1F7E1} stale":"\u{1F7E2} online":"\u26AA registered":"\u{1F534} disabled";return`<div style="padding:10px 12px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;">
|
||||
<div>
|
||||
<strong>${g(m.label||m.id)}</strong>
|
||||
<span style="color:var(--muted);margin-left:8px;font-size:0.8rem;">${g(m.type)}</span>
|
||||
@@ -1895,7 +1895,7 @@ type: ${c.data.host.type}
|
||||
${c.data.api_key}
|
||||
|
||||
Send this key as: Authorization: Bearer <api_key>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m){alert("Failed: "+m.message)}}function g(s){return String(s).replace(/[&<>"']/g,p=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[p])}function b(s,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>s.apply(this,arguments),p)}}})(),(function(){const w=new ErrorHandler;injectModal("weather-modal",`<div id="weather-modal" class="weather-modal"><div class="weather-modal-content"><h3>Weather Settings</h3>
|
||||
To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m){alert("Failed: "+m.message)}}function g(s){return String(s).replace(/[&<>"']/g,p=>({"&":"&","<":"<",">":">",'"':""","'":"'"})[p])}function b(s,p){let l;return function(){clearTimeout(l),l=setTimeout(()=>s.apply(this,arguments),p)}}})(),(function(){const k=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>
|
||||
@@ -1906,7 +1906,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
<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 B="weather-location",A="weather-zip",S="weather-geo",z="weather-unit";!safeGet(B)&&safeGet(A)&&safeSet(B,safeGet(A));function P(){return safeGet(z)||"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 D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},h={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function f(C){return T[Math.round(C/22.5)%16]}async function H(C){const j=safeGet(S);if(j)try{const x=JSON.parse(j);if(x.query===C)return x}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const R=await N.json();if(!R.results||!R.results.length)throw new Error("Location not found");const u=R.results[0],v={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(S,JSON.stringify(v)),v}function k(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function $(C){try{const j=await H(C),N=P(),R=N==="metric"?"celsius":"fahrenheit",u=N==="metric"?"kmh":"mph",v=`https://api.open-meteo.com/v1/forecast?latitude=${j.lat}&longitude=${j.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${R}&wind_speed_unit=${u}`,x=await fetch(v);if(!x.ok)throw new Error("Weather fetch failed");const b=(await x.json()).current,s=b.weather_code;return{temp:Math.round(b.temperature_2m),condition:D[s]||"Unknown",icon:h[s]||"\u{1F324}\uFE0F",locationStr:k(j),windSpeed:Math.round(b.wind_speed_10m),windDir:f(b.wind_direction_10m),unit:N}}catch(j){return console.warn("Weather fetch failed:",j),null}}async function E(){const C=M();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const j=safeGet(B);if(!j){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await $(j);if(N){const R=N.unit==="metric"?"\xB0C":"\xB0F",u=N.unit==="metric"?"km/h":"mph";C.location.textContent=N.locationStr,C.temp.textContent=`${N.temp}${R}`,C.condition.textContent=N.condition,C.wind.textContent=`Wind: ${N.windSpeed} ${u} ${N.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){w.logError("[Weather] Update Error",N,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const I=document.getElementById("weather-modal"),O=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{O.value=safeGet(B)||"";const C=P(),j=I.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);j&&(j.checked=!0),I.classList.add("show"),O.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{I.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=O.value.trim();if(C){safeGet(B)!==C&&safeSet(S,""),safeSet(B,C);const N=I.querySelector('input[name="weather-unit-radio"]:checked'),R=N?N.value:"imperial",u=P();safeSet(z,R),u!==R&&safeSet(S,""),I.classList.remove("show"),E()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(I),document.addEventListener("keydown",C=>{C.keyLine truncated
|
||||
<div class="weather-modal-buttons"><button id="weather-cancel">Cancel</button><button id="weather-save">Save</button></div></div></div>`);const B="weather-location",A="weather-zip",I="weather-geo",z="weather-unit";!safeGet(B)&&safeGet(A)&&safeSet(B,safeGet(A));function P(){return safeGet(z)||"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 D={0:"Clear sky",1:"Mainly clear",2:"Partly cloudy",3:"Overcast",45:"Fog",48:"Rime fog",51:"Light drizzle",53:"Drizzle",55:"Dense drizzle",56:"Freezing drizzle",57:"Dense freezing drizzle",61:"Light rain",63:"Moderate rain",65:"Heavy rain",66:"Light freezing rain",67:"Heavy freezing rain",71:"Light snow",73:"Moderate snow",75:"Heavy snow",77:"Snow grains",80:"Light showers",81:"Moderate showers",82:"Violent showers",85:"Light snow showers",86:"Heavy snow showers",95:"Thunderstorm",96:"Thunderstorm with hail",99:"Severe thunderstorm"},h={0:"\u2600\uFE0F",1:"\u{1F324}\uFE0F",2:"\u26C5",3:"\u2601\uFE0F",45:"\u{1F32B}\uFE0F",48:"\u{1F32B}\uFE0F",51:"\u{1F326}\uFE0F",53:"\u{1F326}\uFE0F",55:"\u{1F326}\uFE0F",56:"\u{1F328}\uFE0F",57:"\u{1F328}\uFE0F",61:"\u{1F326}\uFE0F",63:"\u{1F327}\uFE0F",65:"\u{1F327}\uFE0F",66:"\u{1F328}\uFE0F",67:"\u{1F328}\uFE0F",71:"\u{1F328}\uFE0F",73:"\u2744\uFE0F",75:"\u2744\uFE0F",77:"\u2744\uFE0F",80:"\u{1F326}\uFE0F",81:"\u{1F327}\uFE0F",82:"\u{1F327}\uFE0F",85:"\u{1F328}\uFE0F",86:"\u2744\uFE0F",95:"\u26C8\uFE0F",96:"\u26C8\uFE0F",99:"\u26C8\uFE0F"},T=["N","NNE","NE","ENE","E","ESE","SE","SSE","S","SSW","SW","WSW","W","WNW","NW","NNW"];function f(C){return T[Math.round(C/22.5)%16]}async function H(C){const j=safeGet(I);if(j)try{const x=JSON.parse(j);if(x.query===C)return x}catch{}const N=await fetch(`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(C)}&count=1&language=en&format=json`);if(!N.ok)throw new Error("Geocoding failed");const R=await N.json();if(!R.results||!R.results.length)throw new Error("Location not found");const u=R.results[0],v={query:C,lat:u.latitude,lon:u.longitude,city:u.name,state:u.admin1||"",country:u.country||"",countryCode:u.country_code||""};return safeSet(I,JSON.stringify(v)),v}function S(C){return C.countryCode==="US"&&C.state?`${C.city}, ${C.state}`:C.country?`${C.city}, ${C.country}`:C.city}async function $(C){try{const j=await H(C),N=P(),R=N==="metric"?"celsius":"fahrenheit",u=N==="metric"?"kmh":"mph",v=`https://api.open-meteo.com/v1/forecast?latitude=${j.lat}&longitude=${j.lon}¤t=temperature_2m,weather_code,wind_speed_10m,wind_direction_10m&temperature_unit=${R}&wind_speed_unit=${u}`,x=await fetch(v);if(!x.ok)throw new Error("Weather fetch failed");const b=(await x.json()).current,s=b.weather_code;return{temp:Math.round(b.temperature_2m),condition:D[s]||"Unknown",icon:h[s]||"\u{1F324}\uFE0F",locationStr:S(j),windSpeed:Math.round(b.wind_speed_10m),windDir:f(b.wind_direction_10m),unit:N}}catch(j){return console.warn("Weather fetch failed:",j),null}}async function w(){const C=M();if(!C.icon||!C.temp||!C.condition||!C.location||!C.wind){console.warn("Weather widget elements not found");return}const j=safeGet(B);if(!j){C.location.textContent="Set Location",C.temp.textContent="--\xB0",C.condition.textContent="Click \u2699\uFE0F to configure",C.wind.textContent="--",C.icon.innerHTML='<span class="weather-emoji">\u{1F324}\uFE0F</span>';return}try{const N=await $(j);if(N){const R=N.unit==="metric"?"\xB0C":"\xB0F",u=N.unit==="metric"?"km/h":"mph";C.location.textContent=N.locationStr,C.temp.textContent=`${N.temp}${R}`,C.condition.textContent=N.condition,C.wind.textContent=`Wind: ${N.windSpeed} ${u} ${N.windDir}`,C.icon.innerHTML=`<span class="weather-emoji">${escapeHtml(N.icon)}</span>`}}catch(N){k.logError("[Weather] Update Error",N,{function:"updateWeather"}),C.location.textContent="Weather Error",C.temp.textContent="Error",C.condition.textContent="Failed to load",C.wind.textContent="--"}}const E=document.getElementById("weather-modal"),O=document.getElementById("weather-location-input");document.getElementById("weather-settings")?.addEventListener("click",()=>{O.value=safeGet(B)||"";const C=P(),j=E.querySelector(`input[name="weather-unit-radio"][value="${C}"]`);j&&(j.checked=!0),E.classList.add("show"),O.focus()}),document.getElementById("weather-cancel")?.addEventListener("click",()=>{E.classList.remove("show")}),document.getElementById("weather-save")?.addEventListener("click",()=>{const C=O.value.trim();if(C){safeGet(B)!==C&&safeSet(I,""),safeSet(B,C);const N=E.querySelector('input[name="weather-unit-radio"]:checked'),R=N?N.value:"imperial",u=P();safeSet(z,R),u!==R&&safeSet(I,""),E.classList.remove("show"),w()}else showNotification("Please enter a location (e.g., Hamburg, London, 90210)","warning")}),wireModal(E),document.addEventListener("keydown",C=>{C.keyLine truncated
|
||||
<circle cx="${r}" cy="${d}" r="58" fill="none" stroke="var(--border)" stroke-width="2"/>
|
||||
${F}
|
||||
${q}
|
||||
@@ -1914,7 +1914,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
<line x1="${r}" y1="${d}" x2="${r+38*Math.cos(L*Math.PI/180)}" y2="${d+38*Math.sin(L*Math.PI/180)}" stroke="var(--fg)" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="${r}" y1="${d}" x2="${r+42*Math.cos(y*Math.PI/180)}" y2="${d+42*Math.sin(y*Math.PI/180)}" stroke="#e74c3c" stroke-width="1" stroke-linecap="round"/>
|
||||
<circle cx="${r}" cy="${d}" r="3" fill="var(--fg)"/>
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${_}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function v(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(w.className!==i&&(w.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":j(t);break;case"binary":R(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:O(t)}a===0&&o===0&&e!==M&&(M=e,k(e)),a!==0&&(M=-1)}function x(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{v(),x()},e)}document.addEventListener("visibilitychange",()=>{h="",E(),v(),x()}),v(),x();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let b='<div class="clock-style-grid">';g.forEach(t=>{b+=`<label class="clock-style-option">
|
||||
</svg>`,J=t.getHours()>=12?"PM":"AM";B.innerHTML=`<div class="analog-clock-wrap">${_}<div class="analog-info"><span class="analog-digital">${t.getHours()%12||12}:${String(o).padStart(2,"0")} ${J}</span><span class="analog-date-sm">${$(t)}</span></div></div>`,T="analog"}function v(){const t=new Date,e=t.getHours()%12||12,a=t.getMinutes(),o=t.getSeconds(),i="clock-widget"+(P!=="default"?" "+P:"");switch(k.className!==i&&(k.className=i),P){case"lcd":C(t);break;case"lcd-blue":C(t);break;case"lcd-amber":C(t);break;case"lcd-retro":C(t);break;case"lcd-taxi":C(t);break;case"flip":j(t);break;case"binary":R(t);break;case"analog":u(t,!1);break;case"roman":u(t,!0);break;default:O(t)}a===0&&o===0&&e!==M&&(M=e,S(e)),a!==0&&(M=-1)}function x(){clearTimeout(H);const t=document.hidden?6e4:1e3,e=t-Date.now()%t+25;H=setTimeout(()=>{v(),x()},e)}document.addEventListener("visibilitychange",()=>{h="",w(),v(),x()}),v(),x();const g=[{id:"default",label:"Default",icon:"\u{1F550}"},{id:"lcd",label:"LCD Green",icon:"\u{1F49A}"},{id:"lcd-blue",label:"LCD Blue",icon:"\u{1F499}"},{id:"lcd-amber",label:"LCD Amber",icon:"\u{1F7E0}"},{id:"lcd-retro",label:"LCD Retro",icon:"\u{1F7E9}"},{id:"lcd-taxi",label:"LCD Taxi",icon:"\u{1F7E1}"},{id:"flip",label:"Flip Clock",icon:"\u{1F4DF}"},{id:"binary",label:"Binary",icon:"\u{1F4BB}"},{id:"analog",label:"Analog",icon:"\u23F0"},{id:"roman",label:"Roman",icon:"\u{1F3DB}\uFE0F"}];let b='<div class="clock-style-grid">';g.forEach(t=>{b+=`<label class="clock-style-option">
|
||||
<input type="radio" name="clock-style-radio" value="${t.id}">
|
||||
<span class="clock-style-card"><span class="clock-style-icon">${t.icon}</span><span class="clock-style-label">${t.label}</span></span>
|
||||
</label>`}),b+="</div>",injectModal("clock-settings-modal",`<div id="clock-settings-modal" class="weather-modal">
|
||||
@@ -1946,7 +1946,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
<button id="clock-settings-save">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const s=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),m=document.getElementById("clock-volume-section");function c(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",m.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{m.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),P=e,h="",E(),v(),x(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,h="",E(),v()})})})(),(function(){async function w(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[h,T]of Object.entries(D.status)){const f=document.getElementById("uptime-"+h),H=document.getElementById("uptime-bar-"+h);if(!f)continue;const k=T.uptime?.["24h"];if(k!=null){const $=k.toFixed(1);f.textContent=`${$}% uptime`,f.className="uptime-chip",k>=99.9?f.classList.add("excellent"):k>=99?f.classList.add("good"):k>=95?f.classList.add("degraded"):f.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}let A=[];async function S(M){try{const h=await(await fetch("/api/v1/updates/available")).json();if(!h.success)return;document.querySelectorAll(".update-available-badge").forEach(f=>f.classList.remove("visible"));const T=h.updates||[];if(A=T,M&&T.length>0){const f=window._lastKnownUpdateCount||0;f>0&&T.length>f&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const f of T){const H=window.APPS||[];for(const k of H)if(k.containerId===f.containerId||k.id===f.containerName||k.name===f.containerName){if(B.has(k.id))break;const $=document.getElementById("update-badge-"+k.id),E=document.getElementById("update-btn-"+k.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=I=>{I.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(k.id)}),E&&(E.style.background="#f97316",E.style.borderColor="#f97316",E.style.boxShadow="0 0 6px #f9731688",E.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function z(){setTimeout(()=>{w(),S()},5e3),setInterval(()=>{w(),S(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(w,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),z()})(),(function(){var w=null,B=null,A={},S={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},z=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var M=document.getElementById("theme-label");function D(o){if(S[o])return S[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function h(){M&&(M.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),r=o[(n+1)%o.length];window.applyTheme(r),h()}),h();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
</div>`);const s=document.getElementById("clock-settings-modal"),p=document.getElementById("clock-chimes-toggle"),l=document.getElementById("clock-chime-volume"),m=document.getElementById("clock-volume-section");function c(){const t=safeGet("clock-style")||"default",e=s.querySelector(`input[value="${t}"]`);e&&(e.checked=!0),p.checked=safeGet("clock-chimes")==="true",l.value=safeGet("clock-chime-volume")||"50",m.style.opacity=p.checked?"1":"0.4"}p?.addEventListener("change",()=>{m.style.opacity=p.checked?"1":"0.4"}),document.getElementById("clock-settings")?.addEventListener("click",()=>{c(),s.classList.add("show")}),document.getElementById("clock-chime-test")?.addEventListener("click",()=>{const t=parseInt(l.value,10)/100,e=new Audio("/assets/sounds/church-bell.mp3");e.volume=t,e.play().catch(()=>{})}),document.getElementById("clock-settings-save")?.addEventListener("click",()=>{const t=s.querySelector('input[name="clock-style-radio"]:checked'),e=t?t.value:"default";safeSet("clock-style",e),safeSet("clock-chimes",String(p.checked)),safeSet("clock-chime-volume",l.value),P=e,h="",w(),v(),x(),s.classList.remove("show"),showNotification("Clock settings saved","success",2e3)}),document.getElementById("clock-settings-cancel")?.addEventListener("click",()=>{s.classList.remove("show")}),wireModal(s),s?.querySelectorAll('input[name="clock-style-radio"]').forEach(t=>{t.addEventListener("change",()=>{P=t.value,h="",w(),v()})})})(),(function(){async function k(){try{const D=await(await fetch("/api/v1/health-checks/status")).json();if(!D.success||!D.status)return;for(const[h,T]of Object.entries(D.status)){const f=document.getElementById("uptime-"+h),H=document.getElementById("uptime-bar-"+h);if(!f)continue;const S=T.uptime?.["24h"];if(S!=null){const $=S.toFixed(1);f.textContent=`${$}% uptime`,f.className="uptime-chip",S>=99.9?f.classList.add("excellent"):S>=99?f.classList.add("good"):S>=95?f.classList.add("degraded"):f.classList.add("poor"),H&&(H.style.width=$+"%")}}}catch{console.warn("[Card Badges] Health check API unavailable")}}let B;try{B=new Set(JSON.parse(safeSessionGet("dismissed-updates")||"[]"))}catch{B=new Set}let A=[];async function I(M){try{const h=await(await fetch("/api/v1/updates/available")).json();if(!h.success)return;document.querySelectorAll(".update-available-badge").forEach(f=>f.classList.remove("visible"));const T=h.updates||[];if(A=T,M&&T.length>0){const f=window._lastKnownUpdateCount||0;f>0&&T.length>f&&showNotification(`${T.length} container update(s) available \u2014 click Update Management to review.`,"info"),window._lastKnownUpdateCount=T.length}if(!T.length)return;for(const f of T){const H=window.APPS||[];for(const S of H)if(S.containerId===f.containerId||S.id===f.containerName||S.name===f.containerName){if(B.has(S.id))break;const $=document.getElementById("update-badge-"+S.id),w=document.getElementById("update-btn-"+S.id);$&&($.classList.add("visible"),$.title="Update available \u2014 click to open Update Management.",$.style.cursor="pointer",$.onclick=E=>{E.stopPropagation(),window.openUpdateModal&&window.openUpdateModal(S.id)}),w&&(w.style.background="#f97316",w.style.borderColor="#f97316",w.style.boxShadow="0 0 6px #f9731688",w.title="Update available \u2014 click to open Update Management.");break}}}catch{console.warn("[Card Badges] Updates API unavailable")}}function z(){setTimeout(()=>{k(),I()},5e3),setInterval(()=>{k(),I(!0)},6e4)}const P=window.refreshAll;P&&(window.refreshAll=async function(){try{await P(),setTimeout(k,1e3)}catch(M){console.warn("[Card Badges] Error in refreshAll hook:",M.message)}}),z()})(),(function(){var k=null,B=null,A={},I={dark:"Dark",light:"Light",blue:"Blue",black:"Black",nord:"Nord",dracula:"Dracula","solarized-dark":"Solarized Dark","solarized-light":"Solarized Light",taxi:"Taxi",ocean:"Ocean"},z=[["bg","Background","base"],["card-base","Card","base"],["fg","Text","base"],["muted","Muted Text","base"],["border","Border","base"],["accent","Accent","accent"],["accent-strong","Accent Strong","accent"],["ok-bg","OK Background","status"],["ok-fg","OK Text","status"],["bad-bg","Error Bg","status"],["bad-fg","Error Text","status"],["dot-ok","Dot OK","status"],["dot-bad","Dot Error","status"],["uptime","Uptime Bar","status"],["hover","Hover","advanced"],["card-hover","Card Hover","advanced"],["base","Tags/Badges","advanced"],["fg-muted","Dim Text","advanced"],["success","Success","advanced"],["error","Error","advanced"],["warning","Warning","advanced"]],P=document.getElementById("theme");if(!P)return;var M=document.getElementById("theme-label");function D(o){if(I[o])return I[o];var i=safeGetJSON(window.USER_THEMES_KEY,{});return i[o]&&i[o].name||o}function h(){M&&(M.textContent=D(window.getActiveTheme()))}P.addEventListener("click",function(){var o=window.THEMES.slice(),i=window.getActiveTheme(),n=o.indexOf(i),r=o[(n+1)%o.length];window.applyTheme(r),h()}),h();function T(){var o={base:"Base Colors",accent:"Accent",status:"Status",advanced:"Advanced (auto-deriLine truncated
|
||||
<div class="weather-modal-content" style="min-width: 420px; max-width: 520px;">
|
||||
<h3>DashCaddy License</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--muted); margin: 0 0 16px;">
|
||||
@@ -1983,16 +1983,16 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
<button id="license-activate" class="btn-accent">Activate</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`);const w=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),S=document.getElementById("license-deactivate"),z=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),h=document.getElementById("license-badge"),T=document.getElementById("license-details"),f=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let k=null;function $(){z.style.display="none",P.style.display="none"}function E(x){$(),z.textContent=x,z.style.display="block"}function I(x){$(),P.textContent=x,P.style.display="block"}function O(x){if(k=x,x.active){h.style.background="rgba(46,204,113,0.15)",h.style.color="var(--ok-fg)",M.textContent="\u2605",D.textContent="Premium Active";const s=x.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(x.expiresAt).toLocaleDateString()}</strong> (${x.daysRemaining} days remaining)</div>`;T.innerHTML=`
|
||||
</div>`);const k=document.getElementById("license-modal"),B=document.getElementById("license-code-input"),A=document.getElementById("license-activate"),I=document.getElementById("license-deactivate"),z=document.getElementById("license-error"),P=document.getElementById("license-success"),M=document.getElementById("license-badge-icon"),D=document.getElementById("license-badge-text"),h=document.getElementById("license-badge"),T=document.getElementById("license-details"),f=document.getElementById("license-feature-list"),H=document.getElementById("license-activate-section");let S=null;function $(){z.style.display="none",P.style.display="none"}function w(x){$(),z.textContent=x,z.style.display="block"}function E(x){$(),P.textContent=x,P.style.display="block"}function O(x){if(S=x,x.active){h.style.background="rgba(46,204,113,0.15)",h.style.color="var(--ok-fg)",M.textContent="\u2605",D.textContent="Premium Active";const s=x.lifetime?"<div>License: <strong>LIFETIME</strong></div>":`<div>Expires: <strong>${new Date(x.expiresAt).toLocaleDateString()}</strong> (${x.daysRemaining} days remaining)</div>`;T.innerHTML=`
|
||||
<div>Code: <code style="font-family: monospace;">${x.code||"***"}</code></div>
|
||||
${s}
|
||||
`,H.style.display="none",A.style.display="none",S.style.display=""}else h.style.background="rgba(149,165,166,0.15)",h.style.color="var(--muted)",M.textContent="\u2606",D.textContent=x.expired?"License Expired":"Free Tier",T.innerHTML=x.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",S.style.display="none";const g=x.premiumFeatures||{},b=new Set(x.features||[]);f.innerHTML=Object.entries(g).map(([s,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
`,H.style.display="none",A.style.display="none",I.style.display=""}else h.style.background="rgba(149,165,166,0.15)",h.style.color="var(--muted)",M.textContent="\u2606",D.textContent=x.expired?"License Expired":"Free Tier",T.innerHTML=x.expired?"<div>Your license has expired. Enter a new code to renew.</div>":"<div>Enter a license code to unlock premium features.</div>",H.style.display="",A.style.display="",I.style.display="none";const g=x.premiumFeatures||{},b=new Set(x.features||[]);f.innerHTML=Object.entries(g).map(([s,p])=>`<div style="display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-radius: 6px; background: var(--card-bg); border: 1px solid var(--border);">
|
||||
<span style="font-size: 1.1rem;">${b.has(s)?"\u2705":"\u{1F512}"}</span>
|
||||
<div>
|
||||
<div style="font-weight: 600; font-size: 0.9rem;">${p.name}</div>
|
||||
<div style="font-size: 0.78rem; color: var(--muted);">${p.description}</div>
|
||||
</div>
|
||||
</div>`).join("")}async function C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(O(g.license),R(g.license))}catch(x){console.warn("Failed to load license status:",x.message)}}async function j(){const x=B.value.trim();if(!x){E("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const b=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:x})})).json();b.success?(I(b.message),B.value="",O(b.license),showNotification("License activated! Premium features unlocked.","success",5e3),R(b.license)):E(b.error||"Activation failed")}catch(g){E("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){S.disabled=!0,S.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(I(g.message),await C(),showNotification("License deactivated.","info",3e3),R({active:!1})):E(g.error||"Deactivation failed")}catch(x){E("Network error: "+x.message)}finally{S.disabled=!1,S.textContent="Deactivate"}}}function R(x){const g=document.getElementById("license-status-topbar"),b=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(x.active?"premium":"free"),x.active)if(b.textContent="\u2605",s.textContent="PREMIUM",x.lifetime)p.textContent="\xB7 LIFETIME";else{const l=x.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else b.textContent="\u2606",s.textContent=x.expired?"EXPIRED":"FREE TIER",p.textContent=""}function u(){$(),C(),w.classList.add("show")}B.addEventListener("input",function(){let x=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(x.length>this._prevLength&&(x=x.replace(/-/g,""),x.length>2&&!x.startsWith("DC")&&(x="DC"+x),x.startsWith("DC")&&x.length>2)){const g=["DC"],b=x.substring(2);for(let s=0;s<b.length;s+=5)g.push(b.substring(s,s+5));x=g.join("-")}this._prevLength=x.length,this.value=x}),A.addEventListener("click",j),S.addEventListener("click",N),B.addEventListener("keydown",x=>{x.key==="Enter"&&j()}),wireModal(w,document.getElementById("license-cancel"));const v=document.getElementById("license-status-topbar");v&&v.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(x){try{return(await(await fetch(`/api/v1/license/feature/${x}`)).json()).available}catch{return!1}},C().then(x=>{k&&R(k)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const w=[{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 C(){try{const g=await(await fetch("/api/v1/license/status")).json();g.success&&(O(g.license),R(g.license))}catch(x){console.warn("Failed to load license status:",x.message)}}async function j(){const x=B.value.trim();if(!x){w("Please enter a license code.");return}$(),A.disabled=!0,A.textContent="Activating...";try{const b=await(await secureFetch("/api/v1/license/activate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:x})})).json();b.success?(E(b.message),B.value="",O(b.license),showNotification("License activated! Premium features unlocked.","success",5e3),R(b.license)):w(b.error||"Activation failed")}catch(g){w("Network error: "+g.message)}finally{A.disabled=!1,A.textContent="Activate"}}async function N(){if(confirm("Deactivate your license? You can reuse the code on another machine.")){I.disabled=!0,I.textContent="Deactivating...";try{const g=await(await secureFetch("/api/v1/license/deactivate",{method:"POST"})).json();g.success?(E(g.message),await C(),showNotification("License deactivated.","info",3e3),R({active:!1})):w(g.error||"Deactivation failed")}catch(x){w("Network error: "+x.message)}finally{I.disabled=!1,I.textContent="Deactivate"}}}function R(x){const g=document.getElementById("license-status-topbar"),b=document.getElementById("license-topbar-icon"),s=document.getElementById("license-topbar-text"),p=document.getElementById("license-topbar-time");if(g)if(g.className="license-status-topbar "+(x.active?"premium":"free"),x.active)if(b.textContent="\u2605",s.textContent="PREMIUM",x.lifetime)p.textContent="\xB7 LIFETIME";else{const l=x.daysRemaining;p.textContent=l!=null?"\xB7 "+l+"d remaining":""}else b.textContent="\u2606",s.textContent=x.expired?"EXPIRED":"FREE TIER",p.textContent=""}function u(){$(),C(),k.classList.add("show")}B.addEventListener("input",function(){let x=this.value.toUpperCase().replace(/[^A-Z0-9-]/g,"");if(x.length>this._prevLength&&(x=x.replace(/-/g,""),x.length>2&&!x.startsWith("DC")&&(x="DC"+x),x.startsWith("DC")&&x.length>2)){const g=["DC"],b=x.substring(2);for(let s=0;s<b.length;s+=5)g.push(b.substring(s,s+5));x=g.join("-")}this._prevLength=x.length,this.value=x}),A.addEventListener("click",j),I.addEventListener("click",N),B.addEventListener("keydown",x=>{x.key==="Enter"&&j()}),wireModal(k,document.getElementById("license-cancel"));const v=document.getElementById("license-status-topbar");v&&v.addEventListener("click",()=>window.openLicenseModal&&window.openLicenseModal()),window.openLicenseModal=u,window.checkPremiumFeature=async function(x){try{return(await(await fetch(`/api/v1/license/feature/${x}`)).json()).available}catch{return!1}},C().then(x=>{S&&R(S)})})(),(function(){"use strict";if(window.__dc_058_share_modal_loaded)return;window.__dc_058_share_modal_loaded=!0;const k=[{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>
|
||||
@@ -2014,7 +2014,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
<div class="share-tab-panel" data-panel="public">
|
||||
<label class="form-label-bold" for="share-public-ttl">Link duration:</label>
|
||||
<select id="share-public-ttl" style="width: 100%; padding: 8px 10px; margin: 6px 0 12px; background: var(--input-bg); color: var(--fg); border: 1px solid var(--border); border-radius: 4px;">
|
||||
${w.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
${k.map(t=>`<option value="${t.ms}">${t.label}</option>`).join("")}
|
||||
</select>
|
||||
<button id="share-public-create" class="btn-accent" type="button"
|
||||
style="width: 100%; padding: 10px; background: var(--accent); color: #04141f; border: 0; border-radius: 4px; font-weight: 600; cursor: pointer;">
|
||||
@@ -2061,7 +2061,7 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);const B=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),S=document.getElementById("share-issued"),z=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),h=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),f=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),k=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),E=document.getElementById("share-public-ttl");let I=null,O="public";function C(){D.style.display="none",h.style.display="none",S.style.display="none"}function j(t){h.style.display="none",S.style.display="none",D.textContent=t,D.style.display="block"}function N(t){D.style.display="none",h.textContent=t,h.style.display="block"}function R(){return window.location.origin}function u(t){return t.startsWith("http")?t:R()+t}function v(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function x(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){O=t,C(),B.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),B.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function b(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===I.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(i=>{const n=i.expiresAt?x(new Date(i.expiresAt).getTime()-Date.now()):"";return`
|
||||
`);const B=document.getElementById("share-modal"),A=document.getElementById("share-modal-service-name"),I=document.getElementById("share-issued"),z=document.getElementById("share-issued-url"),P=document.getElementById("share-issued-copy"),M=document.getElementById("share-issued-meta"),D=document.getElementById("share-error"),h=document.getElementById("share-success"),T=document.getElementById("share-outstanding-list"),f=document.getElementById("share-cancel"),H=document.getElementById("share-public-create"),S=document.getElementById("share-ts-create"),$=document.getElementById("share-ts-email"),w=document.getElementById("share-public-ttl");let E=null,O="public";function C(){D.style.display="none",h.style.display="none",I.style.display="none"}function j(t){h.style.display="none",I.style.display="none",D.textContent=t,D.style.display="block"}function N(t){D.style.display="none",h.textContent=t,h.style.display="block"}function R(){return window.location.origin}function u(t){return t.startsWith("http")?t:R()+t}function v(t){if(!t)return"";const e=new Date(t);return Number.isNaN(e.getTime())?"":e.toLocaleString()}function x(t){if(t<=0)return"expired";const e=Math.floor(t/36e5);if(e>=24)return`${Math.floor(e/24)}d ${e%24}h left`;const a=Math.floor(t%36e5/6e4);return`${e}h ${a}m left`}function g(t){O=t,C(),B.querySelectorAll(".share-tab").forEach(e=>{const a=e.dataset.tab===t;e.classList.toggle("active",a),e.style.borderBottomColor=a?"var(--accent)":"transparent",e.style.color=a?"var(--fg)":"var(--muted)",e.style.fontWeight=a?"600":"400"}),B.querySelectorAll(".share-tab-panel").forEach(e=>{e.style.display=e.dataset.panel===t?"":"none"})}async function b(){T.innerHTML='<span style="color: var(--muted);">Loading\u2026</span>';try{const t=await fetch("/api/v1/share",{credentials:"same-origin"});if(!t.ok){T.innerHTML='<span style="color: var(--muted);">No shares listed.</span>';return}const e=await t.json(),o=(e&&e.data||[]).filter(i=>i.serviceId===E.id);if(o.length===0){T.innerHTML='<span style="color: var(--muted);">No outstanding shares for this service.</span>';return}T.innerHTML=o.map(i=>{const n=i.expiresAt?x(new Date(i.expiresAt).getTime()-Date.now()):"";return`
|
||||
<div class="share-row" data-share-id="${escapeHtml(i.id)}"
|
||||
style="display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px dashed var(--border);">
|
||||
<span style="flex: 1;">
|
||||
@@ -2073,5 +2073,5 @@ To endpoint: POST /api/v1/security/events/ingest or /events/batch`),v()}catch(m)
|
||||
Revoke
|
||||
</button>
|
||||
</div>
|
||||
`}).join(""),T.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>s(i.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));j(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),b()}catch{j("Network error while revoking.")}}async function p(){if(!I)return;C(),H.disabled=!0;const t=H.textContent;H.textContent="Creating\u2026";try{const e=parseInt(E.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:I.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data.urlPath;z.value=u(i),M.textContent=`Public link \xB7 expires ${v(o.data.expiresAt)}`,S.style.display="block",N("Share link created."),b()}catch{j("Network error while creating share.")}finally{H.disabled=!1,H.textContent=t}}async function l(){if(!I)return;const t=($.value||"").trim();if(!t||!t.includes("@")){j("A valid recipient email is required.");return}C(),k.disabled=!0;const e=k.textContent;k.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:I.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data&&o.data.urlPath;i?(z.value=u(i),M.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(z.value="",M.textContent=`Tailscale invite sent to ${t}.`),S.style.display="block",N("Tailscale invite created."),$.value="",b()}catch{j("Network error while creating Tailscale invite.")}finally{k.disabled=!1,k.textContent=e}}function m(t,e){const a=e&&e.error||"";if(t===402){j("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){j("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){j("You do not have permission to share services.");return}if(t===404){j("Service not found. It may have been deleted.");return}j(a||`Request failed (HTTP ${t}).`)}function c(t){!t||!t.id||(I={id:t.id,name:t.name||t.id},A.textContent=I.name,C(),g("public"),$.value="",E.value=String(w[1].ms),B.classList.add("show"),b())}B.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),H.addEventListener("click",p),k.addEventListener("click",l),f.addEventListener("click",()=>B.classList.remove("show")),wireModal(B,f),P.addEventListener("click",async()=>{const t=z.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(z.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{j("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|
||||
`}).join(""),T.querySelectorAll(".share-revoke").forEach(i=>{i.addEventListener("click",()=>s(i.dataset.shareId))})}catch{T.innerHTML='<span style="color: var(--muted);">Could not load outstanding shares.</span>'}}async function s(t){try{const e=await fetch(`/api/v1/share/${encodeURIComponent(t)}`,{method:"DELETE",credentials:"same-origin"});if(!e.ok){const a=await e.json().catch(()=>({}));j(a&&a.error||`Revoke failed (HTTP ${e.status}).`);return}N("Share revoked."),b()}catch{j("Network error while revoking.")}}async function p(){if(!E)return;C(),H.disabled=!0;const t=H.textContent;H.textContent="Creating\u2026";try{const e=parseInt(w.value,10),a=await fetch("/api/v1/share",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:E.id,ttlMs:e})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data.urlPath;z.value=u(i),M.textContent=`Public link \xB7 expires ${v(o.data.expiresAt)}`,I.style.display="block",N("Share link created."),b()}catch{j("Network error while creating share.")}finally{H.disabled=!1,H.textContent=t}}async function l(){if(!E)return;const t=($.value||"").trim();if(!t||!t.includes("@")){j("A valid recipient email is required.");return}C(),S.disabled=!0;const e=S.textContent;S.textContent="Creating\u2026";try{const a=await fetch("/api/v1/share/tailscale",{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify({serviceId:E.id,email:t})}),o=await a.json().catch(()=>({}));if(!a.ok||!o.success){m(a.status,o);return}const i=o.data&&o.data.urlPath;i?(z.value=u(i),M.textContent=`Tailscale invite \xB7 emailed to ${t} (or use this URL manually if delivery failed).`):(z.value="",M.textContent=`Tailscale invite sent to ${t}.`),I.style.display="block",N("Tailscale invite created."),$.value="",b()}catch{j("Network error while creating Tailscale invite.")}finally{S.disabled=!1,S.textContent=e}}function m(t,e){const a=e&&e.error||"";if(t===402){j("Share is a Pro feature. Activate a license to unlock.");return}if(t===400&&/tailscale/i.test(a)){j("Tailscale is not configured on this host. Set up Tailscale in the dashboard first.");return}if(t===403){j("You do not have permission to share services.");return}if(t===404){j("Service not found. It may have been deleted.");return}j(a||`Request failed (HTTP ${t}).`)}function c(t){!t||!t.id||(E={id:t.id,name:t.name||t.id},A.textContent=E.name,C(),g("public"),$.value="",w.value=String(k[1].ms),B.classList.add("show"),b())}B.querySelectorAll(".share-tab").forEach(t=>{t.addEventListener("click",()=>g(t.dataset.tab))}),H.addEventListener("click",p),S.addEventListener("click",l),f.addEventListener("click",()=>B.classList.remove("show")),wireModal(B,f),P.addEventListener("click",async()=>{const t=z.value;if(t)try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(z.select(),document.execCommand("copy"));const e=P.textContent;P.textContent="Copied!",setTimeout(()=>{P.textContent=e},1200)}catch{j("Could not copy to clipboard. Select the URL manually.")}}),window.openShareModal=c})();
|
||||
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiPHN0ZGluPiJdLAogICJzb3VyY2VzQ29udGVudCI6IFsiLy8gTG9nbyBDdXN0b21pemF0aW9uIFN5c3RlbVxuKGZ1bmN0aW9uKCkge1xuICAvLyBJbmplY3QgbW9kYWwgSFRNTFxuICBpbmplY3RNb2RhbCgnbG9nby1tb2RhbCcsIGA8ZGl2IGlkPVwibG9nby1tb2RhbFwiIGNsYXNzPVwid2VhdGhlci1tb2RhbFwiPlxuICAgIDxkaXYgY2xhc3M9XCJ3ZWF0aGVyLW1vZGFsLWNvbnRlbnRcIiBzdHlsZT1cIm1pbi13aWR0aDogNDAwcHg7IG1heC13aWR0aDogNTIwcHg7XCI+XG4gICAgICA8aDM+RGFzaGJvYXJkIFNldHRpbmdzPC9oMz5cbiAgICAgIDxwIHN0eWxlPVwiZm9udC1zaXplOiAwLjg1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW46IDAgMCAxNnB4O1wiPlxuICAgICAgICBDdXN0b21pemUgeW91ciBkYXNoYm9hcmQncyBhcHBlYXJhbmNlIGFuZCBzeXN0ZW0gcHJlZmVyZW5jZXMuXG4gICAgICA8L3A+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgZm9yPVwiZGFzaGJvYXJkLXRpdGxlXCIgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5EYXNoYm9hcmQgVGl0bGU6PC9sYWJlbD5cbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgaWQ9XCJkYXNoYm9hcmQtdGl0bGVcIiBwbGFjZWhvbGRlcj1cIkRhc2hDYWRkeVwiIG1heGxlbmd0aD1cIjUwXCIgc3R5bGU9XCJ3aWR0aDogMTAwJTsgcGFkZGluZzogMTBweCAxMnB4OyBib3JkZXI6IDFweCBzb2xpZCB2YXIoLS1ib3JkZXIpOyBib3JkZXItcmFkaXVzOiA0cHg7IGJhY2tncm91bmQ6IHZhcigtLWNhcmQtYmcpOyBjb2xvcjogdmFyKC0tZmcpOyBmb250LXNpemU6IDFyZW07XCIgLz5cbiAgICAgICAgPHAgY2xhc3M9XCJ0aW55LWhpbnRcIj5TaG93biBpbiBicm93c2VyIHRhYiBhbmQgaGVhZGVyIChtYXggNTAgY2hhcmFjdGVycyk8L3A+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGhyIGNsYXNzPVwiaHItZGl2aWRlclwiIC8+XG5cbiAgICAgIDxkaXYgY2xhc3M9XCJtYi0xNlwiPlxuICAgICAgICA8bGFiZWwgY2xhc3M9XCJmb3JtLWxhYmVsLWJvbGRcIj5Mb2dvOjwvbGFiZWw+XG4gICAgICAgIDxwIGNsYXNzPVwidGlueS1oaW50XCIgc3R5bGU9XCJtYXJnaW4tdG9wOiAycHg7XCI+U2VwYXJhdGUgbG9nb3MgZm9yIGRhcmsgYW5kIGxpZ2h0IHRoZW1lcywgb3IgdXNlIHRoZSBzYW1lIGZvciBib3RoLjwvcD5cbiAgICAgIDwvZGl2PlxuXG4gICAgICA8ZGl2IHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4OyBtYXJnaW4tYm90dG9tOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogIzFhMWEyZTsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgyNTUsMjU1LDI1NSwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1kYXJrXCIgc3JjPVwiL2Fzc2V0cy9kYXNoY2FkZHktbG9nby1kYXJrLnBuZ1wiIGFsdD1cIkRhcmsgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjOWFhNmJmOyBtYXJnaW4tdG9wOiA2cHg7XCI+RGFyayB0aGVtZXM8L3A+XG4gICAgICAgIDwvZGl2PlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTsgdGV4dC1hbGlnbjogY2VudGVyOyBwYWRkaW5nOiAxNnB4IDEwcHg7IGJvcmRlci1yYWRpdXM6IDhweDsgYmFja2dyb3VuZDogI2YwZjBmMDsgYm9yZGVyOiAxcHggc29saWQgcmdiYSgwLDAsMCwuMSk7XCI+XG4gICAgICAgICAgPGltZyBpZD1cImxvZ28tcHJldmlldy1saWdodFwiIHNyYz1cIi9hc3NldHMvZGFzaGNhZGR5LWxvZ28tbGlnaHQucG5nXCIgYWx0PVwiTGlnaHQgdGhlbWUgbG9nb1wiIHN0eWxlPVwibWF4LWhlaWdodDogNjBweDsgbWF4LXdpZHRoOiAxMDAlO1wiIC8+XG4gICAgICAgICAgPHAgc3R5bGU9XCJmb250LXNpemU6IDAuNjVyZW07IGNvbG9yOiAjNWY2YjdhOyBtYXJnaW4tdG9wOiA2cHg7XCI+TGlnaHQgdGhlbWVzPC9wPlxuICAgICAgICA8L2Rpdj5cbiAgICAgIDwvZGl2PlxuICAgICAgPHAgaWQ9XCJsb2dvLXN0YXR1c1wiIHN0eWxlPVwiZm9udC1zaXplOiAwLjc1cmVtOyBjb2xvcjogdmFyKC0tbXV0ZWQpOyBtYXJnaW4tYm90dG9tOiAxMnB4OyB0ZXh0LWFsaWduOiBjZW50ZXI7XCI+VXNpbmcgZGVmYXVsdCBsb2dvczwvcD5cblxuICAgICAgPGRpdiBjbGFzcz1cIm1iLTE2XCI+XG4gICAgICAgIDxsYWJlbCBzdHlsZT1cImRpc3BsYXk6IGZsZXg7IGFsaWduLWl0ZW1zOiBjZW50ZXI7IGdhcDogOHB4OyBmb250LXNpemU6IDAuODJyZW07IGN1cnNvcjogcG9pbnRlcjsgdXNlci1zZWxlY3Q6IG5vbmU7XCI+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJjaGVja2JveFwiIGlkPVwibG9nby1zYW1lLWJvdGhcIiAvPiBVc2Ugc2FtZSBsb2dvIGZvciBib3RoXG4gICAgICAgIDwvbGFiZWw+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tZHVhbC11cGxvYWRzXCIgY2xhc3M9XCJtYi0xNlwiIHN0eWxlPVwiZGlzcGxheTogZmxleDsgZ2FwOiAxMnB4O1wiPlxuICAgICAgICA8ZGl2IHN0eWxlPVwiZmxleDogMTtcIj5cbiAgICAgICAgICA8bGFiZWwgZm9yPVwibG9nby11cGxvYWQtZGFya1wiIHN0eWxlPVwiZGlzcGxheTogYmxvY2s7IG1hcmdpbi1ib3R0b206IDZweDsgZm9udC1zaXplOiAwLjhyZW07XCI+RGFyayB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1kYXJrXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiBzdHlsZT1cImZvbnQtc2l6ZTogMC43NXJlbTtcIiAvPlxuICAgICAgICA8L2Rpdj5cbiAgICAgICAgPGRpdiBzdHlsZT1cImZsZXg6IDE7XCI+XG4gICAgICAgICAgPGxhYmVsIGZvcj1cImxvZ28tdXBsb2FkLWxpZ2h0XCIgc3R5bGU9XCJkaXNwbGF5OiBibG9jazsgbWFyZ2luLWJvdHRvbTogNnB4OyBmb250LXNpemU6IDAuOHJlbTtcIj5MaWdodCB0aGVtZSBsb2dvOjwvbGFiZWw+XG4gICAgICAgICAgPGlucHV0IHR5cGU9XCJmaWxlXCIgaWQ9XCJsb2dvLXVwbG9hZC1saWdodFwiIGFjY2VwdD1cImltYWdlLypcIiBjbGFzcz1cImlucHV0LWNhcmQtYWx0XCIgc3R5bGU9XCJmb250LXNpemU6IDAuNzVyZW07XCIgLz5cbiAgICAgICAgPC9kaXY+XG4gICAgICA8L2Rpdj5cblxuICAgICAgPGRpdiBpZD1cImxvZ28tc2luZ2xlLXVwbG9hZFwiIGNsYXNzPVwibWItMTZcIiBzdHlsZT1cImRpc3BsYXk6IG5vbmU7XCI+XG4gICAgICAgIDxsYWJlbCBmb3I9XCJsb2dvLXVwbG9hZC1zaW5nbGVcIiBzdHlsZT1cImRpc3BsYXk6IGJsb2NrOyBtYXJnaW4tYm90dG9tOiA2cHg7IGZvbnQtc2l6ZTogMC44cmVtO1wiPlVwbG9hZCBsb2dvOjwvbGFiZWw+XG4gICAgICAgIDxpbnB1dCB0eXBlPVwiZmlsZVwiIGlkPVwibG9nby11cGxvYWQtc2luZ2xlXCIgYWNjZXB0PVwiaW1hZ2UvKlwiIGNsYXNzPVwiaW5wdXQtY2FyZC1hbHRcIiAvPlxuICAgICAgICA8cCBjbGFzcz1cInRpbnktaGludFwiPlRoaXMgbG9nbyB3aWxsIGJlIHVzZWQgb24gYWxsIHRoZW1lc
Line truncated
|
||||
Reference in new issue
Block a user